50 Commits
Author SHA1 Message Date
TZGyn 3ff5385a9b Show live swap status, explorer links, and full kyc.rip route details 2026-10-26 21:31:41 +08:00
TZGyn 2f70931cba Move the chain switcher into the settings drawer 2026-10-11 20:18:05 +08:00
TZGyn e67ed9e063 Show Monero accounts as their own section below the wallet card 2026-10-11 19:22:21 +08:00
TZGyn c9679e35c4 Give Monero sync its own section and consolidate the wallet buttons 2026-10-10 20:18:41 +08:00
TZGyn 7e739e1b77 Show Monero accounts as a stack of cards you can swipe through 2026-10-07 21:27:57 +08:00
TZGyn 4fa1584dfc Swap XMR and XRP through kyc.rip without leaving the app 2026-10-07 21:01:34 +08:00
TZGyn 31429e6923 Present sheets as a full bottom drawer 2026-10-03 23:42:46 +08:00
TZGyn 782143e87b Offer only USD, MYR and JPY in the currency settings 2026-10-01 22:08:46 +08:00
TZGyn 994ba5ed6b Give each Monero account a swipeable card with its balance and QR 2026-10-01 22:05:19 +08:00
TZGyn e3846fe881 Write the inverted icon variant to Branding, not the icon set 2026-09-29 21:20:58 +08:00
TZGyn edfefb6210 Give the app icon more padding 2026-09-29 21:16:24 +08:00
TZGyn 58d862161e Show each wallet's balance in USD 2026-09-29 21:02:45 +08:00
TZGyn 0b1b253d30 Make the whole transaction row tappable 2026-09-28 22:23:54 +08:00
TZGyn 78e1cefad4 Require Face ID to open the app and to send 2026-09-27 23:35:26 +08:00
TZGyn b989b0f791 Cache wallet balance and activity so screens show it instantly 2026-09-25 21:58:34 +08:00
TZGyn 2728bf161e List transactions newest first on both chains 2026-09-24 21:14:55 +08:00
TZGyn 51dc007107 Use the official Monero mark for the app icon 2026-09-23 21:08:23 +08:00
TZGyn 27dc122632 Fix the QR scanner showing a black preview 2026-09-22 22:45:25 +08:00
TZGyn e9720391b7 Add a black and white Monero app icon 2026-09-22 22:28:46 +08:00
TZGyn c0903f4f02 Fill in payments by scanning a QR code or picking one from photos 2026-09-20 02:00:52 +08:00
TZGyn 399502cf72 Stop a slow chain refresh from showing the wrong wallet balance 2026-09-20 01:30:42 +08:00
TZGyn e53c6aa7a9 Show each address QR in a bottom drawer 2026-09-20 00:57:51 +08:00
TZGyn 021672b47c Show full transaction details for Monero and XRP 2026-09-20 00:43:09 +08:00
TZGyn caca4321cc Add Monero subaccounts and subaddresses with a QR per address 2026-09-20 00:11:53 +08:00
TZGyn 1a23c730c6 Keep the Monero wallet out of iCloud backups 2026-09-19 22:54:58 +08:00
TZGyn 807388ea6b Keep Monero scan progress across app restarts 2026-09-19 22:17:00 +08:00
TZGyn f4037a188a Sign the iOS app for real devices and stop bundling the static archives 2026-09-19 19:25:01 +08:00
TZGyn 6d50566f37 Note the device build steps in the iOS readme 2026-09-19 13:44:11 +08:00
TZGyn 6b52b32e20 Document how the Monero iOS library is built 2026-09-19 13:43:08 +08:00
TZGyn a3af802166 Support real iOS devices with a device build of the Monero library 2026-09-19 13:42:04 +08:00
TZGyn 3e1a39d4d7 Replace the JavaScript mobile app with a native Swift iOS wallet 2026-09-19 13:33:11 +08:00
TZGyn d80b58b704 Point publish instructions at Gitea 2026-09-11 16:21:39 +08:00
TZGyn babd85e614 add vue lynx mobile wallet 2026-08-09 18:09:21 +08:00
TZGyn d15152a013 bump version to 0.0.12 2026-08-04 22:19:25 +08:00
TZGyn 3913889a22 add portfolio allocation donut chart on dashboard 2026-08-04 22:19:13 +08:00
TZGyn da0c56658b add QR image upload to send pages 2026-08-04 22:19:03 +08:00
TZGyn 743700b82c bump version to 0.0.11 2026-08-02 19:17:11 +08:00
TZGyn 8953596194 live sync state with status dot on dashboard 2026-08-02 19:17:02 +08:00
TZGyn 5df3ae7058 bump version to 0.0.10 2026-08-02 18:59:48 +08:00
TZGyn 7b4ba84fee global singleton wallet state with single bootstrap in layout 2026-08-02 18:59:31 +08:00
TZGyn 20f29faa80 bump version to 0.0.9 2026-08-02 18:44:53 +08:00
TZGyn c0f6417925 persist dashboard prices and balances in stores 2026-08-02 18:44:53 +08:00
TZGyn c61fea938a bump version to 0.0.8 2026-08-02 15:09:28 +08:00
TZGyn bdfee2a553 log monero sync state and refresh failures 2026-08-02 15:09:28 +08:00
TZGyn 1c5e5b617f bump version to 0.0.7 2026-08-02 14:54:49 +08:00
TZGyn 11e0bcdaf6 add timeouts so syncing wallet never hangs the UI 2026-08-02 14:54:39 +08:00
TZGyn 4270d380fb fix race calling open before wallet-rpc ready 2026-08-02 14:26:49 +08:00
TZGyn af6251669a add numbering system for pending lists 2026-08-02 14:23:17 +08:00
TZGyn 6eec1f998d bump version to 0.0.5 2026-08-02 14:15:39 +08:00
TZGyn d9ca8a0047 bump version to 0.0.4 2026-08-02 13:30:49 +08:00
107 changed files with 30269 additions and 245 deletions
+14 -3
View File
@@ -1,9 +1,20 @@
# Instructions for AI assistants
- Never commit changes unless explicitly told to do so. Always ask first or wait for the user's command.
- "Apply the changes" / "apply it" / "do it" does NOT mean commit. It means make the file changes. Before any commit (main repo, tap repo, anywhere), stop and ask for explicit permission, and list which files will be committed. Wait for a clear "yes" / "commit it" before running `git commit`.
- "Apply the changes" / "apply it" / "do it" does NOT mean commit. It means make the file changes. Before any commit (main repo, tap repo, anywhere), stop and ask for explicit permission, and list which files will be committed. Wait for a clear "yes" / "commit it" / "commit and push" before running `git commit`.
- Never manually edit `.router/router.ts` — sv-router auto-generates it from the filesystem. Just create the route file in the correct filesystem location.
- Only commit when explicitly told to do so. When asked to commit, always write a single simple line commit message.
- When asked to publish, always rebuild the DMG first (`bun run build:stable` from `apps/desktop`), wait for notarization to complete, verify with `xcrun stapler validate`, then publish the fresh DMG. Never publish a stale build.
- Never publish without explicit permission. Before any publish action — running `bun run build:stable`, `gh release upload`, updating the Homebrew cask, or pushing the tap repo — describe the intended action (what will be built, what SHA, which release) and ask for permission. Once you have asked and received a clear affirmative (any clear yes, "go", "publish", etc.), you may proceed — the affirmative applies to the action you described, and you must still pause before each subsequent publish step (e.g. upload, cask update) to ask for permission again.
- At the end of every response, always surface a "Pending from you" section listing anything you need from the user (decisions, permissions, credentials, confirming a plan, etc.). If nothing is pending, say "Nothing pending."
- Never publish without explicit permission. Before any publish action — running `bun run build:stable`, uploading a release asset to Gitea (`https://gitea.tzgyn.dev/TZGyn/koins`), updating the Homebrew cask, or pushing the tap repo (`https://gitea.tzgyn.dev/TZGyn/homebrew-tap`) — describe the intended action (what will be built, what SHA, which release) and ask for permission. Once you have asked and received a clear affirmative (any clear yes, "go", "publish", etc.), you may proceed through the whole pipeline without pausing between steps: build, notarize, verify, upload to the Gitea release, update the Homebrew cask, and push the Gitea tap repo. One permission covers the entire publish flow. You must still get a separate permission before starting each new publish cycle.
- Permission to commit ("commit", "commit and push", or similar) authorizes `git commit` and also `git push` of the committed changes. Only push the branches/files described in the permission.
- At the end of every response, always surface a "Pending from you" section listing anything you need from the user (decisions, permissions, credentials, confirming a plan, etc.). If nothing is pending, say "Nothing pending." List each pending item as a numbered entry (`1.`, `2.`, …) so the user can approve or act on them by number (e.g. "go ahead with 1, 2").
## iOS app (`apps/ios`)
Native SwiftUI wallet for Monero and XRP — no JavaScript runtime, no CocoaPods, no Swift packages.
- Regenerate the project after adding or removing files: `xcodegen generate` (from `apps/ios`).
- Simulator build: `xcodebuild -project Koins.xcodeproj -scheme Koins -configuration Debug -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' ARCHS=arm64 ONLY_ACTIVE_ARCH=NO -derivedDataPath /tmp/koins-dd build`.
- Crypto/serialization self-test: launch with `-koinsSelfTest`, then read `log show --predicate 'subsystem == "com.koins.app"'`.
- Live network self-test: launch with `-koinsSelfTestNetwork`.
- See `apps/ios/README.md` for the full layout and verification workflow.
+1 -1
View File
@@ -4,7 +4,7 @@ export default {
app: {
name: 'koins',
identifier: 'koins.tzgyn.com',
version: '0.0.3',
version: '0.0.12',
},
build: {
// Vite builds to dist/, we copy from there
+1
View File
@@ -20,6 +20,7 @@
"d3-shape": "^3.2.0",
"electrobun": "^1.18.1",
"env-paths": "^4.0.0",
"jsqr": "^1.4.0",
"mode-watcher": "^1.1.0",
"monero-ts": "^0.11.8",
"qrcode": "^1.5.4",
+132 -48
View File
@@ -1,11 +1,7 @@
import {
MoneroWalletRpc,
MoneroRpcConnection,
connectToWalletRpc,
connectToDaemonRpc,
} from 'monero-ts'
import { MoneroWalletRpc, MoneroRpcConnection, connectToWalletRpc, connectToDaemonRpc } from 'monero-ts'
import { getBinaryPath, getWalletDir } from './binary'
import { existsSync, readdirSync } from 'fs'
import { log, logError } from '../logger'
const DEFAULT_DAEMON = 'xmr-node.cakewallet.com:18081'
@@ -45,13 +41,40 @@ function rpcConnection(
})
}
function withTimeout<T>(
promise: Promise<T>,
ms: number,
label: string,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`${label} timed out after ${ms}ms`)),
ms,
)
promise.then(
(v) => {
clearTimeout(timer)
resolve(v)
},
(e) => {
clearTimeout(timer)
reject(e)
},
)
})
}
async function rawRpc(
state: MoneroWalletState,
method: string,
params: Record<string, any> = {},
): Promise<any> {
const conn = rpcConnection(state)
const res = await conn.sendJsonRequest(method, params)
const res = await withTimeout(
conn.sendJsonRequest(method, params),
30000,
`wallet-rpc ${method}`,
)
if (res?.error)
throw new Error(res.error.message || JSON.stringify(res.error))
return res?.result
@@ -65,20 +88,17 @@ async function waitForRpc(
let lastErr = ''
while (Date.now() - start < timeoutMs) {
try {
await Bun.connect({
hostname: '127.0.0.1',
port: state.rpcPort,
socket: {
open(s) {
s.end()
},
close() {},
data() {},
error() {},
},
})
const conn = rpcConnection(state)
const res = await withTimeout(
conn.sendJsonRequest('get_version', {}),
5000,
'wallet-rpc probe',
)
if (res?.result?.version) {
console.log(`[monero] RPC ready after ${Date.now() - start}ms`)
return
}
lastErr = JSON.stringify(res)
} catch (e) {
lastErr = String(e)
}
@@ -89,6 +109,28 @@ async function waitForRpc(
)
}
async function connectClient(
state: MoneroWalletState,
): Promise<MoneroWalletRpc> {
let lastErr = ''
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const client = (await connectToWalletRpc(
rpcUrl(state),
state.rpcUser,
state.rpcPassword,
)) as MoneroWalletRpc
console.log(`[monero] MoneroWalletRpc connected (attempt ${attempt})`)
return client
} catch (e) {
lastErr = String(e)
console.log(`[monero] connectToWalletRpc attempt ${attempt} failed:`, e)
if (attempt < 3) await new Promise((r) => setTimeout(r, 1000))
}
}
throw new Error(`Failed to connect to monero-wallet-rpc: ${lastErr}`)
}
export async function start(
state: MoneroWalletState,
daemonAddress: string = DEFAULT_DAEMON,
@@ -169,6 +211,9 @@ export async function start(
`[monero] wallet-rpc process exited with code ${exitCode}`,
error?.message ?? '',
)
log(
`[monero] wallet-rpc process exited with code ${exitCode} ${error?.message ?? ''}`,
)
if (state.saveTimer) {
clearInterval(state.saveTimer)
state.saveTimer = null
@@ -222,12 +267,9 @@ export async function start(
console.log(`[monero] RPC server is ready`)
console.log(`[monero] connecting MoneroWalletRpc client...`)
state.wallet = (await connectToWalletRpc(
rpcUrl(state),
state.rpcUser,
state.rpcPassword,
)) as MoneroWalletRpc
state.wallet = await connectClient(state)
console.log(`[monero] MoneroWalletRpc connected successfully`)
log(`[monero] wallet-rpc started, RPC ready`)
}
export async function stop(state: MoneroWalletState) {
@@ -241,21 +283,21 @@ export async function stop(state: MoneroWalletState) {
if (state.wallet) {
await rawRpc(state, 'store')
console.log(`[monero] wallet saved before stop`)
log(`[monero] wallet saved before stop`)
}
} catch (e) {
console.log(`[monero] wallet save before stop failed:`, e)
}
if (state.process) {
console.log(
`[monero] sending SIGTERM to wallet-rpc (pid ${state.process.pid})`,
)
state.process.kill()
await Promise.race([state.process.exited, sleep(3000)])
if (state.process.exitCode === null) {
const proc = state.process
console.log(`[monero] sending SIGTERM to wallet-rpc (pid ${proc.pid})`)
proc.kill()
await Promise.race([proc.exited, sleep(3000)])
if (proc.exitCode === null) {
console.log(
`[monero] wallet-rpc did not exit gracefully, sending SIGKILL`,
)
state.process.kill('SIGKILL')
proc.kill('SIGKILL')
}
state.process = null
}
@@ -279,7 +321,11 @@ export async function createWallet(
language: 'English',
})
if (!state.wallet) throw new Error('Wallet RPC not started')
const address = await state.wallet.getAddress(0, 0)
const address = (await withTimeout(
state.wallet.getAddress(0, 0),
30000,
'wallet get_address',
)) as string
console.log(`[monero] wallet created: ${name} -> ${address}`)
return { mnemonic: result.mnemonic, address }
}
@@ -310,9 +356,20 @@ export async function openWallet(
password: string,
) {
console.log(`[monero] opening wallet: ${name}`)
if (!state.wallet) throw new Error('Wallet RPC not started')
await state.wallet.openWallet(name, password)
console.log(`[monero] wallet opened: ${name}`)
// Self-heal: the client connection may have been lost while the
// wallet-rpc process is still running (e.g. stale-process cleanup).
if (!state.wallet) {
if (!state.process) throw new Error('Wallet RPC not started')
log(`[monero] reconnecting wallet client before open (${name})`)
state.wallet = await connectClient(state)
}
try {
await withTimeout(state.wallet.openWallet(name, password), 30000, `open_wallet ${name}`)
log(`[monero] wallet opened: ${name}`)
} catch (e) {
logError(`[monero] wallet open failed (${name})`, e)
throw e
}
}
export async function closeWallet(state: MoneroWalletState) {
@@ -326,8 +383,16 @@ export async function getBalance(
state: MoneroWalletState,
): Promise<{ balance: bigint; unlocked: bigint }> {
if (!state.wallet) throw new Error('Wallet RPC not started')
const balance = (await state.wallet.getBalance()) as bigint
const unlocked = (await state.wallet.getUnlockedBalance()) as bigint
const balance = (await withTimeout(
state.wallet.getBalance(),
30000,
'wallet get_balance',
)) as bigint
const unlocked = (await withTimeout(
state.wallet.getUnlockedBalance(),
30000,
'wallet get_unlocked_balance',
)) as bigint
console.log(`[monero] balance: ${balance} (unlocked: ${unlocked})`)
return { balance, unlocked }
}
@@ -338,9 +403,10 @@ export async function getAddress(
subIdx = 0,
): Promise<string> {
if (!state.wallet) throw new Error('Wallet RPC not started')
const address = (await state.wallet.getAddress(
accountIdx,
subIdx,
const address = (await withTimeout(
state.wallet.getAddress(accountIdx, subIdx),
30000,
'wallet get_address',
)) as string
console.log(`[monero] address: ${address}`)
return address
@@ -376,7 +442,11 @@ export async function getAccounts(
state: MoneroWalletState,
): Promise<any[]> {
if (!state.wallet) throw new Error('Wallet RPC not started')
const accounts = (await state.wallet.getAccounts(true)) as any[]
const accounts = (await withTimeout(
state.wallet.getAccounts(true),
30000,
'wallet get_accounts',
)) as any[]
console.log(`[monero] accounts: ${accounts?.length ?? 0} returned`)
if (accounts?.length) {
for (const acct of accounts) {
@@ -521,7 +591,11 @@ export async function getHeight(
state: MoneroWalletState,
): Promise<number> {
if (!state.wallet) throw new Error('Wallet RPC not started')
const height = (await state.wallet.getHeight()) as number
const height = (await withTimeout(
state.wallet.getHeight(),
30000,
'wallet get_height',
)) as number
console.log(`[monero] wallet height: ${height}`)
return height
}
@@ -565,7 +639,11 @@ export async function getDaemonHeight(
const daemon = await connectToDaemonRpc(
`http://${state.daemonAddress}`,
)
const height = (await daemon.getHeight()) as number
const height = (await withTimeout(
daemon.getHeight(),
15000,
'daemon get_height',
)) as number
console.log(`[monero] daemon height: ${height}`)
return height
} catch (e) {
@@ -643,9 +721,13 @@ export async function isWalletOpen(
): Promise<boolean> {
if (!state.wallet) return false
try {
await state.wallet.getHeight()
await withTimeout(state.wallet.getHeight(), 8000, 'wallet get_height')
return true
} catch {
} catch (e) {
// A timeout usually means the wallet-rpc is busy syncing, not that
// the wallet is closed. Assume still-open so the UI doesn't flip to
// "closed" and try to re-open an already-open wallet.
if (e instanceof Error && e.message.includes('timed out')) return true
return false
}
}
@@ -655,10 +737,12 @@ export async function isConnected(
): Promise<boolean> {
if (!state.wallet) return false
try {
const daemon = await connectToDaemonRpc(
`http://${state.daemonAddress}`,
const daemon = await withTimeout(
connectToDaemonRpc(`http://${state.daemonAddress}`),
8000,
'daemon connect',
)
await daemon.getHeight()
await withTimeout(daemon.getHeight(), 8000, 'daemon get_height')
return true
} catch (e) {
console.log(`[monero] connection check failed:`, e)
+27 -1
View File
@@ -24,6 +24,7 @@ import {
isConnected,
} from '../../lib/monero'
import type { MoneroWalletState } from '../../lib/monero'
import { log, logError } from '../../lib/logger'
export function createMoneroHandlers(state: {
manager: MoneroWalletState | null
@@ -64,10 +65,12 @@ export function createMoneroHandlers(state: {
await walletStart(state.manager, daemonAddress)
} catch (e) {
console.log('[rpc] moneroStart error:', e)
logError('[rpc] moneroStart error', e)
return { running: false, walletOpen: false, connected: false }
}
const connected = await isConnected(state.manager)
console.log('[rpc] moneroStart complete, connected:', connected)
log(`[rpc] moneroStart complete, connected: ${connected}`)
return { running: true, walletOpen: false, connected }
},
moneroStop: async () => {
@@ -105,12 +108,19 @@ export function createMoneroHandlers(state: {
console.log('[rpc] moneroRestoreWallet:', name)
if (!state.manager)
throw new Error('Monero wallet RPC not started')
// Default to ~2 days of history instead of a full genesis scan
// (which can take hours against a remote daemon).
let height = restoreHeight
if (!height || height === 0) {
const tip = await walletGetDaemonHeight(state.manager)
if (tip > 1440) height = tip - 1440
}
await walletRestore(
state.manager,
name,
password,
mnemonic,
restoreHeight,
height,
)
const address = await walletGetAddress(state.manager)
console.log(
@@ -129,12 +139,19 @@ export function createMoneroHandlers(state: {
console.log('[rpc] moneroOpenWallet:', name)
if (!state.manager)
throw new Error('Monero wallet RPC not started')
try {
await walletOpen(state.manager, name, password)
log(`[rpc] moneroOpenWallet ok: ${name}`)
} catch (e) {
logError(`[rpc] moneroOpenWallet failed (${name})`, e)
throw e
}
},
moneroGetBalance: async () => {
console.log('[rpc] moneroGetBalance')
if (!state.manager)
throw new Error('Monero wallet RPC not started')
try {
const { balance, unlocked } = await walletGetBalance(
state.manager,
)
@@ -155,6 +172,10 @@ export function createMoneroHandlers(state: {
height,
daemonHeight,
}
} catch (e) {
logError('[rpc] moneroGetBalance failed', e)
throw e
}
},
moneroGetTransactions: async ({
accountIndex,
@@ -164,12 +185,17 @@ export function createMoneroHandlers(state: {
console.log('[rpc] moneroGetTransactions', { accountIndex })
if (!state.manager)
throw new Error('Monero wallet RPC not started')
try {
const txs = await walletGetTransactions(
state.manager,
accountIndex,
)
console.log(`[rpc] moneroGetTransactions: ${txs.length} txs`)
return txs
} catch (e) {
logError('[rpc] moneroGetTransactions failed', e)
throw e
}
},
moneroWalletStatus: async () => {
if (!state.manager) {
@@ -0,0 +1,62 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js'
import Loader from '$lib/components/loader.svelte'
import QrCodeIcon from '@lucide/svelte/icons/qr-code'
import { decodeQrFromFile } from '$lib/qr.js'
let {
onDecode,
disabled = false,
}: {
onDecode: (text: string) => void
disabled?: boolean
} = $props()
let fileInput: HTMLInputElement
let busy = $state(false)
let error = $state('')
const handleFile = async (e: Event) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
busy = true
error = ''
try {
const text = await decodeQrFromFile(file)
if (!text) throw new Error('No QR code found in the image')
onDecode(text)
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to read QR code'
} finally {
busy = false
if (fileInput) fileInput.value = ''
}
}
</script>
<div class="flex items-center gap-2">
<input
bind:this={fileInput}
type="file"
accept="image/*"
class="hidden"
onchange={handleFile}
disabled={disabled || busy} />
<Button
type="button"
variant="outline"
size="sm"
class="h-7 gap-1.5"
disabled={disabled || busy}
onclick={() => fileInput?.click()}>
{#if busy}
<Loader class="size-3.5" />
{:else}
<QrCodeIcon size={14} />
{/if}
Scan QR
</Button>
{#if error}
<span class="text-xs text-red-500">{error}</span>
{/if}
</div>
+75
View File
@@ -0,0 +1,75 @@
import jsQR from 'jsqr'
export const decodeQrFromFile = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const url = URL.createObjectURL(file)
const img = new Image()
img.onload = () => {
try {
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Canvas is not supported')
ctx.drawImage(img, 0, 0)
const { data, width, height } = ctx.getImageData(
0,
0,
canvas.width,
canvas.height,
)
resolve(jsQR(data, width, height)?.data ?? '')
} catch (e) {
reject(e)
} finally {
URL.revokeObjectURL(url)
}
}
img.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error('Could not load the image'))
}
img.src = url
})
export type QrParseResult = {
address: string
amount?: string
destinationTag?: string
}
// Handles payment URIs (monero:ADDRESS?tx_amount=..., xrp:rADDR?amount=..&dt=..)
// as well as bare addresses. Returns the last path-like segment before any
// query string so prefixes and /pay paths are skipped.
export const parseQrPayload = (
text: string,
kind: 'xrp' | 'monero',
): QrParseResult => {
let body = text.trim().replace(/\s+/g, '')
body = body.replace(/^(?:[a-z0-9+.-]+):\/?\//i, '')
const [beforeQuery, queryRaw] = body.split('?')
const segments = beforeQuery.split('/')
const address = segments[segments.length - 1]
const params: Record<string, string> = {}
if (queryRaw) {
for (const pair of queryRaw.split('&')) {
const eq = pair.indexOf('=')
const key = (eq >= 0 ? pair.slice(0, eq) : pair).toLowerCase()
const value = eq >= 0 ? decodeURIComponent(pair.slice(eq + 1)) : ''
if (key) params[key] = value
}
}
if (kind === 'xrp') {
return {
address,
amount: params['amount'] || undefined,
destinationTag: params['dt'] || undefined,
}
}
return {
address,
amount: params['tx_amount'] || undefined,
}
}
@@ -1,5 +1,7 @@
import {
electrobun,
frontendLog,
frontendLogError,
type MoneroTxEntry,
type MoneroAccountEntry,
type MoneroSendResult,
@@ -25,7 +27,7 @@ export const atomicToXmr = (atomic: string): string => {
return frac ? `${whole}.${frac}` : `${whole}`
}
export const MoneroWallet = () => {
export const createMoneroWallet = () => {
let accountType = $state<'monero' | null>(null)
let ready = $state(false)
let biometricAvailable = $state(false)
@@ -51,6 +53,30 @@ export const MoneroWallet = () => {
let passwordRequired = $state(false)
let startInFlight: Promise<{ running: boolean; connected: boolean }> | null = null
let loginInFlight: Promise<void> | null = null
let refreshInFlight: Promise<void> | null = null
let syncing = $state(false)
let syncRetryTimer: ReturnType<typeof setTimeout> | null = null
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
let price = $state<string | null>(null)
let priceInFlight = false
const rpcTimeout = <T>(promise: Promise<T>, ms: number, label: string): Promise<T> =>
new Promise<T>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`${label} timed out after ${ms}ms`)),
ms,
)
promise.then(
(v) => {
clearTimeout(timer)
resolve(v)
},
(e) => {
clearTimeout(timer)
reject(e)
},
)
})
const beginOpening = () => {
openingDepth += 1
@@ -80,7 +106,10 @@ export const MoneroWallet = () => {
return authed === true
}
let initPromise: Promise<void> | null = null
const init = async () => {
if (initPromise) return initPromise
initPromise = (async () => {
await checkStatus()
if (installed && !running && !downloading) {
await start()
@@ -88,6 +117,8 @@ export const MoneroWallet = () => {
}
await checkBiometric()
ready = true
})()
return initPromise
}
const login = async () => {
@@ -95,11 +126,21 @@ export const MoneroWallet = () => {
if (loginInFlight) return loginInFlight
loginInFlight = (async () => {
accountType = 'monero'
error = ''
beginOpening()
try {
if (installed && !running) {
await start()
await checkStatus()
} else if (startInFlight) {
// a start is already in flight (e.g. triggered by init);
// wait for it so we don't call open before the server is up
await startInFlight.catch(() => {})
await checkStatus()
}
if (!running) {
error = 'Wallet server failed to start. Please try again.'
return
}
await listWallets()
if (wallets.length > 0) {
@@ -193,14 +234,22 @@ export const MoneroWallet = () => {
const checkStatus = async () => {
if (!electrobun.rpc) return
const [binStatus] = await tryCatch(
rpcTimeout(
electrobun.rpc.request.moneroBinaryStatus({}),
15000,
'moneroBinaryStatus',
),
)
if (binStatus) {
installed = binStatus.installed
downloading = binStatus.downloading
}
const [status] = await tryCatch(
rpcTimeout(
electrobun.rpc.request.moneroWalletStatus({}),
30000,
'moneroWalletStatus',
),
)
if (status) {
running = status.running
@@ -352,13 +401,60 @@ export const MoneroWallet = () => {
if (remember) await moneroStorePassword(target, password)
}
const scheduleSyncRetry = () => {
if (syncRetryTimer) clearTimeout(syncRetryTimer)
syncRetryTimer = setTimeout(async () => {
syncRetryTimer = null
if (walletOpen && !loading) await refresh()
}, 15000)
}
const stopAutoRefresh = () => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer)
autoRefreshTimer = null
}
}
// Refresh runs in the store so the dashboard's connection/sync state
// stays live regardless of which page is mounted.
const startAutoRefresh = () => {
if (autoRefreshTimer || !walletOpen) return
autoRefreshTimer = setInterval(() => {
if (walletOpen && !loading) refresh()
}, 30000)
}
// $effect needs a root outside a component; the root lives for the
// lifetime of the singleton store.
$effect.root(() => {
$effect(() => {
if (walletOpen && !loading) startAutoRefresh()
else if (!walletOpen) stopAutoRefresh()
})
})
const refresh = async () => {
if (!electrobun.rpc) return
const rpc = electrobun.rpc
if (!rpc) return
if (refreshInFlight) return refreshInFlight
refreshInFlight = (async () => {
loading = true
let failed = false
const wasSyncing = syncing
let lastError: unknown = null
try {
const [bal] = await tryCatch(
electrobun.rpc.request.moneroGetBalance({}),
const [bal, balErr] = await tryCatch(
rpcTimeout(
rpc.request.moneroGetBalance({}),
20000,
'moneroGetBalance',
),
)
if (balErr) {
failed = true
lastError = balErr
}
if (bal) {
balAtomic = bal.balance
unlockedAtomic = bal.unlocked
@@ -366,17 +462,64 @@ export const MoneroWallet = () => {
height = bal.height
daemonHeight = bal.daemonHeight
}
const [result] = await tryCatch(
electrobun.rpc.request.moneroGetTransactions({
const [result, txErr] = await tryCatch(
rpcTimeout(
rpc.request.moneroGetTransactions({
accountIndex: selectedAccountIndex,
}),
20000,
'moneroGetTransactions',
),
)
if (txErr) {
failed = true
lastError = txErr
}
txs = result ?? []
await fetchAccounts()
const ok = await fetchAccounts()
if (!ok) {
failed = true
lastError = new Error('moneroGetAccounts failed')
}
if (failed) {
// The wallet-rpc is likely busy syncing; keep the stale
// balance and retry shortly instead of showing an error.
syncing = true
scheduleSyncRetry()
frontendLogError(
`monero refresh failed (height ${height}/${daemonHeight})`,
lastError,
)
} else {
if (wasSyncing) {
frontendLog(`monero sync complete at height ${height}`)
}
syncing = false
}
} catch (e) {
error = e instanceof Error ? e.message : 'Monero refresh failed'
syncing = true
scheduleSyncRetry()
frontendLogError(
`monero refresh failed (height ${height}/${daemonHeight})`,
e,
)
} finally {
loading = false
refreshInFlight = null
}
})()
return refreshInFlight
}
const fetchPrice = async () => {
const rpc = electrobun.rpc
if (!rpc || priceInFlight) return
priceInFlight = true
try {
const [p] = await tryCatch(rpc.request.fetchMoneroPrice({}))
if (p) price = p.usd
} finally {
priceInFlight = false
}
}
@@ -496,6 +639,12 @@ export const MoneroWallet = () => {
get passwordRequired() {
return passwordRequired
},
get syncing() {
return syncing
},
get price() {
return price
},
get error() {
return error
},
@@ -545,6 +694,7 @@ export const MoneroWallet = () => {
autoUnlock,
unlockWallet,
refresh,
fetchPrice,
send,
sendAll,
createAccount,
@@ -552,4 +702,15 @@ export const MoneroWallet = () => {
}
}
// Singleton: calling MoneroWallet() always returns the same instance, so
// no component can ever create a second, disconnected wallet state.
type MoneroWalletStore = ReturnType<typeof createMoneroWallet>
let moneroWalletInstance: MoneroWalletStore | null = null
export const MoneroWallet = (): MoneroWalletStore => {
if (!moneroWalletInstance) moneroWalletInstance = createMoneroWallet()
return moneroWalletInstance
}
export const moneroWallet = MoneroWallet()
@@ -13,7 +13,7 @@ export const xrpNetwork = {
explorerAddressUrl: 'https://xrpscan.com/account/',
} as const
export const XrpWallet = () => {
export const createXrpWallet = () => {
let accountType = $state<'xrp' | null>(null)
let ready = $state(false)
let biometricAvailable = $state(false)
@@ -29,6 +29,9 @@ export const XrpWallet = () => {
let loading = $state(false)
let error = $state('')
let wallets = $state<XrpWalletInfo[]>([])
let balances = $state<Record<string, { balance: number; funded: boolean }>>({})
let balancesLoading = $state(false)
let fetchBalancesInFlight = false
let currentWalletId = $state<string | null>(null)
let currentPasswordHash = $state<string | null>(null)
@@ -73,7 +76,10 @@ export const XrpWallet = () => {
biometricAvailable = ok === true
}
let initPromise: Promise<void> | null = null
const init = async () => {
if (initPromise) return initPromise
initPromise = (async () => {
if (!electrobun.rpc) return
const [walletList] = await tryCatch(
electrobun.rpc.request.xrpListWallets({}),
@@ -86,6 +92,8 @@ export const XrpWallet = () => {
}
await checkBiometric()
ready = true
})()
return initPromise
}
const login = async () => {
@@ -319,6 +327,34 @@ export const XrpWallet = () => {
})()
}
const fetchBalances = async () => {
const rpc = electrobun.rpc
if (!rpc || fetchBalancesInFlight) return
if (wallets.length === 0) return
fetchBalancesInFlight = true
balancesLoading = true
try {
const [p] = await tryCatch(rpc.request.fetchXrpPrice({}))
if (p) price = p.usd
await Promise.allSettled(
wallets.map(async (w) => {
const [res] = await tryCatch(
rpc.request.xrpGetBalance({ address: w.address }),
)
if (res) {
balances[w.address] = {
balance: parseFloat(res.balance),
funded: res.funded,
}
}
}),
)
} finally {
balancesLoading = false
fetchBalancesInFlight = false
}
}
const send = async (
to: string,
amount: string,
@@ -399,6 +435,12 @@ export const XrpWallet = () => {
get wallets() {
return wallets
},
get balances() {
return balances
},
get balancesLoading() {
return balancesLoading
},
get currentWalletId() {
return currentWalletId
},
@@ -424,6 +466,7 @@ export const XrpWallet = () => {
login,
logout,
refresh,
fetchBalances,
createWallet,
importWallet,
lock,
@@ -442,4 +485,15 @@ export const XrpWallet = () => {
}
}
// Singleton: calling XrpWallet() always returns the same instance, so no
// component can ever create a second, disconnected wallet state.
type XrpWalletStore = ReturnType<typeof createXrpWallet>
let xrpWalletInstance: XrpWalletStore | null = null
export const XrpWallet = (): XrpWalletStore => {
if (!xrpWalletInstance) xrpWalletInstance = createXrpWallet()
return xrpWalletInstance
}
export const xrpWallet = XrpWallet()
+110 -76
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import { moneroWallet } from '$lib/states/monero-wallet.svelte.js'
import { xrpWallet } from '$lib/states/xrp-wallet.svelte.js'
import { electrobun } from '$lib/electrobun.js'
import { navigate } from 'sv-router/generated'
import { onMount } from 'svelte'
import {
@@ -16,29 +15,23 @@
import WalletIcon from '@lucide/svelte/icons/wallet'
import Loader from '$lib/components/loader.svelte'
import { frontendLog, frontendLogError } from '$lib/electrobun.js'
let xrpPrice = $state<string | null>(null)
let xmrPrice = $state<string | null>(null)
let xrpBalances = $state<Record<string, { balance: number; funded: boolean }>>({})
let loadingXrp = $state(true)
let loadingXmr = $state(true)
import { arc } from 'd3-shape'
import { tweened } from 'svelte/motion'
import { cubicOut } from 'svelte/easing'
let feError = $state<string | null>(null)
const usd = (amount: string, price: string | null) =>
price ? parseFloat(amount) * parseFloat(price) : 0
const xrpTotalUsd = $derived.by(() => {
const price = xrpPrice
const p = xrpWallet.price
return xrpWallet.wallets.reduce((sum, w) => {
const b = xrpBalances[w.address]?.balance ?? 0
return sum + (price ? b * parseFloat(price) : 0)
const b = xrpWallet.balances[w.address]?.balance ?? 0
return sum + (p ? b * parseFloat(p) : 0)
}, 0)
})
const xrpTotalBalance = $derived.by(() =>
xrpWallet.wallets.reduce((sum, w) => {
return sum + (xrpBalances[w.address]?.balance ?? 0)
return sum + (xrpWallet.balances[w.address]?.balance ?? 0)
}, 0),
)
@@ -46,17 +39,54 @@
moneroWallet.walletOpen ? parseFloat(moneroWallet.balance || '0') : 0,
)
const xmrTotalUsd = $derived(
moneroWallet.walletOpen && xmrPrice
? xmrBalance * parseFloat(xmrPrice)
moneroWallet.walletOpen && moneroWallet.price
? xmrBalance * parseFloat(moneroWallet.price)
: 0,
)
const portfolioUsd = $derived(xrpTotalUsd + xmrTotalUsd)
const xrpShare = $derived(portfolioUsd > 0 ? xrpTotalUsd / portfolioUsd : 0)
const xmrShare = $derived(
portfolioUsd > 0 ? xmrTotalUsd / portfolioUsd : 0,
)
const tweenedUsd = tweened(0, { duration: 600, easing: cubicOut })
const tweenedXrpShare = tweened(0, { duration: 600, easing: cubicOut })
$effect(() => {
tweenedUsd.set(portfolioUsd)
tweenedXrpShare.set(xrpShare)
})
const donut = $derived.by(() => {
if (portfolioUsd <= 0) return { xrp: '', xmr: '' }
const a = arc<{ startAngle: number; endAngle: number }>()
.innerRadius(62)
.outerRadius(80)
.padAngle(0.02)
const start = -Math.PI / 2
const endXrp = start + $tweenedXrpShare * 2 * Math.PI
return {
xrp: a({ startAngle: start, endAngle: endXrp }) ?? '',
xmr: a({ startAngle: endXrp, endAngle: start + 2 * Math.PI }) ?? '',
}
})
const hasXrp = $derived(xrpWallet.wallets.length > 0)
const hasXmr = $derived(moneroWallet.wallets.length > 0)
const noWallets = $derived(!hasXrp && !hasXmr)
const moneroDot = $derived(
!moneroWallet.running
? 'bg-muted'
: !moneroWallet.connected
? 'bg-red-500'
: moneroWallet.syncing || moneroWallet.height < moneroWallet.daemonHeight
? 'bg-yellow-500 animate-pulse'
: 'bg-emerald-500',
)
const fmtUsd = (v: number) =>
v.toLocaleString(undefined, {
minimumFractionDigits: 2,
@@ -69,57 +99,23 @@
maximumFractionDigits: dp,
})
async function fetchXrp() {
loadingXrp = true
const rpc = electrobun.rpc
if (!rpc) {
loadingXrp = false
return
}
try {
const p = await rpc.request.fetchXrpPrice({})
if (p) xrpPrice = p.usd
} catch { /* ignore */ }
await Promise.allSettled(
xrpWallet.wallets.map(async (w) => {
const res = await rpc.request.xrpGetBalance({
address: w.address,
})
xrpBalances[w.address] = {
balance: parseFloat(res.balance),
funded: res.funded,
}
}),
)
loadingXrp = false
}
async function fetchXmr() {
loadingXmr = true
const rpc = electrobun.rpc
if (!rpc) {
loadingXmr = false
return
}
try {
const p = await rpc.request.fetchMoneroPrice({})
xmrPrice = p?.usd ?? null
} catch { /* ignore */ }
loadingXmr = false
}
const fmtPct = (v: number) => `${Math.round(v * 100)}%`
onMount(() => {
frontendLog('dashboard mounted')
moneroWallet.init().catch((e) => frontendLogError('moneroWallet.init() failed', e))
xrpWallet.init().then(async () => {
await fetchXrp()
})
fetchXmr()
// Balances live in the stores so they survive navigation; the effects
// only trigger background refetches.
$effect(() => {
if (xrpWallet.ready && xrpWallet.wallets.length > 0) {
xrpWallet.fetchBalances()
}
})
$effect(() => {
if (moneroWallet.walletOpen) {
fetchXmr()
moneroWallet.fetchPrice()
}
})
@@ -181,7 +177,7 @@
{:else}
<div class="flex flex-col items-center pt-4 pb-2">
<p class="text-xs text-muted-foreground">Total Portfolio Value</p>
{#if loadingXrp || loadingXmr || moneroWallet.opening || (moneroWallet.wallets.length > 0 && !moneroWallet.walletOpen)}
{#if moneroWallet.opening || (moneroWallet.wallets.length > 0 && !moneroWallet.walletOpen) || (xrpWallet.wallets.length > 0 && !xrpWallet.price) || (moneroWallet.walletOpen && !moneroWallet.price)}
<div class="mt-2 h-9 w-44 rounded-md bg-muted animate-pulse"></div>
{:else}
<p class="mt-1 text-3xl font-semibold tabular-nums">
@@ -190,6 +186,43 @@
{/if}
</div>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-base">Portfolio Allocation</CardTitle>
<CardDescription class="text-xs">
Share of your balance by asset
</CardDescription>
</CardHeader>
<CardContent class="flex flex-col items-center gap-5 py-6">
<div class="relative">
<svg viewBox="-100 -100 200 200" class="size-48">
<path d={donut.xrp} fill="#0EA5E9" />
<path d={donut.xmr} fill="#F97316" />
</svg>
<div
class="absolute inset-0 flex flex-col items-center justify-center">
<p
class="text-lg font-semibold tabular-nums">$
{fmtUsd($tweenedUsd)}
</p>
<p class="text-xs text-muted-foreground">
Portfolio
</p>
</div>
</div>
<div class="flex gap-5 text-xs">
<span class="flex items-center gap-1.5">
<span class="size-2.5 rounded-full bg-sky-500"></span>
XRP · {fmtPct($tweenedXrpShare)}
</span>
<span class="flex items-center gap-1.5">
<span class="size-2.5 rounded-full bg-orange-500"></span>
XMR · {fmtPct(1 - $tweenedXrpShare)}
</span>
</div>
</CardContent>
</Card>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<button
onclick={() => navigate('/xrp')}
@@ -200,21 +233,19 @@
<img src="/icons/xrp.png" alt="" class="size-5 rounded-full" />
<CardTitle class="text-base">XRP</CardTitle>
</div>
<CardDescription class="text-xs">
{xrpWallet.wallets.length} wallet{xrpWallet.wallets.length !== 1 ? 's' : ''}
</CardDescription>
<CardDescription class="text-xs">XRP Ledger</CardDescription>
</CardHeader>
<CardContent class="flex flex-1 flex-col justify-center">
{#if loadingXrp}
{#if !xrpWallet.ready || (xrpWallet.wallets.length > 0 && Object.keys(xrpWallet.balances).length === 0)}
<div class="h-14 flex items-center"><Loader /></div>
{:else}
<p class="font-mono text-xl tabular-nums">{fmtBal(xrpTotalBalance)}</p>
{#if xrpPrice}
{#if xrpWallet.price}
<p class="mt-0.5 text-sm text-muted-foreground tabular-nums">
${fmtUsd(xrpTotalUsd)}
</p>
<p class="mt-2 text-xs text-muted-foreground">
${fmtUsd(parseFloat(xrpPrice) )} / XRP
${fmtUsd(parseFloat(xrpWallet.price) )} / XRP
</p>
{/if}
{/if}
@@ -231,23 +262,28 @@
<img src="/icons/monero.png" alt="" class="size-5 rounded-full" />
<CardTitle class="text-base">Monero</CardTitle>
</div>
<CardDescription class="text-xs">
{moneroWallet.walletOpen
? moneroWallet.walletName || 'Wallet open'
: moneroWallet.wallets.length > 0
? `${moneroWallet.wallets.length} wallet${moneroWallet.wallets.length !== 1 ? 's' : ''}`
: 'Not set up'}
<CardDescription class="flex items-center gap-1.5 text-xs">
<span class="size-2 shrink-0 rounded-full {moneroDot}"></span>
{#if !moneroWallet.running}
Server offline
{:else if !moneroWallet.connected}
Connecting to daemon…
{:else}
{fmtBal(moneroWallet.height, 0)} / {fmtBal(moneroWallet.daemonHeight, 0)}
{/if}
</CardDescription>
</CardHeader>
<CardContent class="flex flex-1 flex-col justify-center">
{#if moneroWallet.walletOpen}
<p class="font-mono text-xl tabular-nums">{fmtBal(xmrBalance, 4)}</p>
{#if xmrPrice}
{#if !moneroWallet.syncing && moneroWallet.price}
<p class="mt-0.5 text-sm text-muted-foreground tabular-nums">
${fmtUsd(xmrTotalUsd)}
</p>
{/if}
{#if moneroWallet.price}
<p class="mt-2 text-xs text-muted-foreground">
${fmtUsd(parseFloat(xmrPrice) )} / XMR
${fmtUsd(parseFloat(moneroWallet.price) )} / XMR
</p>
{/if}
{:else if moneroWallet.passwordRequired && moneroWallet.wallets.length > 0}
@@ -275,8 +311,6 @@
<Loader />
<p class="text-sm text-muted-foreground">Opening wallet...</p>
</div>
{:else if loadingXmr}
<div class="h-14 flex items-center"><Loader /></div>
{:else}
<Button
onclick={() => navigate('/monero')}
+1
View File
@@ -31,6 +31,7 @@
if (!xrpWallet.ready) await xrpWallet.init()
if (!moneroWallet.ready) await moneroWallet.init()
await Promise.allSettled([
xrpWallet.login(),
xrpWallet.autoUnlock(),
moneroWallet.login(),
])
@@ -24,8 +24,6 @@
let unlocking = $state(false)
let unlockError = $state('')
let initStarted = false
async function unlockWithPassword() {
if (unlocking || !walletPassword.trim()) return
unlocking = true
@@ -48,16 +46,6 @@
}
})
$effect(() => {
if (!initStarted) {
initStarted = true
w.init().then(async () => {
if (w.accountType === 'monero') return
await w.login()
})
}
})
$effect(() => {
if (w.running && !w.walletOpen && w.wallets.length > 0 && !w.loading && !w.opening) {
w.autoUnlock(w.wallets[0])
@@ -19,6 +19,8 @@
SelectTrigger,
} from '$lib/components/ui/select/index.js'
import { navigate } from 'sv-router/generated'
import QrUpload from '$lib/components/qr-upload.svelte'
import { parseQrPayload } from '$lib/qr.js'
const w = moneroWallet
@@ -134,7 +136,15 @@
</div>
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<label class="text-xs font-medium">Recipient Address</label>
<QrUpload
onDecode={(text) => {
const parsed = parseQrPayload(text, 'monero')
recipient = parsed.address
if (parsed.amount && !sendAll) amountXmr = parsed.amount
}} />
</div>
<Input
placeholder="Monero address starting with 4..."
bind:value={recipient} />
@@ -15,10 +15,6 @@
const w = moneroWallet
let moneroSettingsPw = $state('')
$effect(() => {
w.init()
})
</script>
<div class="mx-auto max-w-xl">
@@ -154,7 +154,6 @@
$effect(() => {
if (w.walletOpen && !autoRefreshTimer) {
autoRefreshTimer = setInterval(() => {
w.refresh()
electrobun.rpc?.request
.fetchMoneroPrice({})
.then((p) => (moneroPrice = p))
@@ -203,7 +202,7 @@
{:else}
<div class="space-y-6">
<div class="flex flex-col items-center py-2">
{#if w.loading}
{#if w.loading && !w.syncing}
<div class="h-12 w-40 rounded-lg bg-muted animate-pulse"></div>
{:else}
<p class="text-sm text-muted-foreground">Balance</p>
@@ -223,6 +222,14 @@
Unlocked: {w.unlocked} XMR
</p>
{/if}
{#if w.syncing}
<div class="mt-2 flex items-center gap-2">
<Loader />
<p class="text-xs text-muted-foreground">
Syncing wallet — balance updates when the scan completes
</p>
</div>
{/if}
<div class="mt-2 flex items-center gap-1.5">
<div class="size-2 rounded-full {w.connected ? 'bg-green-500' : 'bg-red-500'}"></div>
<span class="text-xs text-muted-foreground">
-12
View File
@@ -22,18 +22,6 @@
let inputUnlockPassword = $state('')
let newSeed = $state<string | null>(null)
let initStarted = false
$effect(() => {
if (!initStarted) {
initStarted = true
w.init().then(async () => {
if (w.accountType === 'xrp') return
await w.login()
})
}
})
$effect(() => {
if (w.accountType === 'xrp' && w.currentWalletId && w.seed && !newSeed) {
navigate('/xrp/wallet/:id', {
+11
View File
@@ -10,6 +10,8 @@
CardDescription,
} from '$lib/components/ui/card/index.js'
import { navigate } from 'sv-router/generated'
import QrUpload from '$lib/components/qr-upload.svelte'
import { parseQrPayload } from '$lib/qr.js'
const w = xrpWallet
@@ -78,7 +80,16 @@
}}
class="space-y-4">
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<label class="text-xs font-medium">Recipient Address</label>
<QrUpload
onDecode={(text) => {
const parsed = parseQrPayload(text, 'xrp')
recipient = parsed.address
if (parsed.destinationTag) destinationTag = parsed.destinationTag
if (parsed.amount) amountXrp = parsed.amount
}} />
</div>
<Input
placeholder="XRP address starting with r..."
bind:value={recipient} />
+9
View File
@@ -0,0 +1,9 @@
# Generated by XcodeGen from project.yml
Koins.xcodeproj/
# Build output
build/
DerivedData/
*.xcworkspace
xcuserdata/
*.xcuserstate
+32
View File
@@ -0,0 +1,32 @@
# Branding
The app icon is the **official Monero mark**, monochrome.
`logo-official.svg` is `meta/safari-pinned-tab.svg` from
[monero-project/monero-site](https://github.com/monero-project/monero-site) —
the Monero Project's own traced, single-colour version of the logo, which is
exactly what a black-and-white icon needs. `logo-official.png` is that file
rasterised to 1024×1024:
```sh
qlmanage -t -s 1024 -o /tmp Branding/logo-official.svg
```
iOS cannot use SVG for app icons, so `make-icons.swift` produces the PNG the
asset catalog needs — the mark as published (black badge, white M) inset to 90%
so the platform's squircle mask does not clip it:
```sh
cd apps/ios
swift Branding/make-icons.swift Koins/Assets.xcassets/AppIcon.appiconset
```
That writes the opaque 1024×1024 app icon (no alpha channel — the App Store
rejects icons with alpha) and `Branding/logo-inverted.png`, the same mark
inverted for dark surfaces (white badge, black M, black ground).
The asset catalog uses the single-size format, so Xcode derives every size the
device needs from the 1024.
Note: the Monero name and mark belong to the Monero Project — check their brand
guidance before shipping this publicly.
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M3370 6894 c-410 -21 -760 -95 -1116 -235 -1129 -445 -1939 -1467
-2114 -2666 -69 -479 -37 -963 97 -1436 l36 -127 508 0 509 0 0 1427 0 1428
1105 -1105 1105 -1105 1105 1105 1105 1105 0 -1428 0 -1427 509 0 508 0 36
127 c134 473 166 957 97 1436 -212 1456 -1350 2616 -2800 2856 -207 34 -511
54 -690 45z"/>
<path d="M2028 2635 l-3 -900 -713 -5 -713 -5 79 -117 c526 -777 1341 -1305
2252 -1457 200 -34 338 -45 570 -45 305 0 533 27 805 93 824 202 1538 700
2016 1409 l80 117 -716 3 -715 2 0 903 0 902 -735 -735 -735 -735 -735 735
-735 735 -2 -900z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+134
View File
@@ -0,0 +1,134 @@
// Regenerates the app icon PNGs from the official Monero mark.
//
// cd apps/ios
// swift Branding/make-icons.swift Koins/Assets.xcassets/AppIcon.appiconset
//
// Input: Branding/logo-official.png — the monochrome mark published in
// monero-project/monero-site (meta/safari-pinned-tab.svg), rasterised.
// It is a black badge with the M knocked out, drawn on white.
// Output: AppIcon-1024.png - the inverse: white badge, black M, on black
// (reads well on the app's dark UI)
// AppIcon-light-1024.png - the mark as published: black badge, white M,
// on white
//
// The artwork is inset to 90% of the canvas so the iOS squircle mask does not
// clip the badge's edges. iOS cannot use SVG for app icons, which is why this
// rasterises instead of linking the SVG.
import AppKit
import CoreGraphics
import Foundation
struct Bitmap {
let width: Int
let height: Int
var pixels: [UInt8] // RGBA, premultiplied
func luminance(_ offset: Int) -> Int {
(Int(pixels[offset]) * 299 + Int(pixels[offset + 1]) * 587 + Int(pixels[offset + 2]) * 114) / 1000
}
}
func loadBitmap(_ path: String) -> Bitmap? {
guard let image = NSImage(contentsOfFile: path),
let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil }
let width = cgImage.width
let height = cgImage.height
var pixels = [UInt8](repeating: 0, count: width * height * 4)
guard let context = CGContext(data: &pixels,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: width * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
return Bitmap(width: width, height: height, pixels: pixels)
}
func makeImage(_ bitmap: Bitmap) -> CGImage? {
guard let provider = CGDataProvider(data: Data(bitmap.pixels) as CFData) else { return nil }
return CGImage(width: bitmap.width,
height: bitmap.height,
bitsPerComponent: 8,
bitsPerPixel: 32,
bytesPerRow: bitmap.width * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
provider: provider,
decode: nil,
shouldInterpolate: false,
intent: .defaultIntent)
}
func inverted(_ bitmap: Bitmap) -> Bitmap {
var pixels = bitmap.pixels
for offset in stride(from: 0, to: pixels.count, by: 4) {
let value = UInt8(255 - bitmap.luminance(offset))
pixels[offset] = value
pixels[offset + 1] = value
pixels[offset + 2] = value
}
return Bitmap(width: bitmap.width, height: bitmap.height, pixels: pixels)
}
/// Composes the mark onto a square background, inset so the platform mask has
/// room, and writes an opaque PNG (App Store icons must not carry alpha).
func writeIcon(_ source: Bitmap, size: Int, inset: CGFloat, background: NSColor, to path: String) {
guard let image = makeImage(source) else { return }
var pixels = [UInt8](repeating: 0, count: size * size * 4)
guard let context = CGContext(data: &pixels,
width: size,
height: size,
bitsPerComponent: 8,
bytesPerRow: size * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return }
context.setFillColor(background.cgColor)
context.fill(CGRect(x: 0, y: 0, width: size, height: size))
let side = CGFloat(size) * inset
let offset = (CGFloat(size) - side) / 2
context.interpolationQuality = .high
context.draw(image, in: CGRect(x: offset, y: offset, width: side, height: side))
var rgb = [UInt8](repeating: 0, count: size * size * 3)
for index in 0..<(size * size) {
rgb[index * 3] = pixels[index * 4]
rgb[index * 3 + 1] = pixels[index * 4 + 1]
rgb[index * 3 + 2] = pixels[index * 4 + 2]
}
guard let provider = CGDataProvider(data: Data(rgb) as CFData),
let flattened = CGImage(width: size,
height: size,
bitsPerComponent: 8,
bitsPerPixel: 24,
bytesPerRow: size * 3,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue),
provider: provider,
decode: nil,
shouldInterpolate: false,
intent: .defaultIntent) else { return }
let representation = NSBitmapImageRep(cgImage: flattened)
guard let data = representation.representation(using: .png, properties: [:]) else { return }
try? data.write(to: URL(fileURLWithPath: path))
print("wrote \(path)")
}
let arguments = CommandLine.arguments
let outputDirectory = arguments.count > 1 ? arguments[1] : "/tmp"
let sourcePath = arguments.count > 2 ? arguments[2] : "Branding/logo-official.png"
try? FileManager.default.createDirectory(atPath: outputDirectory, withIntermediateDirectories: true)
guard let source = loadBitmap(sourcePath) else {
print("cannot read source artwork at \(sourcePath)")
exit(1)
}
let inset: CGFloat = 0.78
// Primary: the mark exactly as Monero publishes it (black badge, white M).
writeIcon(source, size: 1024, inset: inset, background: .white,
to: "\(outputDirectory)/AppIcon-1024.png")
// Alternate: inverted, for dark surfaces (white badge, black M, black ground).
writeIcon(inverted(source), size: 1024, inset: inset, background: .black,
to: "Branding/logo-inverted.png")
+588
View File
@@ -0,0 +1,588 @@
import Foundation
import SwiftUI
@MainActor
final class AppModel: ObservableObject {
@Published var chain: Chain = .xrp
@Published var xrpWallet: WalletRecord?
@Published var xmrWallet: WalletRecord?
@Published var balance = Balance(total: "0", unlocked: "", funded: false)
@Published var transactions: [TransactionRecord] = []
@Published var syncStatus: SyncStatus?
@Published var isBusy = false
@Published var errorMessage: String?
@Published var statusMessage: String?
@Published var moneroReady = false
@Published var moneroAddress: String?
@Published var moneroAccounts: [MoneroAccount] = []
@Published var prices = PriceService.Prices(currency: "USD")
/// Whether the app is behind the biometric lock screen right now.
@Published var locked = false
/// Face ID/Touch ID lock. On by default when the device has biometrics;
/// persisted across launches.
var faceIDEnabled: Bool {
get { UserDefaults.standard.object(forKey: "koins.faceID") as? Bool ?? true }
set {
UserDefaults.standard.set(newValue, forKey: "koins.faceID")
objectWillChange.send()
}
}
/// Rough time remaining for the Monero sync, derived from observed block rate.
@Published var syncEta: String?
private var moneroOpened = false
private var refreshTick = 0
private var refreshTask: Task<Void, Never>?
private var lastSample: (height: UInt64, date: Date)?
private var lastCheckpoint = Date.distantPast
/// Invalidates in-flight refreshes: switching chains (or starting a newer
/// refresh) must stop an older request from writing its result to the UI.
private var refreshToken = UUID()
var wallet: WalletRecord? {
chain == .xrp ? xrpWallet : xmrWallet
}
var hasWallet: Bool { wallet != nil }
/// Address for the chain currently on screen. Kept chain-aware because the
/// Monero address stays cached once its wallet is open.
var displayAddress: String {
chain == .xrp ? (xrpWallet?.address ?? "") : (moneroAddress ?? xmrWallet?.address ?? "")
}
var symbol: String { chain.symbol }
// MARK: - Fiat display
/// The fiat currency the wallet shows; persisted, and changing it refetches
/// the price cache.
var fiatCurrency: String {
get { UserDefaults.standard.string(forKey: "koins.fiatCurrency") ?? "USD" }
set {
UserDefaults.standard.set(newValue, forKey: "koins.fiatCurrency")
objectWillChange.send()
Task { await refreshPrices() }
}
}
/// The current chain's balance in the chosen fiat currency, when a price is
/// known.
var fiatBalance: String? {
fiatValue(of: balance.total, chain: chain)
}
/// Converts a chain amount into the chosen fiat currency, when a price is
/// known.
func fiatValue(of amount: String, chain: Chain) -> String? {
let price = chain == .xrp ? prices.xrp : prices.xmr
guard let price, let value = Decimal(string: amount) else { return nil }
let fiat = value * Decimal(price)
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = fiatCurrency
formatter.locale = Locale(identifier: "en_US")
return formatter.string(from: fiat as NSDecimalNumber)
}
private func refreshPrices() async {
prices = await PriceService.shared.prices(currency: fiatCurrency)
}
// MARK: - Biometric lock
// MARK: - Swaps (kyc.rip)
@Published var swapOrders: [SwapOrder] = []
func loadSwapOrders() {
swapOrders = WalletStore.swapOrders()
}
func estimateSwap(amount: String, from: Chain, to: Chain) async throws -> SwapEstimate {
guard let amountNumber = Double(amount.trimmingCharacters(in: .whitespaces)),
amountNumber > 0 else {
throw SwapError.api("Enter a valid amount")
}
return try await SwapService.shared.estimate(from: from.rawValue, to: to.rawValue, amount: amountNumber)
}
func createSwap(route: SwapRoute, estimateID: String, amountFrom: String,
from: Chain, to: Chain) async throws -> SwapOrder {
guard let amountNumber = Double(amountFrom.trimmingCharacters(in: .whitespaces)), amountNumber > 0 else {
throw SwapError.api("Enter a valid amount")
}
guard let destination = (to == .xrp ? xrpWallet?.address : xmrWallet?.address), !destination.isEmpty else {
throw SwapError.api("No \(to.title) wallet to receive into")
}
let order = try await SwapService.shared.createOrder(route: route,
estimateID: estimateID,
from: from.rawValue,
to: to.rawValue,
addressTo: destination,
amountFrom: amountNumber)
WalletStore.saveSwapOrder(order)
loadSwapOrders()
return order
}
/// Sends the swap deposit using the same payment path as a normal send.
func sendSwapDeposit(_ order: SwapOrder, accountIndex: Int = 0) async throws {
try await send(to: order.depositAddress,
amount: String(order.depositAmount),
tag: order.depositMemo ?? "",
priority: .defaultPriority,
accountIndex: accountIndex)
}
func refreshSwapStatus(_ order: SwapOrder) async -> SwapOrder {
guard let updated = try? await SwapService.shared.orderStatus(order) else { return order }
var order = order
order.status = updated.status
order.txIn = updated.txIn ?? order.txIn
order.txOut = updated.txOut ?? order.txOut
if order.txIn?.isEmpty == true { order.txIn = nil }
if order.txOut?.isEmpty == true { order.txOut = nil }
order.supportUrl = updated.supportUrl ?? order.supportUrl
if let index = swapOrders.firstIndex(where: { $0.id == order.id }) {
swapOrders[index] = order
}
WalletStore.saveSwapOrder(order)
return order
}
/// Persists a local change to an order (for example, the deposit was sent).
func saveSwapOrder(_ order: SwapOrder) {
WalletStore.saveSwapOrder(order)
loadSwapOrders()
}
func removeSwapOrder(id: String) {
WalletStore.removeSwapOrder(id: id)
loadSwapOrders()
}
func authenticate() async {
if await Biometrics.authenticate(reason: "Unlock Koins") {
locked = false
}
}
/// Requires biometrics for sensitive actions when the lock is enabled; a
/// device without biometrics is not asked (the wallet is still usable).
func requireAuth(reason: String) async throws {
guard faceIDEnabled, Biometrics.isAvailable else { return }
let ok = await Biometrics.authenticate(reason: reason)
if !ok { throw XRPError.rpc("Authentication failed") }
}
// MARK: - Cache
/// Restores the last-known state for the chain on screen instantly; the
/// refresh that follows replaces it with live data.
private func showCached(_ forChain: Chain) {
guard let cached = WalletStore.cachedState(forChain) else { return }
balance = cached.balance
transactions = cached.transactions
if let accounts = cached.accounts { moneroAccounts = accounts }
}
private func persistState(for forChain: Chain) {
guard (forChain == .xrp ? xrpWallet : xmrWallet) != nil else { return }
WalletStore.saveCachedState(
ChainCache(balance: balance,
transactions: transactions,
accounts: forChain == .xmr ? moneroAccounts : nil,
savedAt: Date()),
chain: forChain)
}
// MARK: - Bootstrap
func bootstrap() async {
xrpWallet = WalletStore.wallet(.xrp)
xmrWallet = WalletStore.wallet(.xmr)
if xmrWallet == nil, let orphan = MoneroService.orphanedWalletNames().first {
// Wallet file on disk with no stored record (for example after the
// preferences were lost): re-attach it so funds are not stranded.
await adoptMoneroWallet(named: orphan)
}
if let xmrWallet {
await openMonero(afterRestore: false, wallet: xmrWallet)
}
// Restore the chain the user was last on, as long as that wallet exists.
let savedChain = UserDefaults.standard.string(forKey: "koins.chain").flatMap(Chain.init(rawValue:))
if let savedChain, (savedChain == .xrp ? xrpWallet : xmrWallet) != nil {
chain = savedChain
} else if xrpWallet != nil || moneroReady {
chain = xrpWallet != nil ? .xrp : .xmr
}
if xrpWallet != nil || xmrWallet != nil, faceIDEnabled, Biometrics.isAvailable {
locked = true
}
loadSwapOrders()
showCached(chain)
await refresh(force: true)
startAutoRefresh()
Task { await refreshPrices() }
}
private func adoptMoneroWallet(named name: String) async {
do {
let address = try await MoneroService.shared.openWallet(name: name)
let record = WalletRecord(id: "xmr_\(Int(Date().timeIntervalSince1970))",
name: name,
address: address,
createdAt: Date())
xmrWallet = record
WalletStore.saveWallet(record, chain: .xmr)
moneroOpened = true
moneroReady = true
moneroAddress = address
statusMessage = "Recovered the existing Monero wallet “\(name)” from this device."
} catch {
errorMessage = "Found a Monero wallet file but could not open it: \(error.localizedDescription)"
}
}
/// Polls the wallet while the app is in the foreground. Monero sync progress
/// changes constantly, so it is refreshed far more often than XRP.
func startAutoRefresh() {
guard refreshTask == nil else { return }
refreshTask = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
let seconds: UInt64 = self.chain == .xmr ? 5 : 30
try? await Task.sleep(nanoseconds: seconds * 1_000_000_000)
if Task.isCancelled { return }
await self.refresh()
}
}
}
func select(_ newChain: Chain) async {
chain = newChain
UserDefaults.standard.set(newChain.rawValue, forKey: "koins.chain")
refreshToken = UUID()
errorMessage = nil
statusMessage = nil
balance = Balance(total: "0", unlocked: "", funded: false)
transactions = []
moneroAccounts = []
syncStatus = nil
showCached(newChain)
if newChain == .xmr, let xmrWallet, !moneroOpened {
await openMonero(afterRestore: false, wallet: xmrWallet)
}
await refresh(force: true)
}
// MARK: - Monero lifecycle
private func openMonero(afterRestore: Bool, wallet: WalletRecord) async {
isBusy = true
defer { isBusy = false }
do {
let address = try await MoneroService.shared.openWallet(name: wallet.name)
moneroOpened = true
moneroReady = true
moneroAddress = address.isEmpty ? wallet.address : address
if let address = moneroAddress, address != wallet.address {
var updated = wallet
updated.address = address
xmrWallet = updated
WalletStore.saveWallet(updated, chain: .xmr)
}
// Always try to catch up, not only right after a restore.
_ = try? await MoneroService.shared.startSync()
} catch {
// Keep the stored wallet so a transient daemon failure is not fatal.
moneroOpened = false
moneroReady = false
moneroAddress = nil
errorMessage = "Monero wallet could not be opened: \(error.localizedDescription)"
}
}
// MARK: - Create / import
func createWallet(name: String) async throws -> String {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw XRPError.rpc("Enter a wallet name") }
isBusy = true
defer { isBusy = false }
if chain == .xrp {
let (record, seed) = try XRPWalletService.shared.createWallet(name: trimmed)
xrpWallet = record
await refresh()
return seed
}
let result = try await MoneroService.shared.createWallet(name: trimmed)
let record = WalletRecord(id: "xmr_\(Int(Date().timeIntervalSince1970))",
name: trimmed,
address: result.address,
createdAt: Date())
xmrWallet = record
WalletStore.saveWallet(record, chain: .xmr)
if !result.mnemonic.isEmpty {
WalletStore.saveSecret(result.mnemonic, chain: .xmr)
}
moneroOpened = true
moneroReady = true
moneroAddress = result.address
_ = try? await MoneroService.shared.startSync()
await refresh(force: true)
return result.mnemonic
}
func importWallet(name: String, secret: String, restoreHeight: UInt64?) async throws {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedSecret = secret.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty else { throw XRPError.rpc("Enter a wallet name") }
guard !trimmedSecret.isEmpty else { throw XRPError.rpc("Enter your seed or secret") }
isBusy = true
defer { isBusy = false }
if chain == .xrp {
xrpWallet = try XRPWalletService.shared.importWallet(name: trimmedName, secret: trimmedSecret)
await refresh(force: true)
return
}
let address = try await MoneroService.shared.restoreWallet(name: trimmedName,
seed: trimmedSecret,
restoreHeight: restoreHeight ?? 0)
let record = WalletRecord(id: "xmr_\(Int(Date().timeIntervalSince1970))",
name: trimmedName,
address: address,
createdAt: Date())
xmrWallet = record
WalletStore.saveWallet(record, chain: .xmr)
WalletStore.saveSecret(trimmedSecret, chain: .xmr)
moneroOpened = true
moneroReady = true
moneroAddress = address
_ = try? await MoneroService.shared.startSync()
await refresh(force: true)
}
// MARK: - Reads
func refresh(force: Bool = false) async {
guard hasWallet else { return }
let token = UUID()
refreshToken = token
let requestChain = chain
errorMessage = nil
await refreshPrices()
if chain == .xrp, let wallet = xrpWallet {
do {
async let balanceCall = XRPWalletService.shared.balance(address: wallet.address)
async let txCall = XRPWalletService.shared.transactions(address: wallet.address)
let fetchedBalance = try await balanceCall
let fetchedTransactions = try await txCall
guard !isStale(token, requestChain) else { return }
balance = fetchedBalance
transactions = fetchedTransactions
persistState(for: requestChain)
} catch {
guard !isStale(token, requestChain) else { return }
errorMessage = error.localizedDescription
}
// The Monero wallet keeps scanning while XRP is on screen, so keep its
// progress current here too.
if moneroReady, let status = try? await MoneroService.shared.syncStatus() {
updateSyncStatus(status)
await checkpointWalletIfSyncing()
}
return
}
guard moneroReady else { return }
do {
// Sync progress is cheap and polled every tick; the balance and transfer
// list touch the wallet's data files, so they are refreshed less often.
let status = try await MoneroService.shared.syncStatus()
if !isStale(token, requestChain) {
updateSyncStatus(status)
}
refreshTick += 1
await checkpointWalletIfSyncing()
if force || refreshTick == 1 || refreshTick % 4 == 0 {
async let balanceCall = MoneroService.shared.balance()
async let txCall = MoneroService.shared.transactions()
async let accountsCall = MoneroService.shared.accounts()
let fetchedBalance = try await balanceCall
let fetchedTransactions = try await txCall
let fetchedAccounts = try await accountsCall
guard !isStale(token, requestChain) else { return }
balance = fetchedBalance
transactions = fetchedTransactions
moneroAccounts = fetchedAccounts
persistState(for: requestChain)
}
} catch {
guard !isStale(token, requestChain) else { return }
errorMessage = error.localizedDescription
}
}
/// True when a newer refresh has started or the user moved to another chain
/// while this request was in flight.
private func isStale(_ token: UUID, _ requestChain: Chain) -> Bool {
token != refreshToken || requestChain != chain
}
/// Tracks how fast blocks are being scanned so the UI can show an estimate.
private func updateSyncStatus(_ status: SyncStatus) {
syncStatus = status
let now = Date()
defer { lastSample = (status.height, now) }
guard status.syncing,
let sample = lastSample,
status.height > sample.height,
status.daemonHeight > status.height else {
if !status.syncing { syncEta = nil }
return
}
let elapsed = now.timeIntervalSince(sample.date)
guard elapsed > 1 else { return }
let blocksPerSecond = Double(status.height - sample.height) / elapsed
guard blocksPerSecond > 0 else { return }
let remaining = Double(status.daemonHeight - status.height) / blocksPerSecond
syncEta = Self.formatDuration(remaining)
}
private static func formatDuration(_ seconds: Double) -> String {
guard seconds.isFinite, seconds > 0 else { return "unknown" }
let total = Int(seconds.rounded())
let hours = total / 3600
let minutes = (total % 3600) / 60
if hours >= 24 { return "\(hours / 24)d \(hours % 24)h" }
if hours > 0 { return "\(hours)h \(minutes)m" }
if minutes > 0 { return "\(minutes)m" }
return "under a minute"
}
func startMoneroSync() async {
guard moneroReady else {
if let wallet = xmrWallet { await openMonero(afterRestore: true, wallet: wallet) }
return
}
_ = try? await MoneroService.shared.startSync()
await refresh(force: true)
}
func revealSeed() async throws -> String {
try await requireAuth(reason: "Reveal your recovery phrase")
if chain == .xrp {
guard let seed = WalletStore.secret(.xrp) else {
throw XRPError.rpc("No stored secret for this wallet")
}
return seed
}
if let stored = WalletStore.secret(.xmr), !stored.isEmpty { return stored }
return try await MoneroService.shared.seed()
}
// MARK: - Sends
func send(to: String, amount: String, tag: String, priority: TransactionPriority,
accountIndex: Int = 0) async throws {
try await requireAuth(reason: "Authorize this payment")
let destinationTag = UInt32(tag.trimmingCharacters(in: .whitespaces))
if chain == .xrp {
guard let seed = WalletStore.secret(.xrp) else {
throw XRPError.rpc("Wallet secret is unavailable")
}
statusMessage = nil
_ = try await XRPWalletService.shared.send(secret: seed,
to: to,
amountXRP: amount,
destinationTag: destinationTag)
statusMessage = "Payment sent"
await refresh(force: true)
return
}
_ = try await MoneroService.shared.send(address: to,
amountXMR: amount,
priority: priority,
accountIndex: accountIndex)
statusMessage = "Payment sent"
await refresh(force: true)
}
// MARK: - Delete
func deleteWallet() async {
if chain == .xrp {
WalletStore.removeWallet(.xrp)
xrpWallet = nil
} else {
if let wallet = xmrWallet { await MoneroService.shared.removeWallet(name: wallet.name) }
WalletStore.removeWallet(.xmr)
xmrWallet = nil
moneroOpened = false
moneroReady = false
moneroAddress = nil
syncStatus = nil
}
balance = Balance(total: "0", unlocked: "", funded: false)
transactions = []
errorMessage = nil
statusMessage = nil
}
func clearMessages() {
errorMessage = nil
statusMessage = nil
}
/// Called when the app leaves the foreground: iOS can suspend or kill it at
/// any point, so flush the wallet while there is still time.
func persistWallet() async {
guard moneroReady else { return }
await MoneroService.shared.save()
}
func addAccount(label: String = "") async throws {
_ = try await MoneroService.shared.createAccount(label: label)
await refresh(force: true)
}
func addSubaddress(accountIndex: Int, label: String = "") async throws {
_ = try await MoneroService.shared.createSubaddress(accountIndex: accountIndex, label: label)
await refresh(force: true)
}
/// The account a payment should leave from by default: the first one with
/// spendable funds, otherwise the primary account.
var defaultMoneroAccountIndex: Int {
moneroAccounts.first { (Decimal(string: $0.unlocked) ?? 0) > 0 }?.index ?? 0
}
/// Refreshes the account/subaddress tree independently of the active chain.
func loadAccounts() async {
guard moneroReady else { return }
if let accounts = try? await MoneroService.shared.accounts() {
moneroAccounts = accounts
}
}
/// monero-cpp only writes the wallet file from save(), and its sync loop
/// never does, so without this a restart re-scans from the beginning.
private func checkpointWalletIfSyncing() async {
guard moneroReady, syncStatus?.syncing == true else { return }
guard Date().timeIntervalSince(lastCheckpoint) >= 60 else { return }
lastCheckpoint = Date()
await MoneroService.shared.save()
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,2 @@
#import "KoinsMoneroWallet.h"
#import "KoinsSecp256k1.h"
+16
View File
@@ -0,0 +1,16 @@
#import <Foundation/Foundation.h>
#import "KoinsSecp256k1.h"
NS_ASSUME_NONNULL_BEGIN
/// Objective-C++ bridge to the on-device monero-cpp wallet. A single JSON
/// in/out entry point keeps the C++ surface out of Swift; work is serialized
/// on a private queue because a monero_wallet_full is not thread safe.
@interface KoinsMoneroWallet : NSObject
+ (void)invoke:(NSString *)jsonRequest
completion:(void (^)(NSString *response))completion;
@end
NS_ASSUME_NONNULL_END
+388
View File
@@ -0,0 +1,388 @@
#import "KoinsMoneroWallet.h"
#import <os/log.h>
#include "monero_wallet_full.h"
static std::string walletPath(NSString *name) {
NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
NSString *dir = paths.firstObject ?: NSTemporaryDirectory();
NSFileManager *fm = [NSFileManager defaultManager];
if (![fm fileExistsAtPath:dir]) {
[fm createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:nil error:nil];
}
return std::string([dir stringByAppendingPathComponent:name].UTF8String);
}
// Keep wallet files out of iCloud/iTunes backups: the seed in the Keychain is
// device-only and the file is rebuildable from it, so backing it up would only
// consume the user's quota (it peaks near 120 MB while a wallet restored from
// genesis scans). Must run after every write, since wallet2 rewrites these
// files on each save.
static void excludeWalletFilesFromBackup(const std::string &walletPathString) {
NSString *base = [NSString stringWithUTF8String:walletPathString.c_str()];
if (base.length == 0) return;
for (NSString *suffix in @[ @"", @".keys", @".address.txt", @".mms" ]) {
NSURL *url = [NSURL fileURLWithPath:[base stringByAppendingString:suffix]];
[url setResourceValue:@YES forKey:NSURLIsExcludedFromBackupKey error:nil];
}
}
static monero_wallet_full *g_wallet = nullptr;
static std::string g_walletPath;
static NSString *g_daemonURL = @"xmr-node.cakewallet.com:18081";
static NSArray<NSString *> *g_fallbackDaemons = nil;
static std::atomic<bool> s_cachedConnected(false);
static std::atomic<unsigned long long> s_cachedDaemonHeight(0);
static std::atomic<long long> s_lastDaemonProbe(0);
static void connectDaemon(monero_wallet_full *w) {
if (!w) return;
@autoreleasepool {
if (!g_fallbackDaemons) {
g_fallbackDaemons = @[
@"xmr-node.cakewallet.com:18081",
@"node.monerodevs.org:18089",
@"monero.stackwallet.com:18081",
];
}
NSMutableArray<NSString *> *candidates = [NSMutableArray arrayWithObject:g_daemonURL];
for (NSString *url in g_fallbackDaemons) {
if (![url isEqualToString:g_daemonURL]) [candidates addObject:url];
}
for (NSString *url in candidates) {
try {
w->set_daemon_connection(std::string(url.UTF8String));
if (w->is_connected_to_daemon()) {
g_daemonURL = url;
return;
}
} catch (...) {
// Try the next daemon.
}
}
}
}
@implementation KoinsMoneroWallet
+ (void)invoke:(NSString *)jsonRequest
completion:(void (^)(NSString *response))completion {
static dispatch_queue_t invokeQueue;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
invokeQueue = dispatch_queue_create("com.koins.monero.invoke", DISPATCH_QUEUE_SERIAL);
});
dispatch_async(invokeQueue, ^{
NSMutableDictionary *resp = [NSMutableDictionary new];
try {
NSData *data = [jsonRequest dataUsingEncoding:NSUTF8StringEncoding];
NSDictionary *req = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
NSString *op = req[@"op"] ?: @"";
resp[@"op"] = op;
if ([op isEqualToString:@"setDaemon"]) {
NSString *url = req[@"url"];
if (url.length > 0) g_daemonURL = url;
resp[@"url"] = g_daemonURL;
} else if ([op isEqualToString:@"createWallet"]) {
NSString *name = req[@"name"];
std::string path = walletPath(name);
std::string password = std::string([req[@"password"] UTF8String]);
bool exists = false;
try { exists = monero_wallet_full::wallet_exists(path); } catch (...) {}
if (exists) {
g_wallet = monero_wallet_full::open_wallet(path, password, monero_network_type::MAINNET, false);
connectDaemon(g_wallet);
resp[@"address"] = [NSString stringWithUTF8String:g_wallet->get_address(0, 0).c_str()];
try { resp[@"mnemonic"] = [NSString stringWithUTF8String:g_wallet->get_seed().c_str()]; } catch (...) {}
g_walletPath = path;
excludeWalletFilesFromBackup(g_walletPath);
} else {
monero_wallet_config config;
config.m_path = path;
config.m_password = password;
config.m_network_type = monero_network_type::MAINNET;
config.m_language = std::string([req[@"language"] ?: @"English" UTF8String]);
config.m_save_current = false;
monero_wallet_full *w = monero_wallet_full::create_wallet(config, nullptr);
g_wallet = w;
connectDaemon(w);
resp[@"address"] = [NSString stringWithUTF8String:w->get_address(0, 0).c_str()];
resp[@"mnemonic"] = [NSString stringWithUTF8String:w->get_seed().c_str()];
g_walletPath = path;
excludeWalletFilesFromBackup(g_walletPath);
}
} else if ([op isEqualToString:@"openWallet"] || [op isEqualToString:@"restoreWallet"]) {
if (g_wallet) {
try { g_wallet->close(true); } catch (...) {}
delete g_wallet;
g_wallet = nullptr;
}
if ([op isEqualToString:@"openWallet"]) {
g_wallet = monero_wallet_full::open_wallet(
walletPath(req[@"name"]),
std::string([req[@"password"] UTF8String]),
monero_network_type::MAINNET, false);
} else {
monero_wallet_config config;
config.m_path = walletPath(req[@"name"]);
config.m_password = std::string([req[@"password"] UTF8String]);
config.m_network_type = monero_network_type::MAINNET;
config.m_seed = std::string([req[@"seed"] UTF8String]);
config.m_restore_height = (uint64_t)[req[@"restoreHeight"] longLongValue];
config.m_language = "English";
config.m_save_current = false;
g_wallet = monero_wallet_full::create_wallet(config, nullptr);
}
connectDaemon(g_wallet);
g_walletPath = walletPath(req[@"name"]);
excludeWalletFilesFromBackup(g_walletPath);
resp[@"address"] = [NSString stringWithUTF8String:g_wallet->get_address(0, 0).c_str()];
} else if ([op isEqualToString:@"getSeed"]) {
if (g_wallet) {
resp[@"mnemonic"] = [NSString stringWithUTF8String:g_wallet->get_seed().c_str()];
} else {
resp[@"error"] = @"No wallet open";
}
} else if ([op isEqualToString:@"removeWallet"]) {
NSString *name = req[@"name"];
if (g_wallet) {
try { g_wallet->close(false); } catch (...) {}
delete g_wallet;
g_wallet = nullptr;
}
NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
NSString *dir = paths.firstObject;
NSFileManager *fm = [NSFileManager defaultManager];
for (NSString *ext in @[ @"", @".keys", @".address.txt", @".mms" ]) {
NSString *file = [dir stringByAppendingPathComponent:[name stringByAppendingString:ext]];
[fm removeItemAtPath:file error:nil];
}
} else if ([op isEqualToString:@"closeWallet"]) {
if (g_wallet) {
try { g_wallet->close(true); } catch (...) {}
delete g_wallet;
g_wallet = nullptr;
}
g_walletPath.clear();
} else if ([op isEqualToString:@"saveWallet"]) {
// monero-cpp never persists while syncing, so scan progress survives a
// restart only if this is called explicitly. save() pauses the sync
// thread while it writes.
if (g_wallet) {
g_wallet->save();
excludeWalletFilesFromBackup(g_walletPath);
resp[@"saved"] = @YES;
} else {
resp[@"error"] = @"No wallet open";
}
} else if ([op isEqualToString:@"getBalance"]) {
if (g_wallet) {
uint64_t balance = 0, unlocked = 0;
try {
balance = g_wallet->get_balance();
unlocked = g_wallet->get_unlocked_balance();
} catch (const std::exception &e) {
os_log_error(OS_LOG_DEFAULT, "[koins] getBalance: %{public}s", e.what());
}
resp[@"balance"] = @(balance).stringValue;
resp[@"unlocked"] = @(unlocked).stringValue;
} else {
resp[@"error"] = @"No wallet open";
}
} else if ([op isEqualToString:@"getAddress"]) {
if (g_wallet) {
resp[@"address"] = [NSString stringWithUTF8String:g_wallet->get_address(0, 0).c_str()];
} else {
resp[@"error"] = @"No wallet open";
}
} else if ([op isEqualToString:@"getTransactions"]) {
NSMutableArray *entries = [NSMutableArray new];
if (!g_wallet) {
resp[@"transactions"] = entries;
} else {
try {
monero_transfer_query query;
for (const auto &transfer : g_wallet->get_transfers(query)) {
const auto &tx = transfer->m_tx;
const auto isIn = transfer->is_incoming();
boost::optional<uint64_t> txHeight = tx ? tx->get_height() : boost::none;
NSMutableDictionary *entry = [NSMutableDictionary dictionary];
entry[@"hash"] = [NSString stringWithUTF8String:((tx && tx->m_hash) ? *tx->m_hash : "").c_str()];
entry[@"amount"] = @((transfer->m_amount ? *transfer->m_amount : 0)).stringValue;
entry[@"timestamp"] = @((tx && tx->m_received_timestamp) ? *tx->m_received_timestamp : 0);
entry[@"direction"] = (isIn && *isIn) ? @"in" : @"out";
entry[@"confirmed"] = @((tx && tx->m_is_confirmed) ? *tx->m_is_confirmed : false);
entry[@"height"] = @(txHeight ? *txHeight : 0);
entry[@"fee"] = @((tx && tx->m_fee) ? *tx->m_fee : 0).stringValue;
// Extra detail for the transaction view.
if (tx) {
if (tx->m_payment_id) entry[@"paymentId"] = [NSString stringWithUTF8String:tx->m_payment_id->c_str()];
if (tx->m_num_confirmations) entry[@"confirmations"] = @(*tx->m_num_confirmations);
if (tx->m_weight) entry[@"weight"] = @(*tx->m_weight);
if (tx->m_note) entry[@"note"] = [NSString stringWithUTF8String:tx->m_note->c_str()];
if (tx->m_unlock_time) entry[@"unlockTime"] = @(*tx->m_unlock_time);
}
if (transfer->m_account_index) entry[@"accountIndex"] = @(*transfer->m_account_index);
if (isIn && *isIn) {
auto incoming = std::dynamic_pointer_cast<monero_incoming_transfer>(transfer);
if (incoming) {
if (incoming->m_subaddress_index) entry[@"subaddressIndex"] = @(*incoming->m_subaddress_index);
if (incoming->m_address) entry[@"counterparty"] = [NSString stringWithUTF8String:incoming->m_address->c_str()];
}
} else {
auto outgoing = std::dynamic_pointer_cast<monero_outgoing_transfer>(transfer);
if (outgoing) {
if (!outgoing->m_addresses.empty()) entry[@"counterparty"] = [NSString stringWithUTF8String:outgoing->m_addresses.front().c_str()];
if (!outgoing->m_subaddress_indices.empty()) entry[@"subaddressIndex"] = @(outgoing->m_subaddress_indices.front());
}
}
[entries addObject:entry];
}
} catch (const std::exception &e) {
os_log_error(OS_LOG_DEFAULT, "[koins] getTransactions: %{public}s", e.what());
}
resp[@"transactions"] = entries;
}
} else if ([op isEqualToString:@"getAccounts"]) {
NSMutableArray *accounts = [NSMutableArray new];
if (!g_wallet) {
resp[@"accounts"] = accounts;
} else {
for (const auto &account : g_wallet->get_accounts(true, "")) {
NSMutableDictionary *accountDict = [NSMutableDictionary new];
accountDict[@"index"] = @(account.m_index ? *account.m_index : 0);
accountDict[@"address"] = [NSString stringWithUTF8String:(account.m_primary_address ? *account.m_primary_address : "").c_str()];
accountDict[@"balance"] = @((account.m_balance ? *account.m_balance : 0)).stringValue;
accountDict[@"unlocked"] = @((account.m_unlocked_balance ? *account.m_unlocked_balance : 0)).stringValue;
NSMutableArray *subaddresses = [NSMutableArray new];
for (const auto &sub : account.m_subaddresses) {
[subaddresses addObject:@{
@"accountIndex" : @(sub.m_account_index ? *sub.m_account_index : 0),
@"index" : @(sub.m_index ? *sub.m_index : 0),
@"address" : [NSString stringWithUTF8String:(sub.m_address ? *sub.m_address : "").c_str()],
@"label" : [NSString stringWithUTF8String:(sub.m_label ? *sub.m_label : "").c_str()],
@"balance" : @((sub.m_balance ? *sub.m_balance : 0)).stringValue,
@"unlocked" : @((sub.m_unlocked_balance ? *sub.m_unlocked_balance : 0)).stringValue,
@"isUsed" : @((sub.m_is_used ? *sub.m_is_used : false)),
}];
}
accountDict[@"subaddresses"] = subaddresses;
[accounts addObject:accountDict];
}
resp[@"accounts"] = accounts;
}
} else if ([op isEqualToString:@"createAccount"]) {
if (!g_wallet) {
resp[@"error"] = @"No wallet open";
} else {
monero_account account = g_wallet->create_account(std::string([req[@"label"] UTF8String]));
g_wallet->save();
excludeWalletFilesFromBackup(g_walletPath);
resp[@"index"] = @(account.m_index ? *account.m_index : 0);
resp[@"address"] = [NSString stringWithUTF8String:(account.m_primary_address ? *account.m_primary_address : "").c_str()];
resp[@"balance"] = @((account.m_balance ? *account.m_balance : 0)).stringValue;
resp[@"unlocked"] = @((account.m_unlocked_balance ? *account.m_unlocked_balance : 0)).stringValue;
}
} else if ([op isEqualToString:@"createSubaddress"]) {
if (!g_wallet) {
resp[@"error"] = @"No wallet open";
} else {
uint32_t accountIndex = (uint32_t)[req[@"accountIndex"] integerValue];
monero_subaddress sub = g_wallet->create_subaddress(accountIndex, std::string([req[@"label"] UTF8String]));
g_wallet->save();
excludeWalletFilesFromBackup(g_walletPath);
resp[@"accountIndex"] = @(sub.m_account_index ? *sub.m_account_index : 0);
resp[@"index"] = @(sub.m_index ? *sub.m_index : 0);
resp[@"address"] = [NSString stringWithUTF8String:(sub.m_address ? *sub.m_address : "").c_str()];
resp[@"label"] = [NSString stringWithUTF8String:(sub.m_label ? *sub.m_label : "").c_str()];
}
} else if ([op isEqualToString:@"startSync"]) {
if (g_wallet) {
connectDaemon(g_wallet);
try {
if (g_wallet->is_connected_to_daemon()) {
g_wallet->start_syncing(5000);
resp[@"syncing"] = @YES;
} else {
resp[@"syncing"] = @NO;
}
} catch (const std::exception &e) {
resp[@"syncing"] = @NO;
}
} else {
resp[@"error"] = @"No wallet open";
}
} else if ([op isEqualToString:@"stopSync"]) {
try { if (g_wallet) g_wallet->stop_syncing(); } catch (...) {}
} else if ([op isEqualToString:@"getSyncStatus"]) {
uint64_t height = 0, daemonHeight = 0;
bool connected = false;
if (g_wallet) {
try {
height = g_wallet->get_height();
// is_connected_to_daemon performs a live round trip; public
// daemons rate limit aggressive polling, so cache for 10s.
long long now = (long long)([[NSDate date] timeIntervalSince1970] * 1000);
if (now - s_lastDaemonProbe.load() > 10000) {
s_lastDaemonProbe.store(now);
connected = g_wallet->is_connected_to_daemon();
s_cachedConnected = connected;
} else {
connected = s_cachedConnected;
}
daemonHeight = s_cachedDaemonHeight;
if (connected) {
daemonHeight = g_wallet->get_daemon_height();
s_cachedDaemonHeight = daemonHeight;
}
} catch (const std::exception &e) {
os_log_error(OS_LOG_DEFAULT, "[koins] getSyncStatus: %{public}s", e.what());
}
}
resp[@"height"] = @(height);
resp[@"daemonHeight"] = @(daemonHeight);
resp[@"syncing"] = @(connected && height < daemonHeight);
resp[@"connected"] = @(connected);
} else if ([op isEqualToString:@"transferTo"]) {
if (!g_wallet) {
resp[@"error"] = @"No wallet open";
} else {
monero_tx_config config;
auto destination = std::make_shared<monero_destination>();
destination->m_address = std::string([req[@"address"] UTF8String]);
destination->m_amount = (uint64_t)[req[@"amount"] longLongValue];
config.m_destinations = { destination };
config.m_priority = (monero_tx_priority)[req[@"priority"] integerValue];
config.m_account_index = (uint32_t)[req[@"accountIndex"] integerValue];
// create_txs relays the transaction when m_relay is set, so it must
// not be submitted a second time.
config.m_relay = true;
auto txs = g_wallet->create_txs(config);
resp[@"txHash"] = [NSString stringWithUTF8String:(txs[0]->m_hash ? *txs[0]->m_hash : "").c_str()];
resp[@"fee"] = @((txs[0]->m_fee ? *txs[0]->m_fee : 0)).stringValue;
}
} else {
resp[@"error"] = [NSString stringWithFormat:@"Unknown op: %@", op];
}
} catch (const std::exception &e) {
os_log_error(OS_LOG_DEFAULT, "[koins] monero %{public}@ failed: %{public}s",
resp[@"op"], e.what());
resp[@"error"] = [NSString stringWithUTF8String:e.what()];
} catch (...) {
resp[@"error"] = @"Unknown native exception";
}
NSData *out = [NSJSONSerialization dataWithJSONObject:resp options:0 error:nil];
NSString *outStr = [[NSString alloc] initWithData:out encoding:NSUTF8StringEncoding];
completion(outStr ?: @"{\"error\":\"encode failed\"}");
});
}
@end
+25
View File
@@ -0,0 +1,25 @@
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
/// secp256k1 helpers used by the XRP wallet. Backed by the OpenSSL build that
/// already ships inside the on-device monero static library, so no extra
/// dependency is linked.
@interface KoinsSecp256k1 : NSObject
/// XRP account private scalar derived from a 16-byte family seed entropy.
+ (nullable NSData *)privateKeyFromEntropy:(NSData *)entropy;
/// 33-byte compressed public key for a 32-byte private scalar.
+ (nullable NSData *)publicKeyFromPrivateKey:(NSData *)privateKey;
/// DER-encoded, low-S ECDSA signature over a 32-byte digest.
+ (nullable NSData *)signDigest:(NSData *)digest
withPrivateKey:(NSData *)privateKey;
/// (left + right) mod n, used by BIP-32 hardened child derivation.
+ (nullable NSData *)addScalar:(NSData *)left toScalar:(NSData *)right;
@end
NS_ASSUME_NONNULL_END
+187
View File
@@ -0,0 +1,187 @@
#import "KoinsSecp256k1.h"
#import <openssl/bn.h>
#import <openssl/ec.h>
#import <openssl/ecdsa.h>
#import <openssl/obj_mac.h>
#import <openssl/sha.h>
// secp256k1 group order.
static const char *kCurveOrderHex =
"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141";
static BIGNUM *KoinsCurveOrder(void) {
BIGNUM *order = NULL;
BN_hex2bn(&order, kCurveOrderHex);
return order;
}
static NSData *KoinsSHA512Half(NSData *input) {
unsigned char digest[SHA512_DIGEST_LENGTH];
SHA512((const unsigned char *)input.bytes, (size_t)input.length, digest);
return [NSData dataWithBytes:digest length:32];
}
// Mirror of ripple-keypairs deriveScalar: hash the bytes (optionally with a
// 32-bit discriminator) until the result is a valid non-zero curve scalar.
static BIGNUM *KoinsDeriveScalar(NSData *bytes, BOOL hasDiscriminator,
uint32_t discriminator, const BIGNUM *order) {
for (uint32_t i = 0; i <= 4294967295u; i++) {
NSMutableData *input = [NSMutableData dataWithData:bytes];
if (hasDiscriminator) {
uint32_t d = htonl(discriminator);
[input appendBytes:&d length:4];
}
uint32_t idx = htonl(i);
[input appendBytes:&idx length:4];
NSData *half = KoinsSHA512Half(input);
BIGNUM *scalar = BN_bin2bn((const unsigned char *)half.bytes, (int)half.length, NULL);
if (scalar && !BN_is_zero(scalar) && BN_cmp(scalar, order) < 0) {
return scalar;
}
if (scalar) BN_free(scalar);
}
return NULL;
}
@implementation KoinsSecp256k1
+ (nullable NSData *)privateKeyFromEntropy:(NSData *)entropy {
if (entropy.length != 16) return nil;
BN_CTX *ctx = BN_CTX_new();
BIGNUM *order = KoinsCurveOrder();
EC_GROUP *group = EC_GROUP_new_by_curve_name(NID_secp256k1);
EC_POINT *publicGen = EC_POINT_new(group);
NSData *result = nil;
BIGNUM *privateGen = KoinsDeriveScalar(entropy, NO, 0, order);
if (privateGen && EC_POINT_mul(group, publicGen, privateGen, NULL, NULL, ctx)) {
unsigned char compressed[33];
size_t written = EC_POINT_point2oct(group, publicGen,
POINT_CONVERSION_COMPRESSED,
compressed, sizeof(compressed), ctx);
if (written == sizeof(compressed)) {
NSData *publicGenBytes = [NSData dataWithBytes:compressed length:written];
BIGNUM *offset = KoinsDeriveScalar(publicGenBytes, YES, 0, order);
if (offset) {
BIGNUM *derived = BN_new();
if (BN_mod_add(derived, offset, privateGen, order, ctx)) {
unsigned char out[32] = {0};
BN_bn2binpad(derived, out, sizeof(out));
result = [NSData dataWithBytes:out length:sizeof(out)];
}
BN_free(derived);
BN_free(offset);
}
}
}
if (privateGen) BN_free(privateGen);
if (publicGen) EC_POINT_free(publicGen);
if (group) EC_GROUP_free(group);
if (order) BN_free(order);
if (ctx) BN_CTX_free(ctx);
return result;
}
+ (nullable NSData *)publicKeyFromPrivateKey:(NSData *)privateKey {
if (privateKey.length != 32) return nil;
BN_CTX *ctx = BN_CTX_new();
EC_GROUP *group = EC_GROUP_new_by_curve_name(NID_secp256k1);
EC_POINT *point = EC_POINT_new(group);
BIGNUM *scalar = BN_bin2bn((const unsigned char *)privateKey.bytes, (int)privateKey.length, NULL);
NSData *result = nil;
if (scalar && EC_POINT_mul(group, point, scalar, NULL, NULL, ctx)) {
unsigned char compressed[33];
size_t written = EC_POINT_point2oct(group, point,
POINT_CONVERSION_COMPRESSED,
compressed, sizeof(compressed), ctx);
if (written == sizeof(compressed)) {
result = [NSData dataWithBytes:compressed length:written];
}
}
if (scalar) BN_free(scalar);
if (point) EC_POINT_free(point);
if (group) EC_GROUP_free(group);
if (ctx) BN_CTX_free(ctx);
return result;
}
+ (nullable NSData *)addScalar:(NSData *)left toScalar:(NSData *)right {
if (left.length != 32 || right.length != 32) return nil;
BN_CTX *ctx = BN_CTX_new();
BIGNUM *order = KoinsCurveOrder();
BIGNUM *a = BN_bin2bn((const unsigned char *)left.bytes, (int)left.length, NULL);
BIGNUM *b = BN_bin2bn((const unsigned char *)right.bytes, (int)right.length, NULL);
BIGNUM *sum = BN_new();
NSData *result = nil;
if (a && b && sum && BN_mod_add(sum, a, b, order, ctx)) {
unsigned char out[32] = {0};
BN_bn2binpad(sum, out, sizeof(out));
result = [NSData dataWithBytes:out length:sizeof(out)];
}
if (sum) BN_free(sum);
if (b) BN_free(b);
if (a) BN_free(a);
if (order) BN_free(order);
if (ctx) BN_CTX_free(ctx);
return result;
}
+ (nullable NSData *)signDigest:(NSData *)digest
withPrivateKey:(NSData *)privateKey {
if (digest.length != 32 || privateKey.length != 32) return nil;
BN_CTX *ctx = BN_CTX_new();
EC_KEY *key = EC_KEY_new_by_curve_name(NID_secp256k1);
BIGNUM *scalar = BN_bin2bn((const unsigned char *)privateKey.bytes, (int)privateKey.length, NULL);
NSData *result = nil;
if (key && scalar && EC_KEY_set_private_key(key, scalar)) {
const EC_GROUP *group = EC_KEY_get0_group(key);
EC_POINT *pub = EC_POINT_new(group);
if (pub && EC_POINT_mul(group, pub, scalar, NULL, NULL, ctx) &&
EC_KEY_set_public_key(key, pub)) {
ECDSA_SIG *sig = ECDSA_do_sign((const unsigned char *)digest.bytes, (int)digest.length, key);
if (sig) {
// Ripple only accepts canonical (low-S) signatures.
BIGNUM *order = KoinsCurveOrder();
BIGNUM *halfOrder = BN_dup(order);
BN_rshift1(halfOrder, halfOrder);
const BIGNUM *r = NULL;
const BIGNUM *s = NULL;
ECDSA_SIG_get0(sig, &r, &s);
if (BN_cmp(s, halfOrder) > 0) {
BIGNUM *lowS = BN_new();
BN_sub(lowS, order, s);
ECDSA_SIG_set0(sig, BN_dup(r), lowS);
}
unsigned char *der = NULL;
int derLen = i2d_ECDSA_SIG(sig, &der);
if (derLen > 0) {
result = [NSData dataWithBytes:der length:(NSUInteger)derLen];
OPENSSL_free(der);
}
BN_free(halfOrder);
BN_free(order);
ECDSA_SIG_free(sig);
}
}
if (pub) EC_POINT_free(pub);
}
if (scalar) BN_free(scalar);
if (key) EC_KEY_free(key);
if (ctx) BN_CTX_free(ctx);
return result;
}
@end
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>Scan a QR code to fill in a payment address.</string>
<key>NSFaceIDUsageDescription</key>
<string>Unlock Koins and approve payments.</string>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Dark</string>
</dict>
</plist>
+30
View File
@@ -0,0 +1,30 @@
import SwiftUI
@main
struct KoinsApp: App {
@StateObject private var model: AppModel
init() {
Secp256k1Backend.derivePrivateKey = { entropy in
KoinsSecp256k1.privateKey(fromEntropy: entropy)
}
Secp256k1Backend.publicKey = { privateKey in
KoinsSecp256k1.publicKey(fromPrivateKey: privateKey)
}
Secp256k1Backend.sign = { digest, privateKey in
KoinsSecp256k1.signDigest(digest, withPrivateKey: privateKey)
}
Secp256k1Backend.addScalar = { left, right in
KoinsSecp256k1.addScalar(left, toScalar: right)
}
_model = StateObject(wrappedValue: AppModel())
SelfTest.runIfRequested()
}
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(model)
}
}
}
+134
View File
@@ -0,0 +1,134 @@
import Foundation
enum Chain: String, Codable, CaseIterable {
case xrp
case xmr
var symbol: String { self == .xrp ? "XRP" : "XMR" }
var title: String { self == .xrp ? "XRP Ledger" : "Monero" }
var subtitle: String {
self == .xrp ? "Fast, low-fee payments" : "Private, untraceable payments"
}
}
/// Web block explorers used for "view on the web" links.
enum BlockExplorer {
static func transactionURL(chain: Chain, hash: String) -> URL? {
guard !hash.isEmpty else { return nil }
switch chain {
case .xmr: return URL(string: "https://xmrchain.net/tx/\(hash)")
case .xrp: return URL(string: "https://livenet.xrpl.org/transactions/\(hash)")
}
}
/// Same lookup for a raw ticker ("XMR"/"XRP"), as used by the swap API.
static func transactionURL(ticker: String, hash: String) -> URL? {
switch ticker.uppercased() {
case "XMR": return transactionURL(chain: .xmr, hash: hash)
case "XRP": return transactionURL(chain: .xrp, hash: hash)
default: return nil
}
}
}
struct WalletRecord: Codable, Identifiable, Equatable {
let id: String
var name: String
var address: String
var createdAt: Date
}
enum Direction: String, Codable {
case incoming
case outgoing
}
struct TransactionRecord: Identifiable, Codable {
let id: String
let amount: String
let timestamp: Date?
let direction: Direction
let confirmed: Bool
var fee: String?
var tag: Int?
var counterparty: String?
var confirmations: UInt64?
var height: UInt64?
var paymentId: String?
var accountIndex: Int?
var subaddressIndex: Int?
var note: String?
var weight: UInt64?
var ledgerIndex: UInt64?
var sequence: UInt64?
}
struct Balance: Codable {
var total: String
var unlocked: String
var funded: Bool
}
/// Persisted per chain so a switch or relaunch shows the last-known state
/// instantly while the network catches up.
struct ChainCache: Codable {
var balance: Balance
var transactions: [TransactionRecord]
var accounts: [MoneroAccount]?
var savedAt: Date
}
struct SyncStatus {
var height: UInt64
var daemonHeight: UInt64
var syncing: Bool
var connected: Bool
var progress: Double {
guard daemonHeight > 0 else { return 0 }
return min(1, Double(height) / Double(daemonHeight))
}
}
enum TransactionPriority: Int {
case defaultPriority = 0
case low = 1
case medium = 2
case high = 3
var label: String {
switch self {
case .defaultPriority: return "Default"
case .low: return "Low"
case .medium: return "Medium"
case .high: return "High"
}
}
}
struct MoneroSubaddress: Identifiable, Codable {
let accountIndex: Int
let index: Int
let address: String
let label: String
let balance: String
let unlocked: String
let isUsed: Bool
var id: String { "\(accountIndex)/\(index)" }
}
struct MoneroAccount: Identifiable, Codable {
let index: Int
let address: String
let balance: String
let unlocked: String
var subaddresses: [MoneroSubaddress]
var id: Int { index }
var title: String {
index == 0 ? "Primary account" : "Account \(index + 1)"
}
}
@@ -0,0 +1,107 @@
/**
* Copyright (c) woodser
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#pragma once
#include <exception>
#include <string>
/**
* Public interface for libmonero-cpp library.
*/
namespace monero {
/**
* Exception when interacting with a Monero wallet or daemon.
*/
class monero_error : public std::exception {
public:
std::string message;
monero_error() {}
/**
* Construct the exception.
*
* @param msg is a human-readable description of the error
*/
monero_error(const std::string& msg) : message(msg) {}
const char* what() const noexcept override {
return message.c_str();
}
};
/**
* Exception when interacting with the Monero daemon or wallet RPC API.
*/
class monero_rpc_error : public monero_error {
public:
int code;
/**
* Construct the exception.
*
* @param error_code is the error code (optional)
* @param msg is a human-readable description of the error
*/
monero_rpc_error(int error_code, const std::string& msg) : code(error_code) { message = msg; }
/**
* Construct the exception.
*
* @param msg is a human-readable description of the error
*/
monero_rpc_error(const std::string& msg) : code(-1) { message = msg; }
};
}
@@ -0,0 +1,439 @@
#include "monero_rpc_connection.h"
#include "utils/gen_utils.h"
#include "utils/monero_utils.h"
#include "daemon/monero_daemon_rpc_model.h"
#include "monero_error.h"
static const uint32_t DEFAULT_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes
namespace monero {
// --------------------------- MONERO REQUEST PARAMS ---------------------------
rapidjson::Value monero_request_params::to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const {
rapidjson::Value root(rapidjson::kObjectType);
return root;
}
// --------------------------- MONERO RPC REQUEST ---------------------------
monero_rpc_request::monero_rpc_request(const std::string& method, const std::shared_ptr<serializable_struct>& params, bool json_rpc): m_method(method), m_params(params) {
if (params == nullptr) m_params = std::make_shared<monero_request_params>();
if (json_rpc) {
m_id = "0";
m_version = "2.0";
}
}
rapidjson::Value monero_rpc_request::to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const {
if (!is_json_rpc()) {
if (m_params == nullptr) throw monero_error("No params provided");
return m_params->to_rapidjson_val(allocator);
}
// create root
rapidjson::Value root(rapidjson::kObjectType);
// set string values
rapidjson::Value value_str(rapidjson::kStringType);
if (m_version != boost::none) monero_utils::add_json_member("jsonrpc", m_version.get(), allocator, root, value_str);
if (m_id != boost::none) monero_utils::add_json_member("id", m_id.get(), allocator, root, value_str);
if (m_method != boost::none) monero_utils::add_json_member("method", m_method.get(), allocator, root, value_str);
if (m_params != nullptr) root.AddMember("params", m_params->to_rapidjson_val(allocator), allocator);
// return root
return root;
}
// --------------------------- MONERO RPC RESPONSE ---------------------------
void raise_rpc_error(const boost::property_tree::ptree& error_node, const boost::string_ref uri, const std::string& method) {
std::string err_message = "Unknown error";
int err_code = -1;
for (auto it = error_node.begin(); it != error_node.end(); ++it) {
std::string key_err = it->first;
if (key_err == std::string("message")) {
if (it->second.data().empty()) {
err_message = "Received error response from RPC request with method '" + method + "' to " + uri.to_string();
}
else err_message = it->second.data();
} else if (key_err == std::string("code")) {
try {
err_code = it->second.get_value<int>();
} catch (...) {
MERROR("non-numeric error code from a malformed/unusual RPC response");
err_code = -1;
}
}
}
throw monero_rpc_error(err_code, err_message);
}
// --------------------------- MONERO RPC CONNECTION ---------------------------
bool monero_rpc_connection::compare(int p1, int p2) {
if (p1 == p2) return false;
// 0 alway first
if (p1 == 0) return true;
if (p2 == 0) return false;
return p1 > p2;
}
monero_rpc_connection::monero_rpc_connection(const std::string& uri, const std::string& username, const std::string& password, const std::string& proxy_uri, const std::string& zmq_uri, int priority, const boost::optional<uint32_t>& timeout_ms) {
if (!uri.empty()) m_uri = uri;
else m_uri = boost::none;
if (!proxy_uri.empty()) m_proxy_uri = proxy_uri;
else m_proxy_uri = boost::none;
if (!zmq_uri.empty()) m_zmq_uri = zmq_uri;
else m_zmq_uri = boost::none;
m_priority = priority;
m_timeout_ms = timeout_ms;
set_credentials(username, password);
}
monero_rpc_connection::monero_rpc_connection(const monero_rpc_connection& rpc) {
boost::lock_guard<boost::recursive_mutex> src_lock(rpc.m_mutex);
m_uri = rpc.m_uri;
m_proxy_uri = rpc.m_proxy_uri;
m_zmq_uri = rpc.m_zmq_uri;
m_priority = rpc.m_priority;
m_timeout_ms = rpc.m_timeout_ms;
set_credentials(rpc.m_username.value_or(""), rpc.m_password.value_or(""));
m_is_online = rpc.m_is_online;
m_is_authenticated = rpc.m_is_authenticated;
m_response_time = rpc.m_response_time;
m_attributes = rpc.m_attributes;
}
rapidjson::Value monero_rpc_connection::to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const {
boost::lock_guard<boost::recursive_mutex> lock(m_mutex);
// create root
rapidjson::Value root(rapidjson::kObjectType);
// set string values
rapidjson::Value value_str(rapidjson::kStringType);
if (m_uri != boost::none) monero_utils::add_json_member("uri", m_uri.get(), allocator, root, value_str);
if (m_username != boost::none) monero_utils::add_json_member("username", m_username.get(), allocator, root, value_str);
if (m_password != boost::none) monero_utils::add_json_member("password", m_password.get(), allocator, root, value_str);
if (m_proxy_uri != boost::none) monero_utils::add_json_member("proxyUri", m_proxy_uri.get(), allocator, root, value_str);
if (m_zmq_uri != boost::none) monero_utils::add_json_member("zmqUri", m_zmq_uri.get(), allocator, root, value_str);
// set num values
rapidjson::Value value_num(rapidjson::kNumberType);
monero_utils::add_json_member("priority", m_priority, allocator, root, value_num);
if (m_timeout_ms != boost::none) monero_utils::add_json_member("timeoutMs", m_timeout_ms.get(), allocator, root, value_num);
if (m_response_time != boost::none) monero_utils::add_json_member("responseTime", m_response_time.get(), allocator, root, value_num);
// set bool values
if (m_is_online != boost::none) monero_utils::add_json_member("isOnline", m_is_online.get(), allocator, root);
if (m_is_authenticated != boost::none) monero_utils::add_json_member("isAuthenticated", m_is_authenticated.get(), allocator, root);
return root;
}
bool monero_rpc_connection::is_onion() const {
boost::lock_guard<boost::recursive_mutex> lock(m_mutex);
epee::net_utils::http::url_content parsed{};
if (!epee::net_utils::parse_url(m_uri.value_or(""), parsed)) return false;
return parsed.host.size() >= 6 && parsed.host.compare(parsed.host.size() - 6, 6, ".onion") == 0;
}
bool monero_rpc_connection::is_i2p() const {
boost::lock_guard<boost::recursive_mutex> lock(m_mutex);
epee::net_utils::http::url_content parsed{};
if (!epee::net_utils::parse_url(m_uri.value_or(""), parsed)) return false;
return parsed.host.size() >= 8 && parsed.host.compare(parsed.host.size() - 8, 8, ".b32.i2p") == 0;
}
void monero_rpc_connection::set_credentials(const std::string& username, const std::string& password) {
boost::lock_guard<boost::recursive_mutex> lock(m_mutex);
// disconnect http client if connected
if (m_http_client != nullptr && m_http_client->is_connected()) m_http_client->disconnect();
bool username_empty = username.empty();
bool password_empty = password.empty();
// check username and password consistency
if (!username_empty || !password_empty) {
if (password_empty) {
throw monero_error("password cannot be empty because username is not empty");
}
if (username_empty) {
throw monero_error("username cannot be empty because password is not empty");
}
}
// check username and password changes
bool username_equals = (m_username == boost::none && username_empty) || (m_username != boost::none && *m_username == username);
bool password_equals = (m_password == boost::none && password_empty) || (m_password != boost::none && *m_password == password);
// connection reset values
if (!username_equals || !password_equals) {
m_is_online = boost::none;
m_is_authenticated = boost::none;
}
// setup username and password
if (!username_empty && !password_empty) {
m_username = username;
m_password = password;
} else {
m_username = boost::none;
m_password = boost::none;
}
}
void monero_rpc_connection::set_attribute(const std::string& key, const std::string& val) {
boost::lock_guard<boost::recursive_mutex> lock(m_mutex);
m_attributes[key] = val;
}
std::string monero_rpc_connection::get_attribute(const std::string& key) const {
boost::lock_guard<boost::recursive_mutex> lock(m_mutex);
std::unordered_map<std::string, std::string>::const_iterator i = m_attributes.find(key);
if (i == m_attributes.end()) {
return std::string(""); // attribute not found
}
return i->second;
}
boost::optional<bool> monero_rpc_connection::is_online() const {
boost::lock_guard<boost::recursive_mutex> lock(m_mutex);
return m_is_online;
}
boost::optional<bool> monero_rpc_connection::is_authenticated() const {
boost::lock_guard<boost::recursive_mutex> lock(m_mutex);
return m_is_authenticated;
}
boost::optional<bool> monero_rpc_connection::is_connected() const {
boost::lock_guard<boost::recursive_mutex> lock(m_mutex);
if (m_is_online == boost::none) return boost::none;
return m_is_online.get() && (m_is_authenticated == boost::none || m_is_authenticated.get());
}
bool monero_rpc_connection::check_connection(const boost::optional<uint32_t>& timeout_ms) {
boost::lock_guard<boost::recursive_mutex> lock(m_mutex);
boost::optional<bool> is_online_before = m_is_online;
boost::optional<bool> is_authenticated_before = m_is_authenticated;
boost::optional<std::chrono::high_resolution_clock::time_point> start_time = boost::none;
try {
// assume daemon connection
monero_get_blocks_by_height_request request(100);
start_time = std::chrono::high_resolution_clock::now();
send_binary_request(request, timeout_ms);
// set response time
m_response_time = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::high_resolution_clock::now() - start_time.get()).count();
m_is_online = true;
m_is_authenticated = true;
}
catch (const monero_rpc_error& ex) {
m_is_online = false;
m_is_authenticated = boost::none;
m_response_time = boost::none;
if (ex.code == 401 || ex.code == 404) {
// fallback to latency check
m_is_online = true;
m_is_authenticated = ex.code == 404;
}
}
catch (...) {
m_is_online = false;
m_is_authenticated = boost::none;
m_response_time = boost::none;
}
if (gen_utils::bool_equals(true, m_is_online) && start_time != boost::none) {
m_response_time = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::high_resolution_clock::now() - start_time.get()).count();
}
return is_online_before != m_is_online || is_authenticated_before != m_is_authenticated;
}
boost::property_tree::ptree monero_rpc_connection::send_json_request(const std::string& path, const std::shared_ptr<serializable_struct>& params, const boost::optional<uint32_t>& timeout_ms) const {
// send JSON-RPC request
monero_rpc_request request(path, params);
auto response = send_json_request(request, timeout_ms);
// assert JSON-RPC response is defined
if (response.m_result == boost::none) throw monero_error("Invalid Monero JSONRPC response");
return response.m_result.get();
}
monero_rpc_response monero_rpc_connection::send_json_request(const monero_rpc_request &request, const boost::optional<uint32_t>& timeout_ms) const {
// invoke JSON-RPC method
monero_rpc_response response;
send_rpc_request("/json_rpc", request, response, timeout_ms);
// return JSON-RPC response
return response;
}
boost::property_tree::ptree monero_rpc_connection::send_path_request(const std::string& path, const std::shared_ptr<serializable_struct>& params, const boost::optional<uint32_t>& timeout_ms) const {
// send RPC request
monero_rpc_request request(path, params, false);
auto response = send_path_request(request, timeout_ms);
// assert RPC response is defined
if (response.m_response == boost::none) throw monero_error("Invalid Monero RPC response");
return response.m_response.get();
}
monero_rpc_response monero_rpc_connection::send_path_request(const monero_rpc_request &request, const boost::optional<uint32_t>& timeout_ms) const {
// validate parameters
if (request.m_method == boost::none || request.m_method->empty()) throw monero_error("No RPC method set in path request");
// invoke RPC method
monero_rpc_response response;
send_rpc_request(std::string("/") + request.m_method.get(), request, response, timeout_ms);
// return RPC response
return response;
}
monero_rpc_response monero_rpc_connection::send_binary_request(const monero_rpc_request &request, const boost::optional<uint32_t>& timeout_ms) const {
// validate parameters
if (request.m_method == boost::none || request.m_method->empty()) throw monero_error("No RPC method set in binary request");
// invoke binary RPC method
monero_rpc_response response;
send_rpc_request(std::string("/") + request.m_method.get(), request, response, timeout_ms, true);
// return binary response
return response;
}
void monero_rpc_connection::ensure_configured() const {
// create http client on first use, so constructing a connection does not require network support
if (m_http_client == nullptr) {
auto factory = std::make_unique<net::http::client_factory>();
m_http_client = factory->create();
m_http_client->set_auto_connect(true);
}
// only reconfigure when uri/creds/proxy changed since last apply
std::string uri = m_uri.value_or("");
std::string username = m_username.value_or("");
std::string password = m_password.value_or("");
std::string proxy_uri = m_proxy_uri.value_or("");
auto key = std::make_tuple(uri, username, password, proxy_uri);
if (m_applied == key) return; // unchanged, so reuse live connection
// setup connection credentials
boost::optional<epee::net_utils::http::login> login;
if(!username.empty() && !password.empty()) {
login = epee::net_utils::http::login();
login->username = username;
login->password = password;
}
// detect ssl
epee::net_utils::ssl_support_t ssl = uri.rfind("https", 0) == 0 ? epee::net_utils::ssl_support_t::e_ssl_support_enabled : epee::net_utils::ssl_support_t::e_ssl_support_disabled;
if (!m_http_client->set_proxy(proxy_uri)) throw monero_error("Could not set proxy uri: " + proxy_uri);
if (!m_http_client->set_server(uri, login, std::move(ssl))) throw monero_error("Could not set uri: " + uri);
if (!m_http_client->connect(std::chrono::milliseconds(DEFAULT_TIMEOUT_MS))) MWARNING("Could not connect to server: " + uri);
m_applied = key;
}
std::string monero_rpc_connection::invoke_post(const boost::string_ref uri, const std::string& body, const boost::optional<uint32_t>& timeout_ms) const {
boost::lock_guard<boost::recursive_mutex> lock(m_mutex);
// request/response timeout: user-configurable, defaults to 3 minutes when not set.
uint32_t timeout = DEFAULT_TIMEOUT_MS;
if (timeout_ms != boost::none) timeout = timeout_ms.get();
else if (m_timeout_ms != boost::none) timeout = m_timeout_ms.get();
if (timeout == 0) timeout = std::numeric_limits<uint32_t>::max(); // timeout of 0 is treated as indefinite
// ensure_configured() bounds only the first connect; epee reconnects with this request timeout
ensure_configured();
const epee::net_utils::http::http_response_info* pri = NULL;
// invoke http json
const uint64_t received_before = m_http_client->get_bytes_received();
if (!m_http_client->invoke_post(uri, body, std::chrono::milliseconds(timeout), std::addressof(pri))) {
// epee never surfaces the 401 status, but a rejected login means the server answered;
// a dead or idle-closed socket yields no response bytes at all
bool received = m_http_client->get_bytes_received() > received_before;
if (received && m_http_client->is_connected()) {
throw monero_rpc_error(401, "Unauthorized");
}
// drop the socket: epee leaves EOF'd sockets marked connected, so without this the next
// call reuses the poisoned socket and hits the same misdiagnosis again
m_http_client->disconnect();
if (m_uri == boost::none || m_uri->empty()) throw monero_error("Cannot send RPC request: uri not set.");
throw monero_error("Network error: " + std::string(received ? "connection dropped mid-response" : "no response") + " from " + m_uri.get() + uri.to_string() + " (timeout: " + std::to_string(timeout) + "ms)");
}
if (!pri) throw monero_error("Could not get response info");
if (pri->m_response_code < 200 || pri->m_response_code > 299) {
std::string err_msg = std::to_string(pri->m_response_code) + " " + pri->m_response_comment + (pri->m_body.empty() ? "" : ": " + pri->m_body);
throw monero_rpc_error(pri->m_response_code, err_msg);
}
// return response info body
return pri->m_body;
}
void deserialize_rpc_response(const std::string& json, monero_rpc_response& response, const boost::string_ref uri, const std::string& method) {
// parse json to property node
boost::property_tree::ptree node;
gen_utils::deserialize(json, node);
// parse JSON-RPC response
for (boost::property_tree::ptree::const_iterator it = node.begin(); it != node.end(); ++it) {
std::string key = it->first;
if (key == std::string("error")) raise_rpc_error(it->second, uri, method);
else if (key == std::string("jsonrpc")) response.m_jsonrpc = it->second.data();
else if (key == std::string("result")) response.m_result = it->second;
}
// if not JSON-RPC, set RPC response
if (response.m_jsonrpc == boost::none) response.m_response = node;
}
void monero_rpc_connection::send_rpc_request(const boost::string_ref uri, const monero_rpc_request& request, monero_rpc_response& response, const boost::optional<uint32_t>& timeout_ms, bool binary) const {
std::string body;
if (request.m_method == boost::none) throw monero_error("Must provide a valid request method");
if (binary) monero_utils::json_to_binary(request.serialize(), body);
else body = request.serialize();
std::string result = invoke_post(uri, body, timeout_ms);
if (binary) response.m_binary = result;
else deserialize_rpc_response(result, response, uri, request.m_method.get());
}
std::shared_ptr<monero_rpc_connection> monero_rpc_connection::from_property_tree(const boost::property_tree::ptree& node) {
std::shared_ptr<monero_rpc_connection> connection = std::make_shared<monero_rpc_connection>();
for (boost::property_tree::ptree::const_iterator it = node.begin(); it != node.end(); ++it) {
std::string key = it->first;
if (key == std::string("uri")) connection->m_uri = it->second.data();
else if (key == std::string("username")) connection->m_username = it->second.data();
else if (key == std::string("password")) connection->m_password = it->second.data();
else if (key == std::string("proxyUri") || key == std::string("proxy_uri")) connection->m_proxy_uri = it->second.data();
else if (key == std::string("zmqUri")) connection->m_zmq_uri = it->second.data();
}
return connection;
}
}
@@ -0,0 +1,281 @@
/**
* Copyright (c) woodser
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#pragma once
#include <boost/thread/mutex.hpp>
#include <boost/thread/thread.hpp>
#include <boost/thread/condition_variable.hpp>
#include <boost/property_tree/ptree.hpp>
#include <boost/property_tree/json_parser.hpp>
#include "rapidjson/document.h"
#include "rapidjson/writer.h"
#include "rapidjson/stringbuffer.h"
#include "net/http.h"
#include "daemon/monero_daemon_model.h"
/**
* Public interface for libmonero-cpp library.
*/
namespace monero {
/**
* Models parameters for a request to a RPC server.
*/
struct monero_request_params : public serializable_struct {
monero_request_params() { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Models a request to a RPC server.
*/
struct monero_rpc_request : public serializable_struct {
boost::optional<std::string> m_id;
boost::optional<std::string> m_version;
boost::optional<std::string> m_method;
std::shared_ptr<serializable_struct> m_params;
monero_rpc_request() { }
monero_rpc_request(const std::string& method, const std::shared_ptr<serializable_struct>& params, bool json_rpc = true);
bool is_json_rpc() const { return m_id != boost::none && m_version != boost::none; }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Models a response from a RPC server.
*/
struct monero_rpc_response {
boost::optional<std::string> m_jsonrpc;
boost::optional<boost::property_tree::ptree> m_result;
boost::optional<boost::property_tree::ptree> m_response;
boost::optional<std::string> m_binary;
};
/**
* Maintains a connection and sends requests to a Monero RPC API.
*/
class monero_rpc_connection : public serializable_struct {
public:
boost::optional<std::string> m_uri;
boost::optional<std::string> m_username;
boost::optional<std::string> m_password;
boost::optional<std::string> m_proxy_uri;
boost::optional<std::string> m_zmq_uri; // TODO: implement zmq listener
boost::optional<uint32_t> m_timeout_ms; // RPC request timeout in milliseconds
boost::optional<uint64_t> m_response_time; // automatically set by calling check_connection()
int m_priority; // priority relative to other connections where 1 is highest, then 2, etc, and 0 is lowest (default)
static std::shared_ptr<monero_rpc_connection> from_property_tree(const boost::property_tree::ptree& node);
/**
* Checks RPC connection priority order.
*
* @param c1 first priority to compare
* @param c2 second priority to compare
*/
static bool compare(int p1, int p2);
/**
* Initialize a new RPC connection.
*
* @param uri RPC connection uri
* @param username RPC connection authentication username
* @param password RPC connection authentication password
* @param proxy_uri RPC connection proxy uri
* @param zmq_uri RPC connection zmq uri
* @param priority RPC connection priority
* @param timeout_ms RPC connection timeout in milliseconds
*/
monero_rpc_connection(const std::string& uri = "", const std::string& username = "", const std::string& password = "", const std::string& proxy_uri = "", const std::string& zmq_uri = "", int priority = 0, const boost::optional<uint32_t>& timeout_ms = boost::none);
/**
* Copy a RPC connection.
*
* @param rpc RPC connection to copy
*/
monero_rpc_connection(const monero_rpc_connection& rpc);
/**
* Indicates if the connection uri is a TOR server.
*
* @return true or false to indicate if connection uri is a TOR server
*/
bool is_onion() const;
/**
* Indicates if the connection uri is a I2P server.
*
* @return true or false to indicate if connection uri is a I2P server
*/
bool is_i2p() const;
/**
* Set connection credentials.
*
* @param username username to use in RPC authentication
* @param password password to use in RPC authentication
*/
void set_credentials(const std::string& username, const std::string& password);
/**
* Set connection attribute.
*
* @param key is the attribute key
* @param val is the attribute value
*/
void set_attribute(const std::string& key, const std::string& val);
/**
* Get connection attribute.
*
* @param key is the attribute to get the value of
* @return key's value if set
*/
std::string get_attribute(const std::string& key) const;
/**
* Indicates if the connection is online, which is set automatically by calling check_connection().
*
* @return true or false to indicate if online, or null if check_connection() has not been called
*/
boost::optional<bool> is_online() const;
/**
* Indicates if the connection is authenticated, which is set automatically by calling check_connection().
*
* @return true if authenticated or no authentication, false if not authenticated, or null if not set
*/
boost::optional<bool> is_authenticated() const;
/**
* Indicates if the connection is connected, which is set automatically by calling check_connection().
*
* @return true or false to indicate if connected, or null if check_connection() has not been called
*/
boost::optional<bool> is_connected() const;
/**
* Check the connection and update online, authentication, and response time status.
*
* @param timeout_ms the maximum response time before considered offline
* @return
*/
bool check_connection(const boost::optional<uint32_t>& timeout_ms = boost::none);
/**
* Send a request to the RPC API.
*
* @param path specifies the method to request
* @param params are the request's input parameters
* @return the RPC API response as a map
*/
boost::property_tree::ptree send_json_request(const std::string& path, const std::shared_ptr<serializable_struct>& params = nullptr, const boost::optional<uint32_t>& timeout_ms = boost::none) const;
/**
* Send a request to the RPC API.
*
* @param request specifies the method to request with parameters
* @param timeout request timeout in milliseconds
* @return the RPC API response as a map
*/
monero_rpc_response send_json_request(const monero_rpc_request &request, const boost::optional<uint32_t>& timeout_ms = boost::none) const;
/**
* Send a RPC request to the given path and with the given paramters.
*
* E.g. "/get_transactions" with params
*
* @param path is the url path of the request to invoke
* @param params are request parameters sent in the body
* @return the RPC API response as a map
*/
boost::property_tree::ptree send_path_request(const std::string& path, const std::shared_ptr<serializable_struct>& params = nullptr, const boost::optional<uint32_t>& timeout_ms = boost::none) const;
/**
* Send a RPC request to the given path and with the given paramters.
*
* @param request specifies the method to request with parameters
* @param timeout request timeout in milliseconds
* @return the request's deserialized response
*/
monero_rpc_response send_path_request(const monero_rpc_request &request, const boost::optional<uint32_t>& timeout_ms = boost::none) const;
/**
* Send a binary RPC request.
*
* @param request specifies the method to request with paramesters
* @param timeout request timeout in milliseconds
* @return the request's deserialized response
*/
monero_rpc_response send_binary_request(const monero_rpc_request &request, const boost::optional<uint32_t>& timeout_ms = boost::none) const;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
protected:
// instance variables
mutable boost::recursive_mutex m_mutex;
boost::optional<bool> m_is_online;
boost::optional<bool> m_is_authenticated;
mutable std::unique_ptr<epee::net_utils::http::abstract_http_client> m_http_client;
std::unordered_map<std::string, std::string> m_attributes;
mutable std::tuple<std::string, std::string, std::string, std::string> m_applied;
void ensure_configured() const;
std::string invoke_post(const boost::string_ref uri, const std::string& body, const boost::optional<uint32_t>& timeout_ms = boost::none) const;
void send_rpc_request(const boost::string_ref uri, const monero_rpc_request& request, monero_rpc_response& response, const boost::optional<uint32_t>& timeout_ms = boost::none, bool binary = false) const;
};
}
@@ -0,0 +1,60 @@
/**
* Copyright (c) woodser
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#include "monero_daemon.h"
/**
* Public library intereface.
*/
namespace monero {
}
@@ -0,0 +1,805 @@
/**
* Copyright (c) woodser
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#pragma once
#include "common/monero_rpc_connection.h"
#include "monero_daemon_model.h"
/**
* Public library interface.
*/
namespace monero {
/**
* Receives notifications as a daemon is updated.
*/
class monero_daemon_listener {
public:
std::shared_ptr<monero_block_header> m_last_header;
/**
* Called when a new block is added to the chain.
*
* @param header is the header of the block added to the chain
*/
virtual void on_block_header(const std::shared_ptr<monero_block_header>& header) {
m_last_header = header;
}
};
/**
* Monero daemon interface.
*/
class monero_daemon {
public:
/**
* Virtual destructor.
*/
virtual ~monero_daemon() {}
/**
* Register a listener receive daemon notifications.
*
* @param listener is the listener to receive daemon notifications
*/
virtual void add_listener(monero_daemon_listener &listener) {
throw std::runtime_error("monero_daemon::add_listener(): not supported");
}
/**
* Unregister a listener to receive daemon notifications.
*
* @param listener is the listener to unregister
*/
virtual void remove_listener(monero_daemon_listener &listener) {
throw std::runtime_error("monero_daemon::remove_listener(): not supported");
}
/**
* Get the listeners registered with the daemon.
*/
virtual std::set<monero_daemon_listener*> get_listeners() {
throw std::runtime_error("monero_daemon::get_listeners(): not supported");
}
/**
* Remove all listeners registered with the daemon.
*/
virtual void remove_listeners() {
throw std::runtime_error("monero_daemon::remove_listeners(): not supported");
}
/**
* Get the daemon's version.
*
* @return the daemon's version
*/
virtual monero_version get_version() {
throw std::runtime_error("monero_daemon::get_version(): not supported");
}
/**
* Indicates if the daemon is trusted or untrusted.
*
* @return true if the daemon is trusted, false otherwise
*/
virtual bool is_trusted() {
throw std::runtime_error("monero_daemon::is_trusted(): not supported");
}
/**
* Get the number of blocks in the longest chain known to the node.
*
* @return the number of blocks
*/
virtual uint64_t get_height() {
throw std::runtime_error("monero_daemon::get_height(): not supported");
}
/**
* Get a block's hash by its height.
*
* @param height is the height of the block hash to get
* @return tthe block's hash at the given height
*/
virtual std::string get_block_hash(uint64_t height) {
throw std::runtime_error("monero_daemon::get_block_hash(): not supported");
}
/**
* Get a block template for mining a new block.
*
* @param wallet_address is the address of the wallet to receive miner transactions if block is successfully mined
* @param reserve_size is the reserve size (optional)
* @return a block template for mining a new block
*/
virtual std::shared_ptr<monero_block_template> get_block_template(const std::string& wallet_address, const boost::optional<int>& reserve_size = boost::none) {
throw std::runtime_error("monero_daemon::get_block_template(): not supported");
}
/**
* Get the last block's header.
*
* @return the last block's header
*/
virtual std::shared_ptr<monero_block_header> get_last_block_header() {
throw std::runtime_error("monero_daemon::get_last_block_header(): not supported");
}
/**
* Get a block header by its hash.
*
* @param block_hash is the hash of the block to get the header of
* @return the block's header
*/
virtual std::shared_ptr<monero_block_header> get_block_header_by_hash(const std::string& block_hash) {
throw std::runtime_error("monero_daemon::get_block_header_by_hash(): not supported");
}
/**
* Get a block header by its height.
*
* @param height is the height of the block to get the header of
* @return the block's header
*/
virtual std::shared_ptr<monero_block_header> get_block_header_by_height(uint64_t height) {
throw std::runtime_error("monero_daemon::get_block_header_by_height(): not supported");
}
/**
* Get block headers for the given range.
*
* @param start_height is the start height lower bound inclusive (optional)
* @param end_height is the end height upper bound inclusive (optional)
* @return block headers in the given range
*/
virtual std::vector<std::shared_ptr<monero_block_header>> get_block_headers_by_range(uint64_t start_height, uint64_t end_height) {
throw std::runtime_error("monero_daemon::get_block_headers_by_range(): not supported");
}
/**
* Get a block by hash.
*
* @param block_hash is the hash of the block to get
* @return the block with the given hash
*/
virtual std::shared_ptr<monero_block> get_block_by_hash(const std::string& block_hash) {
throw std::runtime_error("monero_daemon::get_block_by_hash(): not supported");
}
/**
* Get blocks by hash.
*
* @param block_hashes are array of hashes; first 10 blocks hash goes sequential,
* next goes in pow(2,n) offset, like 2, 4, 8, 16, 32, 64 and so on,
* and the last one is always genesis block
* @param start_height is the start height to get blocks by hash
* @param prune specifies if returned blocks should be pruned (defaults to false) // TODO: test default
* @return the retrieved blocks
*/
virtual std::vector<std::shared_ptr<monero_block>> get_blocks_by_hash(const std::vector<std::string>& block_hashes, uint64_t start_height, bool prune) {
throw std::runtime_error("monero_daemon::get_blocks_by_hash(): not supported");
}
/**
* Get a block by height.
*
* @param height is the height of the block to get
* @return the block at the given height
*/
virtual std::shared_ptr<monero_block> get_block_by_height(uint64_t height) {
throw std::runtime_error("monero_daemon::get_block_by_height(): not supported");
}
/**
* Get blocks at the given heights.
*
* @param heights are the heights of the blocks to get
* @return blocks at the given heights
*/
virtual std::vector<std::shared_ptr<monero_block>> get_blocks_by_height(const std::vector<uint64_t>& heights) {
throw std::runtime_error("monero_daemon::get_blocks_by_height(): not supported");
}
/**
* Get blocks in the given height range.
*
* @param start_height is the start height lower bound inclusive (optional)
* @param end_height is the end height upper bound inclusive (optional)
* @return blocks in the given height range
*/
virtual std::vector<std::shared_ptr<monero_block>> get_blocks_by_range(boost::optional<uint64_t> start_height, boost::optional<uint64_t> end_height) {
throw std::runtime_error("monero_daemon::get_blocks_by_range(): not supported");
}
/**
* Get blocks in the given height range as chunked requests so that each request is
* not too big.
*
* @param start_height is the start height lower bound inclusive (optional)
* @param end_height is the end height upper bound inclusive (optional)
* @return blocks in the given height range
*/
virtual std::vector<std::shared_ptr<monero_block>> get_blocks_by_range_chunked(boost::optional<uint64_t> start_height, boost::optional<uint64_t> end_height, boost::optional<uint64_t> max_chunk_size) {
throw std::runtime_error("monero_daemon::get_blocks_by_range_chunked(): not supported");
}
/**
* Get block hashes as a binary request to the daemon.
*
* @param block_hashes specify block hashes to fetch; first 10 blocks hash goes
* sequential, next goes in pow(2,n) offset, like 2, 4, 8, 16, 32, 64
* and so on, and the last one is always genesis block
* @param start_height is the starting height of block hashes to return
* @return the requested block hashes
*/
virtual std::vector<std::string> get_block_hashes(const std::vector<std::string>& block_hashes, uint64_t start_height) {
throw std::runtime_error("monero_daemon::get_block_hashes(): not supported");
}
/**
* Get a transaction by hash.
*
* @param tx_hash is the hash of the transaction to get
* @param prune specifies if the returned tx should be pruned (defaults to false)
* @return the transaction with the given hash or null if not found
*/
virtual std::shared_ptr<monero_tx> get_tx(const std::string& tx_hash, bool prune = false) {
std::vector<std::string> hashes;
hashes.push_back(tx_hash);
auto txs = get_txs(hashes, prune);
std::shared_ptr<monero_tx> tx;
if (txs.size() > 0) {
tx = txs[0];
}
return tx;
}
/**
* Get transactions by hashes.
*
* @param tx_hashes are hashes of transactions to get
* @param prune specifies if the returned txs should be pruned (defaults to false)
* @return found transactions with the given hashes
*/
virtual std::vector<std::shared_ptr<monero_tx>> get_txs(const std::vector<std::string>& tx_hashes, bool prune = false) {
throw std::runtime_error("monero_daemon::get_txs(): not supported");
}
/**
* Get a transaction hex by hash.
*
* @param tx_hash is the hash of the transaction to get hex from
* @param prune specifies if the returned tx hex should be pruned (defaults to false)
* @return the tx hex with the given hash
*/
virtual boost::optional<std::string> get_tx_hex(const std::string& tx_hash, bool prune = false) {
std::vector<std::string> hashes;
hashes.push_back(tx_hash);
auto hexes = get_tx_hexes(hashes, prune);
boost::optional<std::string> hex;
if (hexes.size() > 0) {
hex = hexes[0];
}
return hex;
}
/**
* Get transaction hexes by hashes.
*
* @param tx_hashes are hashes of transactions to get hexes from
* @param prune specifies if the returned tx hexes should be pruned (defaults to false)
* @return are the tx hexes
*/
virtual std::vector<std::string> get_tx_hexes(const std::vector<std::string>& tx_hashes, bool prune = false) {
throw std::runtime_error("monero_daemon::get_tx_hexes(): not supported");
}
/**
* Gets the total emissions and fees from the genesis block to the current height.
*
* @param height is the height to start computing the miner sum
* @param num_blocks are the number of blocks to include in the sum
* @return the sum emission and fees since the geneis block
*/
virtual std::shared_ptr<monero_miner_tx_sum> get_miner_tx_sum(uint64_t height, uint64_t num_blocks) {
throw std::runtime_error("monero_daemon::get_miner_tx_sum(): not supported");
}
/**
* Get mining fee estimates per kB.
*
* @param grace_blocks TODO
* @return mining fee estimates per kB
*/
virtual std::shared_ptr<monero_fee_estimate> get_fee_estimate(uint64_t grace_blocks = 0) {
throw std::runtime_error("monero_daemon::get_fee_estimate(): not supported");
}
/**
* Submits a transaction to the daemon's pool.
*
* @param tx_hex is the raw transaction hex to submit
* @param do_not_relay specifies if the tx should be relayed (optional)
* @return the submission results
*/
virtual std::shared_ptr<monero_submit_tx_result> submit_tx_hex(const std::string& tx_hex, bool do_not_relay = false) {
throw std::runtime_error("monero_daemon::submit_tx_hex(): not supported");
}
/**
* Relays a transaction by hash.
*
* @param tx_hash identifies the transaction to relay
*/
virtual void relay_tx_by_hash(const std::string& tx_hash) {
std::vector<std::string> tx_hashes;
tx_hashes.push_back(tx_hash);
relay_txs_by_hash(tx_hashes);
}
/**
* Relays transactions by hash.
*
* @param tx_hashes identify the transactions to relay
*/
virtual void relay_txs_by_hash(const std::vector<std::string>& tx_hashes) {
throw std::runtime_error("monero_daemon::relay_txs_by_hash(): not supported");
}
/**
* Get valid transactions seen by the node but not yet mined into a block, as well
* as spent key image information for the tx pool.
*
* @return transactions in the transaction pool
*/
virtual std::vector<std::shared_ptr<monero_tx>> get_tx_pool() {
throw std::runtime_error("monero_daemon::get_tx_pool(): not supported");
}
/**
* Get hashes of transactions in the transaction pool.
*
* @return hashes of transactions in the transaction pool
*/
virtual std::vector<std::string> get_tx_pool_hashes() {
throw std::runtime_error("monero_daemon::get_tx_pool_hashes(): not supported");
}
/**
* Get all transaction pool backlog.
*
* @return transaction pool backlog entries
*/
virtual std::vector<monero_tx_backlog_entry> get_tx_pool_backlog() {
throw std::runtime_error("monero_daemon::get_tx_pool_backlog(): not supported");
}
/**
* Get transaction pool statistics.
*
* @return statistics about the transaction pool
*/
virtual std::shared_ptr<monero_tx_pool_stats> get_tx_pool_stats() {
throw std::runtime_error("monero_daemon::get_tx_pool_stats(): not supported");
}
/**
* Flushes all transactions from the tx pool.
*/
virtual void flush_tx_pool() {
throw std::runtime_error("monero_daemon::flush_tx_pool(): not supported");
}
/**
* Flush transactions from the tx pool.
*
* @param hashes are hashes of transactions to flush
*/
virtual void flush_tx_pool(const std::vector<std::string> &hashes) {
throw std::runtime_error("monero_daemon::flush_tx_pool(): not supported");
}
/**
* Flush a single transaction from the tx pool.
*
* @param hash is the hash of transaction to flush
*/
virtual void flush_tx_pool(const std::string &hash) {
throw std::runtime_error("monero_daemon::flush_tx_pool(): not supported");
}
/**
* Get the spent status of the given key image.
*
* @param key_image is key image hex to get the status of
* @return the status of the key image
*/
virtual monero_key_image_spent_status get_key_image_spent_status(const std::string& key_image) {
std::vector<std::string> key_images;
key_images.push_back(key_image);
auto statuses = get_key_image_spent_statuses(key_images);
if (statuses.empty()) throw std::runtime_error("Could not get key image spent status");
return statuses[0];
}
/**
* Get the spent status of each given key image.
*
* @param key_image are hex key images to get the statuses of
* @return the spent status for each key image
*/
virtual std::vector<monero_key_image_spent_status> get_key_image_spent_statuses(const std::vector<std::string>& key_images) {
throw std::runtime_error("monero_daemon::get_key_image_spent_statuses(): not supported");
}
/**
* Get outputs identified by a list of output amounts and indices as a binary
* request.
*
* @param outputs identify each output by amount and index
* @return the identified outputs
*/
virtual std::vector<std::shared_ptr<monero_output>> get_outputs(const std::vector<monero_output>& outputs) {
throw std::runtime_error("monero_daemon::get_outputs(): not supported");
}
/**
* Get a histogram of output amounts. For all amounts (possibly filtered by
* parameters), gives the number of outputs on the chain for that amount.
* RingCT outputs counts as 0 amount.
*
* @param amounts are amounts of outputs to make the histogram with
* @param min_count TODO
* @param max_count TODO
* @param is_unlocked makes a histogram with outputs with the specified lock state
* @param recent_cutoff TODO
* @return output histogram entries meeting the parameters
*/
virtual std::vector<std::shared_ptr<monero_output_histogram_entry>> get_output_histogram(const std::vector<uint64_t>& amounts, const boost::optional<int>& min_count, const boost::optional<int>& max_count, const boost::optional<bool>& is_unlocked, const boost::optional<int>& recent_cutoff) {
throw std::runtime_error("monero_daemon::get_output_histogram(): not supported");
}
/**
* Creates an output distribution.
*
* @param amounts are amounts of outputs to make the distribution with
* @param is_cumulative specifies if the results should be cumulative (defaults to TODO)
* @param start_height is the start height lower bound inclusive (optional)
* @param end_height is the end height upper bound inclusive (optional)
* @return output distribution entries meeting the parameters
*/
virtual std::vector<std::shared_ptr<monero_output_distribution_entry>> get_output_distribution(const std::vector<uint64_t>& amounts, const boost::optional<bool>& is_cumulative = boost::none, const boost::optional<uint64_t>& start_height = boost::none, const boost::optional<uint64_t>& end_height = boost::none) {
throw std::runtime_error("monero_daemon::get_output_distribution(): not supported");
}
/**
* Get general information about the state of the node and the network.
*
* @return general information about the node and network
*/
virtual std::shared_ptr<monero_daemon_info> get_info() {
throw std::runtime_error("monero_daemon::get_info(): not supported");
}
/**
* Get synchronization information.
*
* @return contains sync information
*/
virtual std::shared_ptr<monero_daemon_sync_info> get_sync_info() {
throw std::runtime_error("monero_daemon::get_sync_info(): not supported");
}
/**
* Look up information regarding hard fork voting and readiness.
*
* @return hard fork information
*/
virtual std::shared_ptr<monero_hard_fork_info> get_hard_fork_info() {
throw std::runtime_error("monero_daemon::get_hard_fork_info(): not supported");
}
/**
* Get alternative chains seen by the node.
*
* @return alternative chains seen by the node
*/
virtual std::vector<std::shared_ptr<monero_alt_chain>> get_alt_chains() {
throw std::runtime_error("monero_daemon::get_alt_chains(): not supported");
}
/**
* Get known block hashes which are not on the main chain.
*
* @return known block hashes which are not on the main chain
*/
virtual std::vector<std::string> get_alt_block_hashes() {
throw std::runtime_error("monero_daemon::get_alt_block_hashes(): not supported");
}
/**
* Get the download bandwidth limit.
*
* @return is the download bandwidth limit
*/
virtual int get_download_limit() {
throw std::runtime_error("monero_daemon::get_download_limit(): not supported");
}
/**
* Set the download bandwidth limit.
*
* @param limit is the download limit to set (-1 to reset to default)
* @return int is the new download limit after setting
*/
virtual int set_download_limit(int limit) {
throw std::runtime_error("monero_daemon::set_download_limit(): not supported");
}
/**
* Reset the download bandwidth limit.
*
* @return the download bandwidth limit after resetting
*/
virtual int reset_download_limit() {
throw std::runtime_error("monero_daemon::reset_download_limit(): not supported");
}
/**
* Get the upload bandwidth limit.
*
* @return is the upload bandwidth limit
*/
virtual int get_upload_limit() {
throw std::runtime_error("monero_daemon::get_upload_limit(): not supported");
}
/**
* Set the upload bandwidth limit.
*
* @param limit is the upload limit to set (-1 to reset to default)
* @return int is the new upload limit after setting
*/
virtual int set_upload_limit(int limit) {
throw std::runtime_error("monero_daemon::set_upload_limit(): not supported");
}
/**
* Reset the upload bandwidth limit.
*
* @return the upload bandwidth limit after resetting
*/
virtual int reset_upload_limit() {
throw std::runtime_error("monero_daemon::reset_upload_limit(): not supported");
}
/**
* Get peers with active incoming or outgoing connections to the node.
*
* @return the daemon's peers
*/
virtual std::vector<std::shared_ptr<monero_peer>> get_peers() {
throw std::runtime_error("monero_daemon::get_peers(): not supported");
}
/**
* Get all known peers including their last known online status.
*
* @return the daemon's known peers
*/
virtual std::vector<std::shared_ptr<monero_peer>> get_known_peers() {
throw std::runtime_error("monero_daemon::get_known_peers(): not supported");
}
/**
* Limit number of outgoing peers.
*
* @param limit is the maximum number of outgoing peers
*/
virtual void set_outgoing_peer_limit(int limit) {
throw std::runtime_error("monero_daemon::set_outgoing_peer_limit(): not supported");
}
/**
* Limit number of incoming peers.
*
* @param limit is the maximum number of incoming peers
*/
virtual void set_incoming_peer_limit(int limit) {
throw std::runtime_error("monero_daemon::set_incoming_peer_limit(): not supported");
}
/**
* Get peer bans.
*
* @return entries about banned peers
*/
virtual std::vector<std::shared_ptr<monero_ban>> get_peer_bans() {
throw std::runtime_error("monero_daemon::get_peer_bans(): not supported");
}
/**
* Ban peers nodes.
*
* @param bans are bans to apply against peer nodes
*/
virtual void set_peer_bans(const std::vector<std::shared_ptr<monero_ban>>& bans) {
throw std::runtime_error("monero_daemon::set_peer_bans(): not supported");
}
/**
* Ban a peer node.
*
* @param ban contains information about a node to ban
*/
virtual void set_peer_ban(const std::shared_ptr<monero_ban>& ban) {
if (ban == nullptr) throw std::runtime_error("Ban is none");
std::vector<std::shared_ptr<monero_ban>> bans;
bans.push_back(ban);
set_peer_bans(bans);
}
/**
* Start mining.
*
* @param address is the address given miner rewards if the daemon mines a block
* @param num_threads is the number of mining threads to run
* @param is_background specifies if the miner should run in the background or not
* @param ignore_battery specifies if the battery state (e.g. on laptop) should be ignored or not
*/
virtual void start_mining(const std::string &address, boost::optional<uint64_t> num_threads, boost::optional<bool> is_background, boost::optional<bool> ignore_battery) {
throw std::runtime_error("monero_daemon::start_mining(): not supported");
}
/**
* Stop mining.
*/
virtual void stop_mining() {
throw std::runtime_error("monero_daemon::stop_mining(): not supported");
}
/**
* Get the daemon's mining status.
*
* @return the daemon's mining status
*/
virtual std::shared_ptr<monero_mining_status> get_mining_status() {
throw std::runtime_error("monero_daemon::get_mining_status(): not supported");
}
/**
* Generate blocks to a wallet address (regtest only).
*
* @param wallet_address is the address of the wallet to receive miner transactions if block is successfully mined
* @param num_blocks is the number of blocks to generate
* @param prev_block_hash is the hash of the previous block to build on top of (optional, builds on the current tip if not given)
* @param starting_nonce is the starting nonce to use (optional)
* @return the result of generating blocks; height is the height of the last block generated
*/
virtual std::shared_ptr<monero_generate_blocks_result> generate_blocks(const std::string& wallet_address, uint64_t num_blocks, const boost::optional<std::string>& prev_block_hash = boost::none, const boost::optional<uint32_t>& starting_nonce = boost::none) {
throw std::runtime_error("monero_daemon::generate_blocks(): not supported");
}
/**
* Submit a mined block to the network.
*
* @param block_blob is the mined block to submit
*/
virtual void submit_block(const std::string& block_blob) {
std::vector<std::string> block_blobs;
block_blobs.push_back(block_blob);
return submit_blocks(block_blobs);
}
/**
* Submit mined blocks to the network.
*
* @param block_blobs are the mined blocks to submit
*/
virtual void submit_blocks(const std::vector<std::string>& block_blobs) {
throw std::runtime_error("monero_daemon::submit_blocks(): not supported");
}
/**
* Prune the blockchain.
*
* @param check specifies to check the pruning (default false)
* @return the prune result
*/
virtual std::shared_ptr<monero_prune_result> prune_blockchain(bool check) {
throw std::runtime_error("monero_daemon::prune_blockchain(): not supported");
}
/**
* Check for update.
*
* @return the result of the update check
*/
virtual std::shared_ptr<monero_daemon_update_check_result> check_for_update() {
throw std::runtime_error("monero_daemon::check_for_update(): not supported");
}
/**
* Download an update.
*
* @param path is the path to download the update (optional)
* @return the result of the update download
*/
virtual std::shared_ptr<monero_daemon_update_download_result> download_update(const std::string& path = "") {
throw std::runtime_error("monero_daemon::download_update(): not supported");
}
/**
* Safely disconnect and shut down the daemon.
*/
virtual void stop() {
throw std::runtime_error("monero_daemon::stop(): not supported");
}
/**
* Get the header of the next block added to the chain.
*
* @return the header of the next block added to the chain
*/
virtual std::shared_ptr<monero_block_header> wait_for_next_block_header() {
throw std::runtime_error("monero_daemon::wait_for_next_block_header(): not supported");
}
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,639 @@
/**
* Copyright (c) woodser
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#pragma once
#include <map>
#include <boost/property_tree/ptree.hpp>
#include <boost/property_tree/json_parser.hpp>
#include "rapidjson/document.h"
#include "rapidjson/writer.h"
#include "rapidjson/stringbuffer.h"
/**
* Public interface for libmonero-cpp library.
*/
namespace monero {
/**
* Base struct which can be serialized.
*/
struct serializable_struct {
virtual ~serializable_struct() = default;
/**
* Serializes the struct to a json std::string.
*
* @return the struct serialized to a json std::string
*/
std::string serialize() const;
/**
* Converts the struct to a rapidjson Value.
*
* @param allocator is the rapidjson document allocator
* @return the struct as a rapidjson Value
*/
virtual rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const = 0;
};
/**
* Models connection ssl options.
*/
struct ssl_options : public serializable_struct {
boost::optional<std::string> m_ssl_private_key_path;
boost::optional<std::string> m_ssl_certificate_path;
boost::optional<std::string> m_ssl_ca_file;
std::vector<std::string> m_ssl_allowed_fingerprints;
boost::optional<bool> m_ssl_allow_any_cert;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Enumerates Monero connection types.
*/
enum monero_connection_type : uint8_t {
INVALID = 0,
IPV4,
IPV6,
TOR,
I2P
};
/**
* Enumerates Monero network types.
*/
enum monero_network_type : uint8_t {
MAINNET = 0,
TESTNET,
STAGENET
};
/**
* Models connection bandwidth limits.
*/
struct monero_bandwidth_limits : public serializable_struct {
boost::optional<int> m_up;
boost::optional<int> m_down;
monero_bandwidth_limits() { }
monero_bandwidth_limits(int up, int down): m_up(up), m_down(down) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_bandwidth_limits>& limits);
};
/**
* Models a Monero version.
*/
struct monero_version : public serializable_struct {
boost::optional<uint32_t> m_number;
boost::optional<bool> m_is_release;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_version>& version);
};
// forward declarations
struct monero_tx;
struct monero_output;
/**
* Models a Monero block header which contains information about the block.
*
* TODO: a header that is transmitted may have fewer fields like cryptonote::block_header; separate?
*/
struct monero_block_header : public serializable_struct {
boost::optional<std::string> m_hash;
boost::optional<uint64_t> m_height;
boost::optional<uint64_t> m_timestamp;
boost::optional<uint64_t> m_size;
boost::optional<uint64_t> m_weight;
boost::optional<uint64_t> m_long_term_weight;
boost::optional<uint64_t> m_depth;
boost::optional<uint64_t> m_difficulty_low;
boost::optional<uint64_t> m_difficulty_high;
boost::optional<uint64_t> m_cumulative_difficulty_low;
boost::optional<uint64_t> m_cumulative_difficulty_high;
boost::optional<uint32_t> m_major_version;
boost::optional<uint32_t> m_minor_version;
boost::optional<uint32_t> m_nonce;
boost::optional<std::string> m_miner_tx_hash;
boost::optional<uint32_t> m_num_txs;
boost::optional<bool> m_orphan_status;
boost::optional<std::string> m_prev_hash;
boost::optional<uint64_t> m_reward;
boost::optional<std::string> m_pow_hash;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
std::shared_ptr<monero_block_header> copy(const std::shared_ptr<monero_block_header>& src, const std::shared_ptr<monero_block_header>& tgt) const;
virtual void merge(const std::shared_ptr<monero_block_header>& self, const std::shared_ptr<monero_block_header>& other);
};
/**
* Models a Monero block in the blockchain.
*/
struct monero_block : public monero_block_header {
boost::optional<std::string> m_hex;
std::shared_ptr<monero_tx> m_miner_tx;
std::vector<std::shared_ptr<monero_tx>> m_txs;
std::vector<std::string> m_tx_hashes;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
std::shared_ptr<monero_block> copy(const std::shared_ptr<monero_block>& src, const std::shared_ptr<monero_block>& tgt) const;
void merge(const std::shared_ptr<monero_block_header>& self, const std::shared_ptr<monero_block_header>& other);
void merge(const std::shared_ptr<monero_block>& self, const std::shared_ptr<monero_block>& other);
};
/**
* Models a Monero transaction on the blockchain.
*/
struct monero_tx : public serializable_struct {
static const std::string DEFAULT_PAYMENT_ID; // default payment id "0000000000000000"
static const std::string DEFAULT_ID;
std::shared_ptr<monero_block> m_block;
boost::optional<std::string> m_hash;
boost::optional<uint32_t> m_version;
boost::optional<bool> m_is_miner_tx;
boost::optional<std::string> m_payment_id;
boost::optional<uint64_t> m_fee;
boost::optional<uint32_t> m_ring_size;
boost::optional<bool> m_relay;
boost::optional<bool> m_is_relayed;
boost::optional<bool> m_is_confirmed;
boost::optional<bool> m_in_tx_pool;
boost::optional<uint64_t> m_num_confirmations;
boost::optional<uint64_t> m_unlock_time;
boost::optional<uint64_t> m_last_relayed_timestamp;
boost::optional<uint64_t> m_received_timestamp;
boost::optional<bool> m_is_double_spend_seen;
boost::optional<std::string> m_key;
boost::optional<std::string> m_full_hex;
boost::optional<std::string> m_pruned_hex;
boost::optional<std::string> m_prunable_hex;
boost::optional<std::string> m_prunable_hash;
boost::optional<uint64_t> m_size;
boost::optional<uint64_t> m_weight;
std::vector<std::shared_ptr<monero_output>> m_inputs;
std::vector<std::shared_ptr<monero_output>> m_outputs;
std::vector<uint64_t> m_output_indices;
boost::optional<std::string> m_metadata;
boost::optional<std::string> m_common_tx_sets;
std::vector<uint8_t> m_extra;
boost::optional<std::string> m_rct_signatures; // TODO: implement
boost::optional<std::string> m_rct_sig_prunable; // TODO: implement
boost::optional<bool> m_is_kept_by_block;
boost::optional<bool> m_is_failed;
boost::optional<uint64_t> m_last_failed_height;
boost::optional<std::string> m_last_failed_hash;
boost::optional<uint64_t> m_max_used_block_height;
boost::optional<std::string> m_max_used_block_hash;
std::vector<std::string> m_signatures;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, std::shared_ptr<monero_tx> tx);
std::shared_ptr<monero_tx> copy(const std::shared_ptr<monero_tx>& src, const std::shared_ptr<monero_tx>& tgt) const;
virtual void merge(const std::shared_ptr<monero_tx>& self, const std::shared_ptr<monero_tx>& other);
boost::optional<uint64_t> get_height() const;
};
/**
* Models a Monero key image.
*/
struct monero_key_image : public serializable_struct {
boost::optional<std::string> m_hex;
boost::optional<std::string> m_signature;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_key_image>& key_image);
static std::vector<std::shared_ptr<monero_key_image>> deserialize_key_images(const std::string& key_images_json); // TODO: remove this specialty util used once
std::shared_ptr<monero_key_image> copy(const std::shared_ptr<monero_key_image>& src, const std::shared_ptr<monero_key_image>& tgt) const;
void merge(const std::shared_ptr<monero_key_image>& self, const std::shared_ptr<monero_key_image>& other);
};
/**
* Models a Monero transaction output.
*/
struct monero_output : public serializable_struct {
std::shared_ptr<monero_tx> m_tx;
std::shared_ptr<monero_key_image> m_key_image;
boost::optional<uint64_t> m_amount;
boost::optional<uint64_t> m_index;
std::vector<uint64_t> m_ring_output_indices;
boost::optional<std::string> m_stealth_public_key;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_output>& output);
std::shared_ptr<monero_output> copy(const std::shared_ptr<monero_output>& src, const std::shared_ptr<monero_output>& tgt) const;
virtual void merge(const std::shared_ptr<monero_output>& self, const std::shared_ptr<monero_output>& other);
};
/**
* Models the status of a Monero key image.
*/
enum monero_key_image_spent_status : uint8_t {
NOT_SPENT = 0,
CONFIRMED,
TX_POOL
};
/**
* Models a Monero RPC payment information.
*/
struct monero_rpc_payment_info : public serializable_struct {
boost::optional<uint64_t> m_credits;
boost::optional<std::string> m_top_block_hash;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_rpc_payment_info>& rpc_payment_info);
};
/**
* Models an alternative chain seen by the node.
*/
struct monero_alt_chain : public serializable_struct {
std::vector<std::string> m_block_hashes;
boost::optional<uint64_t> m_difficulty_low;
boost::optional<uint64_t> m_difficulty_high;
boost::optional<uint64_t> m_height;
boost::optional<uint64_t> m_length;
boost::optional<std::string> m_main_chain_parent_block_hash;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_alt_chain>& alt_chain);
};
/**
* Monero banhammer.
*/
struct monero_ban : public serializable_struct {
boost::optional<std::string> m_host;
boost::optional<uint32_t> m_ip;
boost::optional<bool> m_is_banned;
boost::optional<uint64_t> m_seconds;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_ban>& ban);
};
/**
* Result of pruning the blockchain.
*/
struct monero_prune_result : public serializable_struct {
boost::optional<bool> m_is_pruned;
boost::optional<int> m_pruning_seed;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_prune_result>& result);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Monero daemon mining status.
*/
struct monero_mining_status : public serializable_struct {
boost::optional<bool> m_is_active;
boost::optional<bool> m_is_background;
boost::optional<std::string> m_address;
boost::optional<uint64_t> m_speed;
boost::optional<int> m_num_threads;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_mining_status>& status);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Model for the summation of miner emissions and fees.
*/
struct monero_miner_tx_sum : public serializable_struct {
boost::optional<uint64_t> m_emission_sum_low;
boost::optional<uint64_t> m_emission_sum_high;
boost::optional<uint64_t> m_fee_sum_low;
boost::optional<uint64_t> m_fee_sum_high;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_miner_tx_sum>& sum);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Monero block template to mine.
*/
struct monero_block_template : public serializable_struct {
boost::optional<std::string> m_block_template_blob;
boost::optional<std::string> m_block_hashing_blob;
boost::optional<std::string> m_prev_hash;
boost::optional<std::string> m_seed_hash;
boost::optional<std::string> m_next_seed_hash;
boost::optional<uint64_t> m_difficulty_low;
boost::optional<uint64_t> m_difficulty_high;
boost::optional<uint64_t> m_expected_reward;
boost::optional<uint64_t> m_height;
boost::optional<uint64_t> m_reserved_offset;
boost::optional<uint64_t> m_seed_height;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_block_template>& tmplt);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Monero daemon connection span.
*/
struct monero_connection_span : public serializable_struct {
boost::optional<std::string> m_connection_id;
boost::optional<std::string> m_remote_address;
boost::optional<uint64_t> m_num_blocks;
boost::optional<uint64_t> m_rate;
boost::optional<uint64_t> m_speed;
boost::optional<uint64_t> m_size;
boost::optional<uint64_t> m_start_height;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_connection_span>& span);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Models a peer to the daemon.
*/
struct monero_peer : public serializable_struct {
boost::optional<std::string> m_id;
boost::optional<std::string> m_address;
boost::optional<std::string> m_host;
boost::optional<int> m_port;
boost::optional<bool> m_is_online;
boost::optional<uint64_t> m_last_seen_timestamp;
boost::optional<int> m_pruning_seed;
boost::optional<int> m_rpc_port;
boost::optional<uint64_t> m_rpc_credits_per_hash;
boost::optional<std::string> m_hash;
boost::optional<uint64_t> m_avg_download;
boost::optional<uint64_t> m_avg_upload;
boost::optional<uint64_t> m_current_download;
boost::optional<uint64_t> m_current_upload;
boost::optional<uint64_t> m_height;
boost::optional<bool> m_is_incoming;
boost::optional<uint64_t> m_live_time;
boost::optional<bool> m_is_local_ip;
boost::optional<bool> m_is_local_host;
boost::optional<uint64_t> m_num_receives;
boost::optional<uint64_t> m_num_sends;
boost::optional<uint64_t> m_receive_idle_time;
boost::optional<uint64_t> m_send_idle_time;
boost::optional<std::string> m_state;
boost::optional<int> m_num_support_flags;
boost::optional<monero_connection_type> m_connection_type;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_peer>& peer);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Models the result from submitting a tx to a daemon.
*/
struct monero_submit_tx_result : public monero_rpc_payment_info {
boost::optional<bool> m_has_invalid_input;
boost::optional<bool> m_has_invalid_output;
boost::optional<bool> m_has_too_few_outputs;
boost::optional<bool> m_is_good;
boost::optional<bool> m_is_relayed;
boost::optional<bool> m_is_double_spend;
boost::optional<bool> m_is_fee_too_low;
boost::optional<bool> m_is_mixin_too_low;
boost::optional<bool> m_is_overspend;
boost::optional<bool> m_is_too_big;
boost::optional<bool> m_sanity_check_failed;
boost::optional<bool> m_is_tx_extra_too_big;
boost::optional<bool> m_is_nonzero_unlock_time;
boost::optional<std::string> m_reason;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_submit_tx_result>& result);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* TODO.
*/
struct monero_tx_backlog_entry {
// TODO
};
/**
* Monero output distribution entry.
*/
struct monero_output_distribution_entry : public serializable_struct {
boost::optional<uint64_t> m_amount;
boost::optional<uint64_t> m_base;
std::vector<uint64_t> m_distribution;
boost::optional<uint64_t> m_start_height;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_output_distribution_entry>& entry);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Entry in a Monero output histogram (see get_output_histogram of Daemon RPC documentation).
*/
struct monero_output_histogram_entry : public serializable_struct {
boost::optional<uint64_t> m_amount;
boost::optional<uint64_t> m_num_instances;
boost::optional<uint64_t> m_unlocked_instances;
boost::optional<uint64_t> m_recent_instances;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_output_histogram_entry>& entry);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Models transaction pool statistics.
*/
struct monero_tx_pool_stats : public serializable_struct {
boost::optional<int> m_num_txs;
boost::optional<int> m_num_not_relayed;
boost::optional<int> m_num_failing;
boost::optional<int> m_num_double_spends;
boost::optional<int> m_num10m;
boost::optional<uint64_t> m_fee_total;
boost::optional<uint64_t> m_bytes_max;
boost::optional<uint64_t> m_bytes_med;
boost::optional<uint64_t> m_bytes_min;
boost::optional<uint64_t> m_bytes_total;
std::map<uint64_t, uint64_t> m_histo;
boost::optional<uint64_t> m_histo98pc;
boost::optional<uint64_t> m_oldest_timestamp;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_tx_pool_stats>& stats);
};
/**
* Models the result of checking for a daemon update.
*/
struct monero_daemon_update_check_result : public serializable_struct {
boost::optional<bool> m_is_update_available;
boost::optional<std::string> m_version;
boost::optional<std::string> m_hash;
boost::optional<std::string> m_auto_uri;
boost::optional<std::string> m_user_uri;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_daemon_update_check_result>& check);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Models the result of downloading an update.
*/
struct monero_daemon_update_download_result : public monero_daemon_update_check_result {
boost::optional<std::string> m_download_path;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_daemon_update_download_result>& check);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Models a Monero fee estimate.
*/
struct monero_fee_estimate : public serializable_struct {
boost::optional<uint64_t> m_quantization_mask;
boost::optional<uint64_t> m_fee;
std::vector<uint64_t> m_fees;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_fee_estimate>& estimate);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Monero daemon info.
*/
struct monero_daemon_info : public monero_rpc_payment_info {
boost::optional<std::string> m_version;
boost::optional<uint64_t> m_num_alt_blocks;
boost::optional<uint64_t> m_block_size_limit;
boost::optional<uint64_t> m_block_size_median;
boost::optional<uint64_t> m_block_weight_limit;
boost::optional<uint64_t> m_block_weight_median;
boost::optional<std::string> m_bootstrap_daemon_address;
boost::optional<uint64_t> m_difficulty_low;
boost::optional<uint64_t> m_difficulty_high;
boost::optional<uint64_t> m_cumulative_difficulty_low;
boost::optional<uint64_t> m_cumulative_difficulty_high;
boost::optional<uint64_t> m_free_space;
boost::optional<int> m_num_offline_peers;
boost::optional<int> m_num_online_peers;
boost::optional<uint64_t> m_height;
boost::optional<uint64_t> m_height_without_bootstrap;
boost::optional<monero_network_type> m_network_type;
boost::optional<bool> m_is_offline;
boost::optional<int> m_num_incoming_connections;
boost::optional<int> m_num_outgoing_connections;
boost::optional<int> m_num_rpc_connections;
boost::optional<uint64_t> m_start_timestamp;
boost::optional<uint64_t> m_adjusted_timestamp;
boost::optional<uint64_t> m_target;
boost::optional<uint64_t> m_target_height;
boost::optional<int> m_num_txs;
boost::optional<int> m_num_txs_pool;
boost::optional<bool> m_was_bootstrap_ever_used;
boost::optional<uint64_t> m_database_size;
boost::optional<bool> m_update_available;
boost::optional<bool> m_is_busy_syncing;
boost::optional<bool> m_is_synchronized;
boost::optional<bool> m_is_restricted;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_daemon_info>& info);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Models daemon synchronization information.
*/
struct monero_daemon_sync_info : public monero_rpc_payment_info {
boost::optional<std::string> m_overview;
boost::optional<uint64_t> m_height;
boost::optional<uint64_t> m_target_height;
boost::optional<int> m_next_needed_pruning_seed;
std::vector<std::shared_ptr<monero_peer>> m_peers;
std::vector<std::shared_ptr<monero_connection_span>> m_spans;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_daemon_sync_info>& info);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Monero hard fork info.
*/
struct monero_hard_fork_info : public monero_rpc_payment_info {
boost::optional<bool> m_is_enabled;
boost::optional<uint64_t> m_earliest_height;
boost::optional<int> m_state;
boost::optional<int> m_threshold;
boost::optional<int> m_version;
boost::optional<int> m_num_votes;
boost::optional<int> m_window;
boost::optional<int> m_voting;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_hard_fork_info>& info);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Models the result of generating blocks.
*/
struct monero_generate_blocks_result : public serializable_struct {
std::vector<std::string> m_block_hashes;
boost::optional<uint64_t> m_height;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_generate_blocks_result>& result);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
}
@@ -0,0 +1,892 @@
/**
* Copyright (c) everoddandeven
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2025-2026 woodser
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#include "monero_daemon_rpc.h"
#include "monero_daemon_rpc_model.h"
#include "common/monero_error.h"
#include "utils/gen_utils.h"
#include "utils/monero_utils.h"
static const uint64_t MAX_REQ_SIZE = 3000000;
static const uint64_t NUM_HEADERS_PER_REQ = 750;
namespace monero {
/**
* Polls daemon and sends notifications in order to notify external daemon listeners.
*/
class monero_daemon_poller : public gen_utils::thread_poller {
public:
explicit monero_daemon_poller(monero_daemon* daemon, uint64_t poll_period_ms = 10000): m_daemon(daemon) {
init_common("monero_daemon_rpc");
m_poll_period_ms = poll_period_ms;
}
~monero_daemon_poller() override {
set_is_polling(false);
}
void poll() override {
if (!m_last_header) {
m_last_header = m_daemon->get_last_block_header();
return;
}
auto header = m_daemon->get_last_block_header();
if (header->m_hash != m_last_header->m_hash) {
m_last_header = header;
announce_block_header(header);
}
}
private:
monero_daemon* m_daemon;
std::shared_ptr<monero_block_header> m_last_header;
void announce_block_header(const std::shared_ptr<monero_block_header>& header) {
gen_utils::thread_poller::announce_scope scope(*this); // see wait_for_callbacks_idle()
auto listeners = m_daemon->get_listeners();
for (auto& listener : listeners) {
try {
listener->on_block_header(header);
} catch (const std::exception& e) {
MERROR("Error calling listener on new block header: " << e.what());
}
}
}
};
/**
* Sends a notification on a condition variable when a block is added to blockchain.
*/
class block_notifier : public monero_daemon_listener {
public:
block_notifier(boost::mutex* temp, boost::condition_variable* cv, bool* ready) { this->temp = temp; this->cv = cv; this->ready = ready; }
void on_block_header(const std::shared_ptr<monero_block_header>& header) override {
boost::mutex::scoped_lock lock(*temp);
m_last_header = header;
*ready = true;
cv->notify_one();
}
private:
boost::mutex* temp;
boost::condition_variable* cv;
bool* ready;
};
void monero_daemon_rpc::set_poll_period_in_ms(uint64_t period_ms) {
boost::lock_guard<boost::mutex> poller_lock(m_poller_mutex);
if (!m_poller) m_poller = std::make_unique<monero_daemon_poller>(this);
m_poller->set_period_in_ms(period_ms);
}
/**
* Validate RPC response status.
*/
void check_response_status(const boost::property_tree::ptree& node) {
for (boost::property_tree::ptree::const_iterator it = node.begin(); it != node.end(); ++it) {
std::string key = it->first;
if (key == std::string("status")) {
std::string status = it->second.data();
// TODO monero-project empty string status is returned for download update response when an update is available
if (status == std::string("OK") || status == std::string("")) {
return;
}
else throw monero_rpc_error(status);
}
}
throw monero_error("Could not get JSON RPC response status");
}
monero_daemon_rpc::monero_daemon_rpc(const std::shared_ptr<monero_rpc_connection>& rpc): m_rpc(rpc) {
if (rpc == nullptr) throw monero_error("Connection cannot be null");
if (!rpc->is_online().value_or(false) && rpc->m_uri != boost::none) rpc->check_connection();
}
monero_daemon_rpc::monero_daemon_rpc(const std::string& uri, const std::string& username, const std::string& password, const std::string& proxy_uri, const std::string& zmq_uri, const boost::optional<uint32_t>& timeout):
m_rpc(std::make_shared<monero_rpc_connection>(uri, username, password, proxy_uri, zmq_uri, 0, timeout)) {
if (!uri.empty()) m_rpc->check_connection();
}
std::set<monero_daemon_listener*> monero_daemon_rpc::get_listeners() {
boost::lock_guard<boost::recursive_mutex> lock(m_listeners_mutex);
return m_listeners;
}
void monero_daemon_rpc::add_listener(monero_daemon_listener &listener) {
{
boost::lock_guard<boost::recursive_mutex> lock(m_listeners_mutex);
m_listeners.insert(&listener);
}
refresh_listening();
}
void monero_daemon_rpc::remove_listener(monero_daemon_listener &listener) {
{
boost::lock_guard<boost::recursive_mutex> lock(m_listeners_mutex);
m_listeners.erase(&listener);
}
refresh_listening();
wait_for_listener_callbacks_idle();
}
void monero_daemon_rpc::remove_listeners() {
{
boost::lock_guard<boost::recursive_mutex> lock(m_listeners_mutex);
m_listeners.clear();
}
refresh_listening();
wait_for_listener_callbacks_idle();
}
void monero_daemon_rpc::wait_for_listener_callbacks_idle() {
// ensures no in-flight callback dispatch can still reference a just-removed listener
monero_daemon_poller* poller;
{
boost::lock_guard<boost::mutex> poller_lock(m_poller_mutex);
poller = m_poller.get();
}
if (poller != nullptr) poller->wait_for_callbacks_idle();
}
std::shared_ptr<monero_rpc_connection> monero_daemon_rpc::get_rpc_connection() const {
MTRACE("monero_daemon_rpc::get_rpc_connection()");
return m_rpc;
}
bool monero_daemon_rpc::is_connected() {
try {
get_version();
return true;
}
catch (...) {
return false;
}
}
monero_version monero_daemon_rpc::get_version() {
auto result = m_rpc->send_json_request("get_version");
check_response_status(result);
monero_version version;
deserialize_version(result, version);
return version;
}
bool monero_daemon_rpc::is_trusted() {
auto result = m_rpc->send_path_request("get_height");
check_response_status(result);
auto get_height_response = std::make_shared<monero_get_block_result>();
monero_get_block_result::from_property_tree(result, get_height_response);
return gen_utils::bool_equals(false, get_height_response->m_untrusted);
}
uint64_t monero_daemon_rpc::get_height() {
auto result = m_rpc->send_json_request("get_block_count");
check_response_status(result);
std::shared_ptr<monero_get_block_result> block_result = std::make_shared<monero_get_block_result>();
monero_get_block_result::from_property_tree(result, block_result);
if (block_result->m_count == boost::none) throw monero_error("Could not get height");
return block_result->m_count.get();
}
std::string monero_daemon_rpc::get_block_hash(uint64_t height) {
std::shared_ptr<monero_get_block_hash_params> params = std::make_shared<monero_get_block_hash_params>(height);
auto result = m_rpc->send_json_request("on_get_block_hash", params);
return result.data();
}
std::shared_ptr<monero_block_template> monero_daemon_rpc::get_block_template(const std::string& wallet_address, const boost::optional<int>& reserve_size) {
MTRACE("monero_daemon_rpc::get_block_template()");
auto params = std::make_shared<monero_get_block_params>(wallet_address, reserve_size);
auto result = m_rpc->send_json_request("get_block_template", params);
check_response_status(result);
std::shared_ptr<monero_block_template> tmplt = std::make_shared<monero_block_template>();
deserialize_block_template(result, tmplt);
return tmplt;
}
std::shared_ptr<monero_block_header> monero_daemon_rpc::get_last_block_header() {
auto result = m_rpc->send_json_request("get_last_block_header");
check_response_status(result);
std::shared_ptr<monero_block_header> header = std::make_shared<monero_block_header>();
deserialize_block_header(result, header);
return header;
}
std::shared_ptr<monero_block_header> monero_daemon_rpc::get_block_header_by_hash(const std::string& hash) {
std::shared_ptr<monero_get_block_params> params = std::make_shared<monero_get_block_params>(hash);
auto result = m_rpc->send_json_request("get_block_header_by_hash", params);
check_response_status(result);
std::shared_ptr<monero_block_header> header = std::make_shared<monero_block_header>();
deserialize_block_header(result, header);
return header;
}
std::shared_ptr<monero_block_header> monero_daemon_rpc::get_block_header_by_height(uint64_t height) {
std::shared_ptr<monero_get_block_params> params = std::make_shared<monero_get_block_params>(height);
auto result = m_rpc->send_json_request("get_block_header_by_height", params);
check_response_status(result);
std::shared_ptr<monero_block_header> header = std::make_shared<monero_block_header>();
deserialize_block_header(result, header);
return header;
}
std::vector<std::shared_ptr<monero_block_header>> monero_daemon_rpc::get_block_headers_by_range(uint64_t start_height, uint64_t end_height) {
auto params = std::make_shared<monero_get_block_params>(start_height, end_height);
auto result = m_rpc->send_json_request("get_block_headers_range", params);
check_response_status(result);
std::vector<std::shared_ptr<monero_block_header>> headers;
deserialize_block_headers(result, headers);
return headers;
}
std::shared_ptr<monero_block> monero_daemon_rpc::get_block_by_hash(const std::string& hash) {
std::shared_ptr<monero_get_block_params> params = std::make_shared<monero_get_block_params>(hash);
auto result = m_rpc->send_json_request("get_block", params);
check_response_status(result);
auto block = std::make_shared<monero_block>();
deserialize_block(result, block);
return block;
}
std::shared_ptr<monero_block_header> monero_daemon_rpc::get_block_header_by_height_cached(uint64_t height, uint64_t max_height, std::unordered_map<uint64_t, std::shared_ptr<monero_block_header>>& header_cache) {
// get header from cache
auto found = header_cache.find(height);
if (found != header_cache.end()) return found->second;
// fetch and cache headers if not in cache
uint64_t end_height = std::min(max_height, height + NUM_HEADERS_PER_REQ - 1);
auto headers = get_block_headers_by_range(height, end_height);
for(const auto& header : headers) {
if (header->m_height == boost::none) throw monero_error("Malformed block header: expected height to be defined");
header_cache[header->m_height.get()] = header;
}
// look up rather than default-construct via operator[]: a daemon response missing the
// requested height must not silently cache (and return) a null header
found = header_cache.find(height);
if (found == header_cache.end()) throw monero_error("Daemon did not return a header for height " + std::to_string(height));
return found->second;
}
std::vector<std::shared_ptr<monero_block>> monero_daemon_rpc::get_blocks_by_hash(const std::vector<std::string>& block_hashes, uint64_t start_height, bool prune) {
throw monero_error("monero_daemon_rpc::get_blocks_by_hash(): not implemented");
}
std::shared_ptr<monero_block> monero_daemon_rpc::get_block_by_height(uint64_t height) {
std::shared_ptr<monero_get_block_params> params = std::make_shared<monero_get_block_params>(height);
auto result = m_rpc->send_json_request("get_block", params);
check_response_status(result);
auto block = std::make_shared<monero_block>();
deserialize_block(result, block);
return block;
}
std::vector<std::shared_ptr<monero_block>> monero_daemon_rpc::get_blocks_by_height(const std::vector<uint64_t>& heights) {
// fetch blocks in binary
monero_get_blocks_by_height_request request(heights);
auto response = m_rpc->send_binary_request(request);
if (response.m_binary == boost::none) throw monero_error("Invalid Monero Binary response");
boost::property_tree::ptree node;
monero_utils::binary_blocks_to_property_tree(response.m_binary.get(), node);
check_response_status(node);
std::vector<std::shared_ptr<monero_block>> blocks;
deserialize_blocks(node, heights, blocks);
return blocks;
}
std::vector<std::shared_ptr<monero_block>> monero_daemon_rpc::get_blocks_by_range(boost::optional<uint64_t> start_height, boost::optional<uint64_t> end_height) {
if (start_height == boost::none) start_height = 0;
if (end_height == boost::none) {
uint64_t block_height = get_height();
end_height = block_height == 0 ? 0 : block_height - 1;
}
std::vector<uint64_t> heights;
for (uint64_t height = start_height.get(); height <= end_height.get(); height++) heights.push_back(height);
return get_blocks_by_height(heights);
}
std::vector<std::shared_ptr<monero_block>> monero_daemon_rpc::get_blocks_by_range_chunked(boost::optional<uint64_t> start_height, boost::optional<uint64_t> end_height, boost::optional<uint64_t> max_chunk_size) {
if (start_height == boost::none) start_height = 0;
if (end_height == boost::none) {
uint64_t block_height = get_height();
end_height = block_height == 0 ? 0 : block_height - 1;
}
boost::optional<uint64_t> last_height = boost::none;
std::vector<std::shared_ptr<monero_block>> blocks;
if (start_height.get() > end_height.get()) return blocks;
// scoped to this call: see the comment on get_max_blocks()/get_block_header_by_height_cached() in the header
std::unordered_map<uint64_t, std::shared_ptr<monero_block_header>> header_cache;
while (last_height == boost::none || last_height.get() < end_height.get()) {
uint64_t height_to_get = (last_height == boost::none) ? start_height.get() : last_height.get() + 1;
auto max_blocks = get_max_blocks(height_to_get, end_height, max_chunk_size, header_cache);
if (max_blocks.empty()) throw monero_error("Daemon returned an empty block chunk for a non-empty range at height " + std::to_string(height_to_get));
blocks.insert(blocks.end(), max_blocks.begin(), max_blocks.end());
last_height = blocks[blocks.size() - 1]->m_height.get();
}
return blocks;
}
std::vector<std::shared_ptr<monero_block>> monero_daemon_rpc::get_max_blocks(boost::optional<uint64_t> start_height, boost::optional<uint64_t> max_height, boost::optional<uint64_t> chunk_size, std::unordered_map<uint64_t, std::shared_ptr<monero_block_header>>& header_cache) {
if (start_height == boost::none) start_height = 0;
if (max_height == boost::none) {
uint64_t block_height = get_height();
max_height = block_height == 0 ? 0 : block_height - 1;
}
if (chunk_size == boost::none) chunk_size = MAX_REQ_SIZE;
// determine end height to fetch
uint64_t req_size = 0;
boost::optional<uint64_t> end_height = boost::none; // none = no block accepted yet
uint64_t height_to_get = start_height.get();
while (req_size < chunk_size.get() && height_to_get <= max_height.get()) {
// get header of next block
auto header = get_block_header_by_height_cached(height_to_get, max_height.get(), header_cache);
if (header->m_size == boost::none) throw monero_error("Malformed block header: expected size to be defined");
uint64_t header_size = header->m_size.get();
// block cannot be bigger than max request size
if (header_size > chunk_size.get()) throw monero_error("Block exceeds maximum request size: " + std::to_string(header_size));
// done iterating if fetching block would exceed max request size
if (req_size + header_size > chunk_size.get()) break;
// otherwise block is included
req_size += header_size;
end_height = height_to_get;
height_to_get++;
}
if (end_height != boost::none) {
return get_blocks_by_range(start_height, end_height.get());
}
return std::vector<std::shared_ptr<monero_block>>();
}
std::vector<std::string> monero_daemon_rpc::get_block_hashes(const std::vector<std::string>& block_hashes, uint64_t start_height) {
throw monero_error("monero_daemon_rpc::get_block_hashes(): not implemented");
}
std::vector<std::shared_ptr<monero_tx>> monero_daemon_rpc::get_txs(const std::vector<std::string>& tx_hashes, bool prune) {
MTRACE("monero_daemon_rpc::get_txs()");
if (tx_hashes.empty()) throw monero_error("Must provide an array of transaction hashes");
auto params = std::make_shared<monero_get_txs_params>(tx_hashes, prune);
auto result = m_rpc->send_path_request("get_transactions", params);
try { check_response_status(result); }
catch (const std::exception& ex) {
if (std::string(ex.what()).find("Failed to parse hex representation of transaction hash") != std::string::npos) {
throw monero_error("Invalid transaction hash");
}
throw;
}
std::vector<std::shared_ptr<monero_tx>> txs;
deserialize_txs(result, txs);
return txs;
}
std::vector<std::string> monero_daemon_rpc::get_tx_hexes(const std::vector<std::string>& tx_hashes, bool prune) {
MTRACE("monero_daemon_rpc::get_tx_hexes()");
std::vector<std::string> hexes;
for(const auto& tx : get_txs(tx_hashes, prune)) {
// tx may be pruned regardless of configuration
if (tx->m_pruned_hex == boost::none) {
if (tx->m_full_hex == boost::none) throw monero_error("Tx has no hex");
hexes.push_back(tx->m_full_hex.get());
} else {
hexes.push_back(tx->m_pruned_hex.get());
}
}
return hexes;
}
std::shared_ptr<monero_miner_tx_sum> monero_daemon_rpc::get_miner_tx_sum(uint64_t height, uint64_t num_blocks) {
auto params = std::make_shared<monero_get_miner_tx_sum_params>(height, num_blocks);
auto result = m_rpc->send_json_request("get_coinbase_tx_sum", params);
check_response_status(result);
auto sum = std::make_shared<monero_miner_tx_sum>();
deserialize_miner_tx_sum(result, sum);
return sum;
}
std::shared_ptr<monero_fee_estimate> monero_daemon_rpc::get_fee_estimate(uint64_t grace_blocks) {
auto params = std::make_shared<monero_get_fee_estimate_params>(grace_blocks);
auto result = m_rpc->send_json_request("get_fee_estimate", params);
check_response_status(result);
auto estimate = std::make_shared<monero_fee_estimate>();
deserialize_fee_estimate(result, estimate);
return estimate;
}
std::shared_ptr<monero_submit_tx_result> monero_daemon_rpc::submit_tx_hex(const std::string& tx_hex, bool do_not_relay) {
MTRACE("monero_daemon_rpc::submit_tx_hex()");
auto params = std::make_shared<monero_submit_tx_params>(tx_hex, do_not_relay);
auto result = m_rpc->send_path_request("send_raw_transaction", params);
auto sum = std::make_shared<monero_submit_tx_result>();
deserialize_submit_tx_result(result, sum);
// set m_is_good based on status
try {
check_response_status(result);
sum->m_is_good = true;
} catch (...) {
sum->m_is_good = false;
}
return sum;
}
void monero_daemon_rpc::relay_txs_by_hash(const std::vector<std::string>& tx_hashes) {
MTRACE("monero_daemon_rpc::relay_txs_by_hash()");
auto params = std::make_shared<monero_submit_tx_params>(tx_hashes);
auto result = m_rpc->send_json_request("relay_tx", params);
check_response_status(result);
}
std::shared_ptr<monero_tx_pool_stats> monero_daemon_rpc::get_tx_pool_stats() {
auto result = m_rpc->send_path_request("get_transaction_pool_stats");
check_response_status(result);
auto stats = std::make_shared<monero_tx_pool_stats>();
deserialize_tx_pool_stats(result, stats);
return stats;
}
std::vector<std::shared_ptr<monero_tx>> monero_daemon_rpc::get_tx_pool() {
auto result = m_rpc->send_path_request("get_transaction_pool");
check_response_status(result);
std::vector<std::shared_ptr<monero_tx>> pool;
deserialize_txs(result, pool);
return pool;
}
std::vector<std::string> monero_daemon_rpc::get_tx_pool_hashes() {
auto result = m_rpc->send_path_request("get_transaction_pool_hashes");
check_response_status(result);
std::vector<std::string> tx_hashes;
deserialize_tx_hashes(result, tx_hashes);
return tx_hashes;
}
void monero_daemon_rpc::flush_tx_pool(const std::vector<std::string> &hashes) {
MTRACE("monero_daemon_rpc::flush_tx_pool()");
auto params = std::make_shared<monero_submit_tx_params>(hashes);
auto result = m_rpc->send_json_request("flush_txpool", params);
check_response_status(result);
}
void monero_daemon_rpc::flush_tx_pool() {
std::vector<std::string> hashes;
flush_tx_pool(hashes);
}
void monero_daemon_rpc::flush_tx_pool(const std::string &hash) {
std::vector<std::string> hashes;
hashes.push_back(hash);
flush_tx_pool(hashes);
}
std::vector<monero_key_image_spent_status> monero_daemon_rpc::get_key_image_spent_statuses(const std::vector<std::string>& key_images) {
if (key_images.empty()) throw monero_error("Must provide key images to check the status of");
auto params = std::make_shared<monero_is_key_image_spent_params>(key_images);
auto result = m_rpc->send_path_request("is_key_image_spent", params);
check_response_status(result);
std::vector<monero_key_image_spent_status> statuses;
deserialize_key_image_spent_status(result, statuses);
return statuses;
}
std::vector<std::shared_ptr<monero_output>> monero_daemon_rpc::get_outputs(const std::vector<monero_output>& outputs) {
throw monero_error("monero_daemon_rpc::get_outputs(): not implemented");
}
std::vector<std::shared_ptr<monero_output_histogram_entry>> monero_daemon_rpc::get_output_histogram(const std::vector<uint64_t>& amounts, const boost::optional<int>& min_count, const boost::optional<int>& max_count, const boost::optional<bool>& is_unlocked, const boost::optional<int>& recent_cutoff) {
MTRACE("monero_daemon_rpc::get_output_histogram()");
auto params = std::make_shared<monero_get_output_histogram_params>(amounts, min_count, max_count, is_unlocked, recent_cutoff);
auto result = m_rpc->send_json_request("get_output_histogram", params);
check_response_status(result);
std::vector<std::shared_ptr<monero_output_histogram_entry>> entries;
deserialize_output_histogram_entries(result, entries);
return entries;
}
std::vector<std::shared_ptr<monero_output_distribution_entry>> monero_daemon_rpc::get_output_distribution(const std::vector<uint64_t>& amounts, const boost::optional<bool>& is_cumulative, const boost::optional<uint64_t>& start_height, const boost::optional<uint64_t>& end_height) {
MTRACE("monero_daemon_rpc::get_output_distribution()");
auto params = std::make_shared<monero_get_output_distribution_params>(amounts, is_cumulative, start_height, end_height);
auto result = m_rpc->send_json_request("get_output_distribution", params);
check_response_status(result);
std::vector<std::shared_ptr<monero_output_distribution_entry>> entries;
deserialize_output_distribution_entries(result, entries);
return entries;
}
std::shared_ptr<monero_daemon_info> monero_daemon_rpc::get_info() {
auto result = m_rpc->send_json_request("get_info");
check_response_status(result);
std::shared_ptr<monero_daemon_info> info = std::make_shared<monero_daemon_info>();
deserialize_daemon_info(result, info);
return info;
}
std::shared_ptr<monero_daemon_sync_info> monero_daemon_rpc::get_sync_info() {
auto result = m_rpc->send_json_request("sync_info");
check_response_status(result);
std::shared_ptr<monero_daemon_sync_info> info = std::make_shared<monero_daemon_sync_info>();
deserialize_daemon_sync_info(result, info);
return info;
}
std::shared_ptr<monero_hard_fork_info> monero_daemon_rpc::get_hard_fork_info() {
auto result = m_rpc->send_json_request("hard_fork_info");
check_response_status(result);
std::shared_ptr<monero_hard_fork_info> info = std::make_shared<monero_hard_fork_info>();
deserialize_hard_fork_info(result, info);
return info;
}
std::vector<std::shared_ptr<monero_alt_chain>> monero_daemon_rpc::get_alt_chains() {
auto result = m_rpc->send_json_request("get_alternate_chains");
check_response_status(result);
std::vector<std::shared_ptr<monero_alt_chain>> alt_chains;
deserialize_alt_chains(result, alt_chains);
return alt_chains;
}
std::vector<std::string> monero_daemon_rpc::get_alt_block_hashes() {
auto result = m_rpc->send_path_request("get_alt_blocks_hashes");
check_response_status(result);
std::vector<std::string> hashes;
deserialize_alt_block_hashes(result, hashes);
return hashes;
}
int monero_daemon_rpc::get_download_limit() {
MTRACE("monero_daemon_rpc::get_download_limit()");
auto limits = get_bandwidth_limits();
if (limits->m_down != boost::none) return limits->m_down.get();
throw monero_error("Could not get download limit");
}
int monero_daemon_rpc::set_download_limit(int limit) {
MTRACE("monero_daemon_rpc::set_download_limit()");
if (limit == -1) return reset_download_limit();
if (limit <= 0) throw monero_error("Download limit must be an integer greater than 0");
auto result = set_bandwidth_limits(0, limit);
if (result->m_down != boost::none) return result->m_down.get();
throw monero_error("Could not set download limit");
}
int monero_daemon_rpc::reset_download_limit() {
MTRACE("monero_daemon_rpc::reset_download_limit()");
auto result = set_bandwidth_limits(0, -1);
if (result->m_down != boost::none) return result->m_down.get();
throw monero_error("Could not set download limit");
}
int monero_daemon_rpc::get_upload_limit() {
MTRACE("monero_daemon_rpc::get_upload_limit()");
auto limits = get_bandwidth_limits();
if (limits->m_up != boost::none) return limits->m_up.get();
throw monero_error("Could not get upload limit");
}
int monero_daemon_rpc::set_upload_limit(int limit) {
MTRACE("monero_daemon_rpc::set_upload_limit()");
if (limit == -1) return reset_upload_limit();
if (limit <= 0) throw monero_error("Upload limit must be an integer greater than 0");
auto result = set_bandwidth_limits(limit, 0);
if (result->m_up != boost::none) return result->m_up.get();
throw monero_error("Could not set download limit");
}
int monero_daemon_rpc::reset_upload_limit() {
MTRACE("monero_daemon_rpc::reset_upload_limit()");
auto result = set_bandwidth_limits(-1, 0);
if (result->m_up != boost::none) return result->m_up.get();
throw monero_error("Could not set download limit");
}
std::vector<std::shared_ptr<monero_peer>> monero_daemon_rpc::get_peers() {
auto result = m_rpc->send_json_request("get_connections");
check_response_status(result);
std::vector<std::shared_ptr<monero_peer>> peers;
deserialize_peers(result, peers);
return peers;
}
std::vector<std::shared_ptr<monero_peer>> monero_daemon_rpc::get_known_peers() {
auto result = m_rpc->send_path_request("get_peer_list");
check_response_status(result);
std::vector<std::shared_ptr<monero_peer>> peers;
deserialize_peers(result, peers);
return peers;
}
void monero_daemon_rpc::set_outgoing_peer_limit(int limit) {
MTRACE("monero_daemon_rpc::set_outgoing_peer_limit()");
if (limit < 0) throw monero_error("Outgoing peer limit must be >= 0");
auto params = std::make_shared<monero_peer_limits_params>();
params->m_out_peers = limit;
auto result = m_rpc->send_path_request("out_peers", params);
check_response_status(result);
}
void monero_daemon_rpc::set_incoming_peer_limit(int limit) {
MTRACE("monero_daemon_rpc::set_incoming_peer_limit()");
if (limit < 0) throw monero_error("Incoming peer limit must be >= 0");
auto params = std::make_shared<monero_peer_limits_params>();
params->m_in_peers = limit;
auto result = m_rpc->send_path_request("in_peers", params);
check_response_status(result);
}
std::vector<std::shared_ptr<monero_ban>> monero_daemon_rpc::get_peer_bans() {
MTRACE("monero_daemon_rpc::get_peer_bans()");
auto result = m_rpc->send_json_request("get_bans");
check_response_status(result);
std::vector<std::shared_ptr<monero_ban>> bans;
deserialize_bans(result, bans);
return bans;
}
void monero_daemon_rpc::set_peer_bans(const std::vector<std::shared_ptr<monero_ban>>& bans) {
MTRACE("monero_daemon_rpc::set_peer_bans()");
auto params = std::make_shared<monero_set_bans_params>(bans);
auto result = m_rpc->send_json_request("set_bans", params);
check_response_status(result);
}
void monero_daemon_rpc::start_mining(const std::string &address, boost::optional<uint64_t> num_threads, boost::optional<bool> background_mining, boost::optional<bool> ignore_battery) {
MTRACE("monero_daemon_rpc::start_mining()");
if (address.empty()) throw monero_error("Must provide address to mine to");
if (num_threads != boost::none && num_threads.get() == 0) throw monero_error("Number of threads must be an integer greater than 0");
auto params = std::make_shared<monero_start_mining_params>(address, num_threads, background_mining, ignore_battery);
auto result = m_rpc->send_path_request("start_mining", params);
check_response_status(result);
}
void monero_daemon_rpc::stop_mining() {
MTRACE("monero_daemon_rpc::stop_mining()");
auto result = m_rpc->send_path_request("stop_mining");
check_response_status(result);
}
std::shared_ptr<monero_mining_status> monero_daemon_rpc::get_mining_status() {
MTRACE("monero_daemon_rpc::get_mining_status()");
auto result = m_rpc->send_path_request("mining_status");
check_response_status(result);
auto status = std::make_shared<monero_mining_status>();
deserialize_mining_status(result, status);
return status;
}
std::shared_ptr<monero_generate_blocks_result> monero_daemon_rpc::generate_blocks(const std::string& wallet_address, uint64_t num_blocks, const boost::optional<std::string>& prev_block_hash, const boost::optional<uint32_t>& starting_nonce) {
MTRACE("monero_daemon_rpc::generate_blocks()");
if (wallet_address.empty()) throw monero_error("Must provide an address to mine to");
if (num_blocks == 0) throw monero_error("Must provide a number of blocks to generate greater than 0");
auto params = std::make_shared<monero_generate_blocks_params>(wallet_address, num_blocks, prev_block_hash, starting_nonce);
auto result = m_rpc->send_json_request("generateblocks", params);
check_response_status(result);
auto generate_result = std::make_shared<monero_generate_blocks_result>();
deserialize_generate_blocks_result(result, generate_result);
return generate_result;
}
void monero_daemon_rpc::submit_blocks(const std::vector<std::string>& block_blobs) {
MTRACE("monero_daemon_rpc::submit_blocks()");
if (block_blobs.empty()) throw monero_error("Must provide an array of mined block blobs to submit");
auto params = std::make_shared<monero_submit_blocks_params>(block_blobs);
auto result = m_rpc->send_json_request("submit_block", params);
check_response_status(result);
}
std::shared_ptr<monero_prune_result> monero_daemon_rpc::prune_blockchain(bool check) {
MTRACE("monero_daemon_rpc::prune_blockchain()");
auto params = std::make_shared<monero_prune_blockchain_params>(check);
auto result = m_rpc->send_json_request("prune_blockchain", params);
check_response_status(result);
std::shared_ptr<monero_prune_result> prune_result = std::make_shared<monero_prune_result>();
deserialize_prune_result(result, prune_result);
return prune_result;
}
std::shared_ptr<monero_daemon_update_check_result> monero_daemon_rpc::check_for_update() {
MTRACE("monero_daemon_rpc::check_for_update()");
auto params = std::make_shared<monero_download_update_params>("check");
auto result = m_rpc->send_path_request("update", params);
check_response_status(result);
auto check_result = std::make_shared<monero_daemon_update_check_result>();
deserialize_update_check_result(result, check_result);
return check_result;
}
std::shared_ptr<monero_daemon_update_download_result> monero_daemon_rpc::download_update(const std::string& path) {
MTRACE("monero_daemon_rpc::download_update()");
auto params = std::make_shared<monero_download_update_params>("download", path);
auto result = m_rpc->send_path_request("update", params);
check_response_status(result);
auto download_result = std::make_shared<monero_daemon_update_download_result>();
deserialize_update_download_result(result, download_result);
return download_result;
}
void monero_daemon_rpc::stop() {
MTRACE("monero_daemon_rpc::stop()");
auto result = m_rpc->send_path_request("stop_daemon");
check_response_status(result);
}
std::shared_ptr<monero_block_header> monero_daemon_rpc::wait_for_next_block_header() {
MTRACE("monero_daemon_rpc::wait_for_next_block_header()");
// use mutex and condition variable with predicate to wait for block
boost::mutex temp;
boost::condition_variable cv;
bool ready = false;
// create listener which notifies condition variable when block is added
block_notifier block_listener(&temp, &cv, &ready);
// register the listener, and unregister it unconditionally on the way out
// otherwise m_listeners keeps a pointer into this stack
// frame after it's gone, and the next block dispatch calls through it (UAF)
struct listener_guard {
monero_daemon_rpc* daemon;
monero_daemon_listener& listener;
~listener_guard() {
// never let remove_listener() escape a destructor since if we're
// already unwinding from another exception, that would call std::terminate
try { daemon->remove_listener(listener); } catch (...) {}
}
} unregister_guard{this, block_listener};
add_listener(block_listener);
// guard against an offline/unresponsive daemon (no connection, stalled node, etc.):
// such a daemon will never announce a new block, so bound the wait instead of hanging
// this call forever.
// TODO monero-java waits indefinitely.
bool timed_out;
std::shared_ptr<monero_block_header> header;
{
boost::mutex::scoped_lock lock(temp);
cv.timed_wait(lock, boost::posix_time::minutes(30), [&]() { return ready; });
// check ready directly rather than trust timed_wait's return value, still under the
// lock: avoids any discrepancy if a header arrives right at the timeout boundary
timed_out = !ready;
// copy out while still holding the lock: block_listener stays registered until the
// unregister_guard destructor runs (at function exit, after this point), so
// on_block_header() could still write m_last_header under temp concurrently with an
// unlocked read here
if (!timed_out) header = block_listener.m_last_header;
}
if (timed_out) throw monero_error("Timed out waiting for next block header");
// return last height
if (header == nullptr) throw monero_error("Could not get last block header.");
return header;
}
std::shared_ptr<monero_bandwidth_limits> monero_daemon_rpc::get_bandwidth_limits() {
MTRACE("monero_daemon_rpc::get_bandwidth_limits()");
auto result = m_rpc->send_path_request("get_limit");
check_response_status(result);
auto limits = std::make_shared<monero_bandwidth_limits>();
monero_bandwidth_limits::from_property_tree(result, limits);
return limits;
}
std::shared_ptr<monero_bandwidth_limits> monero_daemon_rpc::set_bandwidth_limits(int up, int down) {
MTRACE("monero_daemon_rpc::set_bandwidth_limits()");
auto limits = std::make_shared<monero_bandwidth_limits>(up, down);
auto result = m_rpc->send_path_request("set_limit", limits);
check_response_status(result);
monero_bandwidth_limits::from_property_tree(result, limits);
return limits;
}
void monero_daemon_rpc::refresh_listening() {
boost::lock_guard<boost::mutex> poller_lock(m_poller_mutex);
bool should_poll;
{
boost::lock_guard<boost::recursive_mutex> lock(m_listeners_mutex);
should_poll = m_listeners.size() > 0;
}
if (!m_poller && should_poll) {
m_poller = std::make_unique<monero_daemon_poller>(this);
}
if (m_poller) m_poller->request_is_polling(should_poll);
}
monero_daemon_rpc::~monero_daemon_rpc() {
MTRACE("~monero_daemon_rpc()");
remove_listeners();
}
}
@@ -0,0 +1,189 @@
/**
* Copyright (c) everoddandeven
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2025-2026 woodser
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#pragma once
#include "monero_daemon.h"
/**
* Implements a monero_daemon.h using monero-project's monerod.
*/
namespace monero {
// forward declaration of internal daemon poller
class monero_daemon_poller;
/**
* Implements a Monero daemon using monerod-rpc.
*/
class monero_daemon_rpc : public monero_daemon {
public:
/**
* Destruct the daemon.
*/
~monero_daemon_rpc() override;
monero_daemon_rpc(const std::shared_ptr<monero_rpc_connection>& rpc);
monero_daemon_rpc(const std::string& uri, const std::string& username = "", const std::string& password = "", const std::string& proxy_uri = "", const std::string& zmq_uri = "", const boost::optional<uint32_t>& timeout = boost::none);
/**
* Get the daemon's RPC connection.
*
* @return the daemon's rpc connection
*/
std::shared_ptr<monero_rpc_connection> get_rpc_connection() const;
/**
* Indicates if the client is connected to the daemon via RPC.
*
* @return true if the client is connected to the daemon, false otherwise
*/
bool is_connected();
/**
* Set the daemon poll period.
*
* @param period_ms poll period in milliseconds.
*/
void set_poll_period_in_ms(uint64_t period_ms);
/**
* Supported daemon methods.
*/
std::set<monero_daemon_listener*> get_listeners() override;
void add_listener(monero_daemon_listener &listener) override;
void remove_listener(monero_daemon_listener &listener) override;
void remove_listeners() override;
monero_version get_version() override;
bool is_trusted() override;
uint64_t get_height() override;
std::string get_block_hash(uint64_t height) override;
std::shared_ptr<monero_block_template> get_block_template(const std::string& wallet_address, const boost::optional<int>& reserve_size = boost::none) override;
std::shared_ptr<monero_block_header> get_last_block_header() override;
std::shared_ptr<monero_block_header> get_block_header_by_hash(const std::string& hash) override;
std::shared_ptr<monero_block_header> get_block_header_by_height(uint64_t height) override;
std::vector<std::shared_ptr<monero_block_header>> get_block_headers_by_range(uint64_t start_height, uint64_t end_height) override;
std::shared_ptr<monero_block> get_block_by_hash(const std::string& hash) override;
std::vector<std::shared_ptr<monero_block>> get_blocks_by_hash(const std::vector<std::string>& block_hashes, uint64_t start_height, bool prune) override;
std::shared_ptr<monero_block> get_block_by_height(uint64_t height) override;
std::vector<std::shared_ptr<monero_block>> get_blocks_by_height(const std::vector<uint64_t>& heights) override;
std::vector<std::shared_ptr<monero_block>> get_blocks_by_range(boost::optional<uint64_t> start_height, boost::optional<uint64_t> end_height) override;
std::vector<std::shared_ptr<monero_block>> get_blocks_by_range_chunked(boost::optional<uint64_t> start_height, boost::optional<uint64_t> end_height, boost::optional<uint64_t> max_chunk_size) override;
std::vector<std::string> get_block_hashes(const std::vector<std::string>& block_hashes, uint64_t start_height) override;
std::vector<std::shared_ptr<monero_tx>> get_txs(const std::vector<std::string>& tx_hashes, bool prune = false) override;
std::vector<std::string> get_tx_hexes(const std::vector<std::string>& tx_hashes, bool prune = false) override;
std::shared_ptr<monero_miner_tx_sum> get_miner_tx_sum(uint64_t height, uint64_t num_blocks) override;
std::shared_ptr<monero_fee_estimate> get_fee_estimate(uint64_t grace_blocks = 0) override;
std::shared_ptr<monero_submit_tx_result> submit_tx_hex(const std::string& tx_hex, bool do_not_relay = false) override;
void relay_txs_by_hash(const std::vector<std::string>& tx_hashes) override;
std::shared_ptr<monero_tx_pool_stats> get_tx_pool_stats() override;
std::vector<std::shared_ptr<monero_tx>> get_tx_pool() override;
std::vector<std::string> get_tx_pool_hashes() override;
void flush_tx_pool(const std::vector<std::string> &hashes) override;
void flush_tx_pool() override;
void flush_tx_pool(const std::string &hash) override;
std::vector<monero_key_image_spent_status> get_key_image_spent_statuses(const std::vector<std::string>& key_images) override;
std::vector<std::shared_ptr<monero_output>> get_outputs(const std::vector<monero_output>& outputs) override;
std::vector<std::shared_ptr<monero_output_histogram_entry>> get_output_histogram(const std::vector<uint64_t>& amounts, const boost::optional<int>& min_count, const boost::optional<int>& max_count, const boost::optional<bool>& is_unlocked, const boost::optional<int>& recent_cutoff) override;
std::vector<std::shared_ptr<monero_output_distribution_entry>> get_output_distribution(const std::vector<uint64_t>& amounts, const boost::optional<bool>& is_cumulative = boost::none, const boost::optional<uint64_t>& start_height = boost::none, const boost::optional<uint64_t>& end_height = boost::none) override;
std::shared_ptr<monero_daemon_info> get_info() override;
std::shared_ptr<monero_daemon_sync_info> get_sync_info() override;
std::shared_ptr<monero_hard_fork_info> get_hard_fork_info() override;
std::vector<std::shared_ptr<monero_alt_chain>> get_alt_chains() override;
std::vector<std::string> get_alt_block_hashes() override;
int get_download_limit() override;
int set_download_limit(int limit) override;
int reset_download_limit() override;
int get_upload_limit() override;
int set_upload_limit(int limit) override;
int reset_upload_limit() override;
std::vector<std::shared_ptr<monero_peer>> get_peers() override;
std::vector<std::shared_ptr<monero_peer>> get_known_peers() override;
void set_outgoing_peer_limit(int limit) override;
void set_incoming_peer_limit(int limit) override;
std::vector<std::shared_ptr<monero_ban>> get_peer_bans() override;
void set_peer_bans(const std::vector<std::shared_ptr<monero_ban>>& bans) override;
void start_mining(const std::string &address, boost::optional<uint64_t> num_threads, boost::optional<bool> background_mining, boost::optional<bool> ignore_battery) override;
void stop_mining() override;
std::shared_ptr<monero_mining_status> get_mining_status() override;
std::shared_ptr<monero_generate_blocks_result> generate_blocks(const std::string& wallet_address, uint64_t num_blocks, const boost::optional<std::string>& prev_block_hash = boost::none, const boost::optional<uint32_t>& starting_nonce = boost::none) override;
void submit_blocks(const std::vector<std::string>& block_blobs) override;
std::shared_ptr<monero_prune_result> prune_blockchain(bool check) override;
std::shared_ptr<monero_daemon_update_check_result> check_for_update() override;
std::shared_ptr<monero_daemon_update_download_result> download_update(const std::string& path = "") override;
void stop() override;
std::shared_ptr<monero_block_header> wait_for_next_block_header() override;
// --------------------------------- PRIVATE --------------------------------
private:
friend class monero_daemon_poller;
mutable boost::recursive_mutex m_listeners_mutex;
std::set<monero_daemon_listener*> m_listeners;
std::shared_ptr<monero_rpc_connection> m_rpc;
boost::mutex m_poller_mutex;
std::unique_ptr<monero_daemon_poller> m_poller;
// header_cache is scoped to a single get_blocks_by_range_chunked() call (see below) rather than
// stored as a member: it only helps avoid re-fetching a header already seen earlier in that one
// chunking pass, and keeping it as instance state would let it grow unbounded over the connection's
// lifetime and serve stale headers across reorgs on later calls
std::vector<std::shared_ptr<monero_block>> get_max_blocks(boost::optional<uint64_t> start_height, boost::optional<uint64_t> max_height, boost::optional<uint64_t> chunk_size, std::unordered_map<uint64_t, std::shared_ptr<monero_block_header>>& header_cache);
std::shared_ptr<monero_block_header> get_block_header_by_height_cached(uint64_t height, uint64_t max_height, std::unordered_map<uint64_t, std::shared_ptr<monero_block_header>>& header_cache);
std::shared_ptr<monero_bandwidth_limits> get_bandwidth_limits();
std::shared_ptr<monero_bandwidth_limits> set_bandwidth_limits(int up, int down);
void refresh_listening();
void wait_for_listener_callbacks_idle();
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,288 @@
/**
* Copyright (c) everoddandeven
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2025-2026 woodser
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#pragma once
#include "common/monero_rpc_connection.h"
/**
* Internal data model for monero_daemon_rpc.h.
*/
namespace monero {
/**
* Models a Monero RPC banhammer.
*/
struct monero_rpc_ban : public monero_ban {
monero_rpc_ban(const std::shared_ptr<monero_ban> &ban);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
// ------------------------------ Binary Request ---------------------------------
/**
* Models paramaters for monerod-rpc `/get_blocks_by_height.bin` method.
*/
struct monero_get_blocks_by_height_request : public monero_rpc_request {
std::vector<uint64_t> m_heights;
monero_get_blocks_by_height_request(uint64_t num_blocks);
monero_get_blocks_by_height_request(const std::vector<uint64_t>& heights): m_heights(heights) { m_method = "get_blocks_by_height.bin"; }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
// ------------------------------ RPC Params ---------------------------------
struct monero_download_update_params : public serializable_struct {
boost::optional<std::string> m_command;
boost::optional<std::string> m_path;
monero_download_update_params(const std::string& command = "download", const std::string& path = "");
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_submit_tx_params : public serializable_struct {
boost::optional<std::string> m_tx_hex;
boost::optional<bool> m_do_not_relay;
std::vector<std::string> m_tx_hashes;
monero_submit_tx_params(const std::vector<std::string>& tx_hashes): m_tx_hashes(tx_hashes) { }
monero_submit_tx_params(const std::string& tx_hex, bool do_not_relay): m_tx_hex(tx_hex), m_do_not_relay(do_not_relay) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_peer_limits_params : public serializable_struct {
boost::optional<int> m_in_peers;
boost::optional<int> m_out_peers;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_get_txs_params : public serializable_struct {
std::vector<std::string> m_tx_hashes;
boost::optional<bool> m_decode_as_json;
boost::optional<bool> m_prune;
monero_get_txs_params(const std::vector<std::string> &tx_hashes, bool prune, bool decode_as_json = true): m_tx_hashes(tx_hashes), m_prune(prune), m_decode_as_json(decode_as_json) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_is_key_image_spent_params : public serializable_struct {
std::vector<std::string> m_key_images;
monero_is_key_image_spent_params(const std::vector<std::string>& key_images): m_key_images(key_images) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
// ------------------------------ JSON-RPC Params ---------------------------------
struct monero_start_mining_params : public serializable_struct {
boost::optional<std::string> m_miner_address;
boost::optional<uint64_t> m_num_threads;
boost::optional<bool> m_is_background;
boost::optional<bool> m_ignore_battery;
monero_start_mining_params(const boost::optional<std::string>& address, const boost::optional<uint64_t>& num_threads, const boost::optional<bool>& is_background, const boost::optional<bool>& ignore_battery): m_miner_address(address), m_num_threads(num_threads), m_is_background(is_background), m_ignore_battery(ignore_battery) { }
monero_start_mining_params(const boost::optional<uint64_t>& num_threads, const boost::optional<bool>& is_background, const boost::optional<bool>& ignore_battery): m_num_threads(num_threads), m_is_background(is_background), m_ignore_battery(ignore_battery) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_generate_blocks_params : public serializable_struct {
boost::optional<std::string> m_wallet_address;
boost::optional<uint64_t> m_num_blocks;
boost::optional<std::string> m_prev_block_hash;
boost::optional<uint32_t> m_starting_nonce;
monero_generate_blocks_params(const std::string& wallet_address, uint64_t num_blocks, const boost::optional<std::string>& prev_block_hash, const boost::optional<uint32_t>& starting_nonce): m_wallet_address(wallet_address), m_num_blocks(num_blocks), m_prev_block_hash(prev_block_hash), m_starting_nonce(starting_nonce) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_prune_blockchain_params : public serializable_struct {
boost::optional<bool> m_check;
monero_prune_blockchain_params(bool check = true): m_check(check) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_submit_blocks_params : public serializable_struct {
std::vector<std::string> m_block_blobs;
monero_submit_blocks_params(const std::vector<std::string>& block_blobs): m_block_blobs(block_blobs) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_get_block_params : public serializable_struct {
boost::optional<uint64_t> m_height;
boost::optional<std::string> m_hash;
boost::optional<bool> m_fill_pow_hash;
boost::optional<uint64_t> m_start_height;
boost::optional<uint64_t> m_end_height;
boost::optional<std::string> m_wallet_address;
boost::optional<int> m_reserve_size;
monero_get_block_params(uint64_t height, bool fill_pow_hash = false): m_height(height), m_fill_pow_hash(fill_pow_hash) { }
monero_get_block_params(const std::string& hash, bool fill_pow_hash = false): m_hash(hash), m_fill_pow_hash(fill_pow_hash) { }
monero_get_block_params(uint64_t start_height, uint64_t end_height): m_start_height(start_height), m_end_height(end_height) { }
monero_get_block_params(const std::string& wallet_address, const boost::optional<int>& reserve_size): m_wallet_address(wallet_address), m_reserve_size(reserve_size) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_get_block_hash_params : public serializable_struct {
boost::optional<uint64_t> m_height;
monero_get_block_hash_params(uint64_t height): m_height(height) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_get_miner_tx_sum_params : public serializable_struct {
boost::optional<uint64_t> m_height;
boost::optional<uint64_t> m_count;
monero_get_miner_tx_sum_params(uint64_t height, uint64_t count): m_height(height), m_count(count) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_get_fee_estimate_params : public serializable_struct {
boost::optional<uint64_t> m_grace_blocks;
monero_get_fee_estimate_params(uint64_t grace_blocks = 0): m_grace_blocks(grace_blocks) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_set_bans_params : public serializable_struct {
std::vector<std::shared_ptr<monero_rpc_ban>> m_bans;
monero_set_bans_params(const std::vector<std::shared_ptr<monero_ban>>& bans);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_get_output_histogram_params : public serializable_struct {
std::vector<uint64_t> m_amounts;
boost::optional<int> m_min_count;
boost::optional<int> m_max_count;
boost::optional<bool> m_is_unlocked;
boost::optional<int> m_recent_cutoff;
monero_get_output_histogram_params(const std::vector<uint64_t>& amounts, const boost::optional<int>& min_count, const boost::optional<int>& max_count, const boost::optional<bool>& is_unlocked, const boost::optional<int>& recent_cutoff) : m_amounts(amounts), m_min_count(min_count), m_max_count(max_count), m_is_unlocked(is_unlocked), m_recent_cutoff(recent_cutoff) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_get_output_distribution_params : public serializable_struct {
std::vector<uint64_t> m_amounts;
boost::optional<bool> m_cumulative;
boost::optional<bool> m_binary;
boost::optional<uint64_t> m_from_height;
boost::optional<uint64_t> m_to_height;
monero_get_output_distribution_params(const std::vector<uint64_t>& amounts, const boost::optional<bool>& cumulative, const boost::optional<uint64_t>& from_height, const boost::optional<uint64_t>& to_height) : m_amounts(amounts), m_cumulative(cumulative), m_from_height(from_height), m_to_height(to_height), m_binary(false) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
// ------------------------------ JSON-RPC Response ---------------------------------
struct monero_get_block_result {
boost::optional<uint64_t> m_count;
boost::optional<uint64_t> m_height;
boost::optional<bool> m_untrusted;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_get_block_result>& result);
};
// ------------------------------ RPC Deserialization ---------------------------------
void deserialize_version(const boost::property_tree::ptree& node, monero_version& version);
void deserialize_block_header(const boost::property_tree::ptree& node, const std::shared_ptr<monero_block_header>& header);
void deserialize_block_headers(const boost::property_tree::ptree& node, std::vector<std::shared_ptr<monero_block_header>>& headers);
void deserialize_block(const boost::property_tree::ptree& node, const std::shared_ptr<monero_block>& block, bool is_nested = false);
void deserialize_blocks(const boost::property_tree::ptree& node, const std::vector<uint64_t>& heights, std::vector<std::shared_ptr<monero_block>>& blocks);
void deserialize_alt_block_hashes(const boost::property_tree::ptree& node, std::vector<std::string>& block_hashes);
void deserialize_txs(const boost::property_tree::ptree& node, std::vector<std::shared_ptr<monero_tx>>& txs);
void deserialize_tx_hashes(const boost::property_tree::ptree& node, std::vector<std::string>& tx_hashes);
void deserialize_key_image_spent_status(const boost::property_tree::ptree& node, std::vector<monero_key_image_spent_status>& statuses);
void deserialize_alt_chains(const boost::property_tree::ptree& node, std::vector<std::shared_ptr<monero_alt_chain>>& alt_chains);
void deserialize_bans(const boost::property_tree::ptree& node, std::vector<std::shared_ptr<monero_ban>>& bans);
void deserialize_prune_result(const boost::property_tree::ptree& node, const std::shared_ptr<monero_prune_result>& result);
void deserialize_mining_status(const boost::property_tree::ptree& node, const std::shared_ptr<monero_mining_status>& status);
void deserialize_generate_blocks_result(const boost::property_tree::ptree& node, const std::shared_ptr<monero_generate_blocks_result>& result);
void deserialize_miner_tx_sum(const boost::property_tree::ptree& node, const std::shared_ptr<monero_miner_tx_sum>& sum);
void deserialize_block_template(const boost::property_tree::ptree& node, const std::shared_ptr<monero_block_template>& tmplt);
void deserialize_peers(const boost::property_tree::ptree& node, std::vector<std::shared_ptr<monero_peer>>& peers);
void deserialize_submit_tx_result(const boost::property_tree::ptree& node, const std::shared_ptr<monero_submit_tx_result>& result);
void deserialize_output_distribution_entries(const boost::property_tree::ptree& node, std::vector<std::shared_ptr<monero_output_distribution_entry>>& entries);
void deserialize_output_histogram_entries(const boost::property_tree::ptree& node, std::vector<std::shared_ptr<monero_output_histogram_entry>>& entries);
void deserialize_tx_pool_stats(const boost::property_tree::ptree& node, const std::shared_ptr<monero_tx_pool_stats>& stats);
void deserialize_update_check_result(const boost::property_tree::ptree& node, const std::shared_ptr<monero_daemon_update_check_result>& check);
void deserialize_update_download_result(const boost::property_tree::ptree& node, const std::shared_ptr<monero_daemon_update_download_result>& check);
void deserialize_fee_estimate(const boost::property_tree::ptree& node, const std::shared_ptr<monero_fee_estimate>& estimate);
void deserialize_daemon_info(const boost::property_tree::ptree& node, const std::shared_ptr<monero_daemon_info>& info);
void deserialize_daemon_sync_info(const boost::property_tree::ptree& node, const std::shared_ptr<monero_daemon_sync_info>& info);
void deserialize_hard_fork_info(const boost::property_tree::ptree& node, const std::shared_ptr<monero_hard_fork_info>& info);
}
@@ -0,0 +1,220 @@
/**
* Copyright (c) woodser
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#include "gen_utils.h"
#include "rapidjson/stringbuffer.h"
#include "rapidjson/writer.h"
using namespace gen_utils;
// ------------------------- SERIALIZATION ---------------------------
std::string gen_utils::serialize(const rapidjson::Document& doc) {
rapidjson::StringBuffer buffer;
rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
doc.Accept(writer);
return buffer.GetString();
}
std::string gen_utils::serialize(const boost::property_tree::ptree& node) {
std::stringstream ss;
boost::property_tree::write_json(ss, node, false);
std::string str = ss.str();
return str.substr(0, str.size() - 1); // strip newline
}
void gen_utils::deserialize(const std::string& json, boost::property_tree::ptree& root) {
std::istringstream iss = json.empty() ? std::istringstream() : std::istringstream(json);
try {
boost::property_tree::read_json(iss, root);
} catch (std::exception const& e) {
throw std::runtime_error("Invalid JSON");
}
}
// --------------------------- THREAD POLLER ---------------------------
thread_poller::thread_poller(): m_is_polling(false), m_poll_loop_running(false), m_poll_period_ms(20000) {
}
thread_poller::~thread_poller() {
// safety net only: derived classes must stop polling in their own destructor
// (before their members are torn down) since by the time this base destructor
// runs, the derived part of the object is already gone and a concurrently
// running poll() (a virtual call) would hit a partially-destructed object
set_is_polling(false);
}
namespace {
// stack of poller instances whose announce_scope is currently active on the current
// thread, innermost last. Per-instance so that a callback of poller A calling into
// a *different* poller B's wait_for_callbacks_idle() still waits normally.
thread_local std::vector<const thread_poller*> t_active_dispatch_stack;
}
thread_poller::announce_scope::announce_scope(thread_poller& poller): m_poller(poller) {
t_active_dispatch_stack.push_back(&poller);
boost::lock_guard<boost::mutex> lock(m_poller.m_announce_mutex);
++m_poller.m_announce_count;
}
thread_poller::announce_scope::~announce_scope() {
{
boost::lock_guard<boost::mutex> lock(m_poller.m_announce_mutex);
if (--m_poller.m_announce_count == 0) m_poller.m_announce_cv.notify_all();
}
t_active_dispatch_stack.pop_back();
}
void thread_poller::wait_for_callbacks_idle() {
// skip the wait only if this poller's own dispatch is active on the current thread;
// waiting would deadlock in that case. A different poller's active dispatch must not
// short-circuit this wait.
for (const thread_poller* active : t_active_dispatch_stack) {
if (active == this) return;
}
boost::unique_lock<boost::mutex> lock(m_announce_mutex);
m_announce_cv.wait(lock, [&]() { return m_announce_count == 0; });
}
void thread_poller::init_common(const std::string& name) {
m_name = name;
}
void thread_poller::request_is_polling(bool is_polling) {
boost::lock_guard<boost::mutex> lifecycle_lock(m_lifecycle_mutex);
if (is_polling) {
if (m_is_polling) return;
m_is_polling = true;
run_poll_loop();
return;
}
bool was_polling = m_is_polling.exchange(false);
if (was_polling) {
boost::lock_guard<boost::mutex> polling_lock(m_polling_mutex);
m_poll_cv.notify_one();
}
}
void thread_poller::set_is_polling(bool is_polling) {
boost::unique_lock<boost::mutex> lifecycle_lock(m_lifecycle_mutex);
if (is_polling) {
if (m_is_polling) return;
m_is_polling = true;
run_poll_loop();
return;
}
bool was_polling = m_is_polling.exchange(false);
if (was_polling) {
boost::lock_guard<boost::mutex> polling_lock(m_polling_mutex);
m_poll_cv.notify_one();
}
if (!m_poll_loop_running) return; // loop already fully exited; nothing to wait for
if (boost::this_thread::get_id() == m_poll_thread_id) {
// called from the poll thread itself (e.g. a listener removed itself from within a
// callback invoked by poll()): waiting for our own loop to exit would deadlock. Just
// return -- the loop re-checks m_is_polling itself and will exit and clear
// m_poll_loop_running/m_poll_thread_id on its own (see run_poll_loop).
return;
}
// wait for the loop to actually finish
m_lifecycle_cv.wait(lifecycle_lock, [&]() { return !m_poll_loop_running; });
}
void thread_poller::run_poll_loop() {
// called with m_lifecycle_mutex held
if (m_poll_loop_running) return; // only run one loop at a time
m_poll_loop_running = true;
// TODO: use global threadpool, background sync wasm wallet in c++ thread
try {
boost::thread([this]() {
{
boost::lock_guard<boost::mutex> lifecycle_lock(m_lifecycle_mutex);
m_poll_thread_id = boost::this_thread::get_id();
}
while (true) {
while (m_is_polling) {
try { poll(); }
catch (const std::exception& e) { MERROR(m_name << " failed to background poll: " << e.what()); }
catch (...) { MERROR(m_name << " failed to background poll"); }
// only wait if polling still enabled
if (m_is_polling) {
boost::mutex::scoped_lock lock(m_polling_mutex);
boost::posix_time::milliseconds wait_for_ms(m_poll_period_ms.load());
m_poll_cv.timed_wait(lock, wait_for_ms, [&]() { return !m_is_polling; });
}
}
boost::unique_lock<boost::mutex> lifecycle_lock(m_lifecycle_mutex);
if (m_is_polling) continue;
m_poll_loop_running = false;
m_poll_thread_id = boost::thread::id();
m_lifecycle_cv.notify_all();
return;
}
}).detach();
} catch (...) {
m_poll_loop_running = false;
m_is_polling = false;
throw;
}
}
@@ -0,0 +1,220 @@
/**
* Copyright (c) woodser
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#pragma once
#ifndef gen_utils_h
#define gen_utils_h
#include <boost/lexical_cast.hpp>
#include <boost/uuid/uuid.hpp>
#include <boost/uuid/uuid_generators.hpp>
#include <boost/uuid/uuid_io.hpp>
#include <boost/property_tree/ptree.hpp>
#include <boost/property_tree/json_parser.hpp>
#include "rapidjson/document.h"
#include <boost/thread/recursive_mutex.hpp>
#include <boost/thread/thread.hpp>
#include <boost/thread/condition_variable.hpp>
#include <chrono>
#include <thread>
#include <atomic>
#include "include_base_utils.h"
#include "common/util.h"
/**
* Collection of generic utilities.
*/
namespace gen_utils
{
/**
* Return a unique identifier.
*
* @return a unique id
*/
static std::string get_uuid() {
boost::uuids::random_generator generator;
boost::uuids::uuid uuid = generator();
return boost::uuids::to_string(uuid);
}
/**
* Wait for the given duration.
*
* @param duration_ms the duration to wait in milliseconds
*/
static void wait_for(uint64_t duration_ms) {
std::this_thread::sleep_for(std::chrono::milliseconds(duration_ms));
}
static bool bool_equals(bool val, const boost::optional<bool>& opt_val) {
return opt_val == boost::none ? false : val == *opt_val;
}
// ------------------------- SERIALIZATION ---------------------------
// TODO: fully switch from property trees to rapidjson
std::string serialize(const rapidjson::Document& doc);
std::string serialize(const boost::property_tree::ptree& node);
void deserialize(const std::string& json, boost::property_tree::ptree& root);
// ------------------------- VALUE RECONCILATION ----------------------------
// TODO: refactor common template code
template <class T, typename std::enable_if<std::is_same<T, std::string>::value, T>::type* = nullptr>
boost::optional<T> reconcile(const boost::optional<T>& val1, const boost::optional<T>& val2, boost::optional<bool> resolve_defined, boost::optional<bool> resolve_true, boost::optional<bool> resolve_max, const std::string& err_msg = "") {
// check for equality
if (val1 == val2) return val1;
// resolve one value none
if (val1 == boost::none || val2 == boost::none) {
if (resolve_defined != boost::none && *resolve_defined == false) return boost::none;
else return val1 == boost::none ? val2 : val1;
}
throw std::runtime_error(std::string("Cannot reconcile strings: ") + boost::lexical_cast<std::string>(val1) + std::string(" vs ") + boost::lexical_cast<std::string>(val2) + (!err_msg.empty() ? std::string(". ") + err_msg : std::string("")));
}
template <class T, typename std::enable_if<std::is_same<T, std::string>::value, T>::type* = nullptr>
boost::optional<T> reconcile(const boost::optional<T>& val1, const boost::optional<T>& val2, const std::string& err_msg = "") {
return reconcile(val1, val2, boost::none, boost::none, boost::none, err_msg);
}
template <class T, typename std::enable_if<std::is_integral<T>::value, T>::type* = nullptr>
boost::optional<T> reconcile(const boost::optional<T>& val1, const boost::optional<T>& val2, boost::optional<bool> resolve_defined, boost::optional<bool> resolve_true, boost::optional<bool> resolve_max, const std::string& err_msg = "") {
// check for equality
if (val1 == val2) return val1;
// resolve one value none
if (val1 == boost::none || val2 == boost::none) {
if (resolve_defined != boost::none && *resolve_defined == false) return boost::none;
else return val1 == boost::none ? val2 : val1;
}
// resolve different booleans
if (resolve_true != boost::none) return (bool) val1 == *resolve_true ? val1 : val2; // if resolve true, return true, else return false
// resolve different numbers
if (resolve_max != boost::none) return *resolve_max ? std::max(*val1, *val2) : std::min(*val1, *val2);
// cannot reconcile
throw std::runtime_error(std::string("Cannot reconcile integrals: ") + boost::lexical_cast<std::string>(val1) + std::string(" vs ") + boost::lexical_cast<std::string>(val2) + (!err_msg.empty() ? std::string(". ") + err_msg : std::string("")));
}
template <class T, typename std::enable_if<std::is_integral<T>::value, T>::type* = nullptr>
boost::optional<T> reconcile(const boost::optional<T>& val1, const boost::optional<T>& val2, const std::string& err_msg = "") {
return reconcile(val1, val2, boost::none, boost::none, boost::none, err_msg);
}
template <class T>
std::vector<T> reconcile(const std::vector<T>& v1, const std::vector<T>& v2, const std::string& err_msg = "") {
// check for equality
if (v1 == v2) return v1;
// resolve one vector empty
if (v1.empty()) return v2;
if (v2.empty()) return v1;
// otherwise cannot reconcile
throw std::runtime_error("Cannot reconcile vectors" + (!err_msg.empty() ? std::string(". ") + err_msg : std::string("")));
}
// ------------------------- THREAD POLLER ----------------------------
// WARNING: Only destroy a thread_poller from a thread other than its own pool thread.
// (e.g. a listener callback running on the poll thread, drops the last owning reference/unique_ptr
// to the poller.)
class thread_poller {
public:
thread_poller();
virtual ~thread_poller();
bool is_polling() const { return m_is_polling; }
void set_is_polling(bool is_polling);
void request_is_polling(bool is_polling);
void set_period_in_ms(uint64_t period_ms) { m_poll_period_ms = period_ms; }
virtual void poll() = 0;
void wait_for_callbacks_idle();
class announce_scope {
public:
explicit announce_scope(thread_poller& poller);
~announce_scope();
announce_scope(const announce_scope&) = delete;
announce_scope& operator=(const announce_scope&) = delete;
private:
thread_poller& m_poller;
};
protected:
std::string m_name;
boost::recursive_mutex m_mutex;
boost::mutex m_polling_mutex; // guards the interruptible periodic sleep (m_poll_cv)
boost::mutex m_lifecycle_mutex; // guards m_poll_loop_running/m_poll_thread_id and start/stop coordination
boost::thread::id m_poll_thread_id; // id of the currently running loop thread; guarded by m_lifecycle_mutex
std::atomic<bool> m_is_polling;
bool m_poll_loop_running; // guarded by m_lifecycle_mutex
std::atomic<uint64_t> m_poll_period_ms;
boost::condition_variable m_poll_cv; // periodic-sleep interrupt
boost::condition_variable m_lifecycle_cv; // notified (under m_lifecycle_mutex) when the loop actually exits
boost::mutex m_announce_mutex; // guards m_announce_count
boost::condition_variable m_announce_cv; // notified (under m_announce_mutex) when m_announce_count reaches 0
int m_announce_count = 0; // number of announce_scope instances currently alive, across all threads
void init_common(const std::string& name);
void run_poll_loop();
};
}
#endif /* gen_utils_h */
@@ -0,0 +1,624 @@
/**
* Copyright (c) woodser
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#include "monero_utils.h"
#include "rpc/core_rpc_server_commands_defs.h"
#include "storages/portable_storage_template_helper.h"
#include "cryptonote_basic/cryptonote_format_utils.h"
#include "mnemonics/electrum-words.h"
#include "mnemonics/english.h"
#include "string_tools.h"
#include "byte_stream.h"
#include "gen_utils.h"
using namespace cryptonote;
using namespace monero_utils;
void monero_utils::set_log_level(int level) {
mlog_set_log_level(level);
}
void monero_utils::set_log_categories(const std::string& categories) {
mlog_set_categories(categories.c_str());
}
void monero_utils::configure_logging(const std::string& path, bool console) {
mlog_configure(path, console);
}
// --------------------------- VALIDATION UTILS -------------------------------
monero_integrated_address monero_utils::get_integrated_address(monero_network_type network_type, const std::string& standard_address, const std::string& payment_id) {
// parse and validate address
cryptonote::address_parse_info address_info;
if (!get_account_address_from_str(address_info, static_cast<cryptonote::network_type>(network_type), standard_address)) throw std::runtime_error("Invalid address");
if (address_info.has_payment_id) throw std::runtime_error("The given address already has a payment id");
// randomly generate payment id if not given, else validate
crypto::hash8 payment_id_h8;
if (payment_id.empty()) {
payment_id_h8 = crypto::rand<crypto::hash8>();
} else {
cryptonote::blobdata payment_id_data;
if (!epee::string_tools::parse_hexstr_to_binbuff(payment_id, payment_id_data) || sizeof(crypto::hash8) != payment_id_data.size()) throw std::runtime_error("Invalid payment id");
payment_id_h8 = *reinterpret_cast<const crypto::hash8*>(payment_id_data.data());
}
// build integrated address
monero_integrated_address integrated_address;
integrated_address.m_integrated_address = cryptonote::get_account_integrated_address_as_str(static_cast<cryptonote::network_type>(network_type), address_info.address, payment_id_h8);
integrated_address.m_standard_address = standard_address;
integrated_address.m_payment_id = epee::string_tools::pod_to_hex(payment_id_h8);
return integrated_address;
}
bool monero_utils::is_valid_address(const std::string& address, monero_network_type network_type) {
try {
validate_address(address, network_type);
return true;
} catch (...) {
return false;
}
}
bool monero_utils::is_valid_private_view_key(const std::string& private_view_key) {
try {
validate_private_view_key(private_view_key);
return true;
} catch (...) {
return false;
}
}
bool monero_utils::is_valid_private_spend_key(const std::string& private_spend_key) {
try {
validate_private_spend_key(private_spend_key);
return true;
} catch (...) {
return false;
}
}
bool monero_utils::is_valid_public_view_key(const std::string& public_view_key) {
try {
validate_public_view_key(public_view_key);
return true;
} catch (...) {
return false;
}
}
bool monero_utils::is_valid_public_spend_key(const std::string& public_spend_key) {
try {
validate_public_spend_key(public_spend_key);
return true;
} catch (...) {
return false;
}
}
bool monero_utils::is_valid_payment_id(const std::string& payment_id) {
try {
validate_payment_id(payment_id);
return true;
} catch (...) {
return false;
}
}
bool monero_utils::is_valid_mnemonic(const std::string& mnemonic, const std::string& language) {
try {
validate_mnemonic(mnemonic, language);
return true;
}
catch (...) {
return false;
}
}
void monero_utils::validate_address(const std::string& address, monero_network_type network_type) {
cryptonote::address_parse_info info;
if (!get_account_address_from_str(info, static_cast<cryptonote::network_type>(network_type), address)) throw std::runtime_error("Invalid address");
}
void monero_utils::validate_private_view_key(const std::string& private_view_key) {
if (private_view_key.length() != 64) throw std::runtime_error("private view key expected to be 64 hex characters");
cryptonote::blobdata private_view_key_data;
if (!epee::string_tools::parse_hexstr_to_binbuff(private_view_key, private_view_key_data) || private_view_key_data.size() != sizeof(crypto::secret_key)) {
throw std::runtime_error("private view key expected to be 64 hex characters");
}
}
void monero_utils::validate_private_spend_key(const std::string& private_spend_key) {
if (private_spend_key.length() != 64) throw std::runtime_error("private spend key expected to be 64 hex characters");
cryptonote::blobdata private_spend_key_data;
if (!epee::string_tools::parse_hexstr_to_binbuff(private_spend_key, private_spend_key_data) || private_spend_key_data.size() != sizeof(crypto::secret_key)) {
throw std::runtime_error("private spend key expected to be 64 hex characters");
}
}
void monero_utils::validate_public_view_key(const std::string& public_view_key) {
if (public_view_key.length() != 64) throw std::runtime_error("public view key expected to be 64 hex characters");
cryptonote::blobdata public_view_key_data;
if (!epee::string_tools::parse_hexstr_to_binbuff(public_view_key, public_view_key_data) || public_view_key_data.size() != sizeof(crypto::public_key)) {
throw std::runtime_error("public view key expected to be 64 hex characters");
}
}
void monero_utils::validate_public_spend_key(const std::string& public_spend_key) {
if (public_spend_key.length() != 64) throw std::runtime_error("public spend key expected to be 64 hex characters");
cryptonote::blobdata public_spend_key_data;
if (!epee::string_tools::parse_hexstr_to_binbuff(public_spend_key, public_spend_key_data) || public_spend_key_data.size() != sizeof(crypto::public_key)) {
throw std::runtime_error("public spend key expected to be 64 hex characters");
}
}
void monero_utils::validate_payment_id(const std::string& payment_id) {
if (payment_id.length() != 64 && payment_id.length() != 16) throw std::runtime_error("payment id expected to be 64 or 16 hex characters");
cryptonote::blobdata payment_id_data;
if(!epee::string_tools::parse_hexstr_to_binbuff(payment_id, payment_id_data) || (payment_id_data.size() != sizeof(crypto::hash8) && payment_id_data.size() != sizeof(crypto::hash))) {
throw std::runtime_error("payment id expected to be 64 or 16 hex characters");
}
}
void monero_utils::validate_mnemonic(const std::string& mnemonic, const std::string& language) {
if (mnemonic.empty()) throw std::runtime_error("Mnemonic phrase is empty");
std::string seed_language;
crypto::secret_key recovery_key;
if (crypto::ElectrumWords::get_is_old_style_seed(mnemonic)) throw std::runtime_error("Mnemonic phrased words must be 25");
if(!crypto::ElectrumWords::words_to_bytes(mnemonic, recovery_key, seed_language)) throw std::runtime_error("Invalid mnemonic");
if (seed_language == crypto::ElectrumWords::old_language_name) seed_language = "English";
// validate language
if (!language.empty()) {
if (!crypto::ElectrumWords::is_valid_language(language)) throw std::runtime_error("Invalid language: " + language);
if (language != seed_language) throw std::runtime_error("Seed language mismatch");
}
}
// -------------------------- BINARY SERIALIZATION ----------------------------
void monero_utils::json_to_binary(const std::string &json, std::string &bin) {
epee::serialization::portable_storage ps;
ps.load_from_json(json);
epee::byte_stream bs;
ps.store_to_binary(bs);
bin = std::string((char*) bs.data(), bs.size());
}
void monero_utils::binary_to_json(const std::string &bin, std::string &json) {
epee::serialization::portable_storage ps;
ps.load_from_binary(bin);
ps.dump_as_json(json);
}
void monero_utils::binary_blocks_to_json(const std::string &bin, std::string &json) {
// load binary rpc response to struct
cryptonote::COMMAND_RPC_GET_BLOCKS_BY_HEIGHT::response resp_struct;
epee::serialization::load_t_from_binary(resp_struct, bin);
// build property tree from deserialized blocks and transactions
boost::property_tree::ptree root;
boost::property_tree::ptree blocksNode; // array of block strings
boost::property_tree::ptree txsNodes; // array of txs per block (array of array)
for (int blockIdx = 0; blockIdx < resp_struct.blocks.size(); blockIdx++) {
// parse and validate block
cryptonote::block block;
if (cryptonote::parse_and_validate_block_from_blob(resp_struct.blocks[blockIdx].block, block)) {
// add block node to blocks node
boost::property_tree::ptree blockNode;
blockNode.put("", cryptonote::obj_to_json_str(block)); // TODO: no pretty print
blocksNode.push_back(std::make_pair("", blockNode));
} else {
throw std::runtime_error("failed to parse block blob at index " + std::to_string(blockIdx));
}
// parse and validate txs
boost::property_tree::ptree txs_node;
for (int txIdx = 0; txIdx < resp_struct.blocks[blockIdx].txs.size(); txIdx++) {
cryptonote::transaction tx;
if (cryptonote::parse_and_validate_tx_from_blob(resp_struct.blocks[blockIdx].txs[txIdx].blob, tx)) {
// add tx node to txs node
boost::property_tree::ptree txNode;
//MTRACE("PRUNED:\n" << monero_utils::get_pruned_tx_json(tx));
txNode.put("", monero_utils::get_pruned_tx_json(tx)); // TODO: no pretty print
txs_node.push_back(std::make_pair("", txNode));
} else {
throw std::runtime_error("failed to parse tx blob at index " + std::to_string(txIdx));
}
}
txsNodes.push_back(std::make_pair("", txs_node)); // array of array of transactions, one array per block
}
root.add_child("blocks", blocksNode);
root.add_child("txs", txsNodes);
root.put("status", resp_struct.status);
root.put("untrusted", resp_struct.untrusted); // TODO: loss of ints and bools
// convert root to string // TODO: common utility with serial_bridge
std::stringstream ss;
boost::property_tree::write_json(ss, root, false/*pretty*/);
json = ss.str();
}
// ------------------------------- RAPIDJSON ----------------------------------
void monero_utils::add_json_member(std::string key, std::string val, rapidjson::Document::AllocatorType& allocator, rapidjson::Value& root, rapidjson::Value& field) {
rapidjson::Value field_key(key.c_str(), key.size(), allocator);
field.SetString(val.c_str(), val.size(), allocator);
root.AddMember(field_key, field, allocator);
}
void monero_utils::add_json_member(std::string key, bool val, rapidjson::Document::AllocatorType& allocator, rapidjson::Value& root) {
rapidjson::Value field_key(key.c_str(), key.size(), allocator);
if (val) {
rapidjson::Value field_val(rapidjson::kTrueType);
root.AddMember(field_key, field_val, allocator);
} else {
rapidjson::Value field_val(rapidjson::kFalseType);
root.AddMember(field_key, field_val, allocator);
}
}
rapidjson::Value monero_utils::to_rapidjson_val(rapidjson::Document::AllocatorType& allocator, const std::vector<std::string>& strs) {
rapidjson::Value value_arr(rapidjson::kArrayType);
rapidjson::Value value_str(rapidjson::kStringType);
for (const std::string& str : strs) {
value_str.SetString(str.c_str(), str.size(), allocator);
value_arr.PushBack(value_str, allocator);
}
return value_arr;
}
rapidjson::Value monero_utils::to_rapidjson_val(rapidjson::Document::AllocatorType& allocator, const std::vector<int>& nums) {
rapidjson::Value value_arr(rapidjson::kArrayType);
rapidjson::Value value_num(rapidjson::kNumberType);
for (const auto& num : nums) {
value_num.SetInt(num);
value_arr.PushBack(value_num, allocator);
}
return value_arr;
}
rapidjson::Value monero_utils::to_rapidjson_val(rapidjson::Document::AllocatorType& allocator, const std::vector<uint8_t>& nums) {
rapidjson::Value value_arr(rapidjson::kArrayType);
rapidjson::Value value_num(rapidjson::kNumberType);
for (const auto& num : nums) {
value_num.SetInt(num);
value_arr.PushBack(value_num, allocator);
}
return value_arr;
}
// TODO: remove these redundant implementations for different sizes?
rapidjson::Value monero_utils::to_rapidjson_val(rapidjson::Document::AllocatorType& allocator, const std::vector<uint32_t>& nums) {
rapidjson::Value value_arr(rapidjson::kArrayType);
rapidjson::Value value_num(rapidjson::kNumberType);
for (const auto& num : nums) {
value_num.SetUint64(num);
value_arr.PushBack(value_num, allocator);
}
return value_arr;
}
rapidjson::Value monero_utils::to_rapidjson_val(rapidjson::Document::AllocatorType& allocator, const std::vector<uint64_t>& nums) {
rapidjson::Value value_arr(rapidjson::kArrayType);
rapidjson::Value value_num(rapidjson::kNumberType);
for (const auto& num : nums) {
value_num.SetUint64(num);
value_arr.PushBack(value_num, allocator);
}
return value_arr;
}
// ----------------------------------------------------------------------------
bool monero_utils::is_valid_language(const std::string& language) {
std::vector<std::string> languages;
crypto::ElectrumWords::get_language_list(languages, false);
std::vector<std::string>::iterator it = std::find(languages.begin(), languages.end(), language);
if (it == languages.end()) {
crypto::ElectrumWords::get_language_list(languages, true);
it = std::find(languages.begin(), languages.end(), language);
}
if (it == languages.end()) return false;
return true;
}
// TODO: this is unused
std::shared_ptr<monero_block> monero_utils::cn_block_to_block(const cryptonote::block& cn_block) {
cryptonote::block temp = cn_block;
std::cout << cryptonote::obj_to_json_str(temp) << std::endl;
std::shared_ptr<monero_block> block = std::make_shared<monero_block>();
block->m_major_version = cn_block.major_version;
block->m_minor_version = cn_block.minor_version;
block->m_timestamp = cn_block.timestamp;
block->m_prev_hash = epee::string_tools::pod_to_hex(cn_block.prev_id);
block->m_nonce = cn_block.nonce;
block->m_miner_tx = monero_utils::cn_tx_to_tx(cn_block.miner_tx);
for (const crypto::hash& tx_hash : cn_block.tx_hashes) {
block->m_tx_hashes.push_back(epee::string_tools::pod_to_hex(tx_hash));
}
return block;
}
std::shared_ptr<monero_tx> monero_utils::cn_tx_to_tx(const cryptonote::transaction& cn_tx, bool init_as_tx_wallet) {
std::shared_ptr<monero_tx> tx = init_as_tx_wallet ? std::make_shared<monero_tx_wallet>() : std::make_shared<monero_tx>();
tx->m_version = cn_tx.version;
tx->m_unlock_time = cn_tx.unlock_time;
tx->m_hash = epee::string_tools::pod_to_hex(cn_tx.hash);
tx->m_extra = cn_tx.extra;
// init inputs
for (const txin_v& cnVin : cn_tx.vin) {
if (cnVin.which() != 0 && cnVin.which() != 3) throw std::runtime_error("Unsupported variant type");
if (tx->m_is_miner_tx == boost::none) tx->m_is_miner_tx = cnVin.which() == 0;
if (cnVin.which() != 3) continue; // only process txin_to_key of variant TODO: support other types, like 0 "gen" which is miner tx?
std::shared_ptr<monero_output> input = init_as_tx_wallet ? std::make_shared<monero_output_wallet>() : std::make_shared<monero_output>();
input->m_tx = tx;
tx->m_inputs.push_back(input);
const txin_to_key& txin = boost::get<txin_to_key>(cnVin);
input->m_amount = txin.amount;
input->m_ring_output_indices = txin.key_offsets;
crypto::key_image cnKeyImage = txin.k_image;
input->m_key_image = std::make_shared<monero_key_image>();
input->m_key_image->m_hex = epee::string_tools::pod_to_hex(cnKeyImage);
}
// init outputs
for (const tx_out& cnVout : cn_tx.vout) {
std::shared_ptr<monero_output> output = init_as_tx_wallet ? std::make_shared<monero_output_wallet>() : std::make_shared<monero_output>();
output->m_tx = tx;
tx->m_outputs.push_back(output);
output->m_amount = cnVout.amount;
// before HF_VERSION_VIEW_TAGS, outputs with public keys are of type txout_to_key
// after HF_VERSION_VIEW_TAGS, outputs with public keys are of type txout_to_tagged_key
crypto::public_key cnStealthPublicKey;
if (cnVout.target.type() == typeid(txout_to_key))
cnStealthPublicKey = boost::get<txout_to_key>(cnVout.target).key;
else if (cnVout.target.type() == typeid(txout_to_tagged_key))
cnStealthPublicKey = boost::get<txout_to_tagged_key>(cnVout.target).key;
else
throw std::runtime_error(std::string("Unexpected output target type found: ") + std::string(cnVout.target.type().name()));
output->m_stealth_public_key = epee::string_tools::pod_to_hex(cnStealthPublicKey);
}
return tx;
// TODO: finish this, cryptonote::transaction has:
// std::vector<std::vector<crypto::signature> > m_signatures;
// rct::rctSig m_rct_signatures;
// mutable size_t blob_size;
}
void monero_utils::binary_blocks_to_property_tree(const std::string &bin, boost::property_tree::ptree &node) {
std::string response_json;
monero_utils::binary_blocks_to_json(bin, response_json);
std::istringstream iss(response_json);
boost::property_tree::read_json(iss, node);
auto blocks = node.get_child("blocks");
boost::property_tree::ptree parsed_blocks;
for (auto &entry : blocks) {
const std::string &block_str = entry.second.get_value<std::string>();
boost::property_tree::ptree block_node;
gen_utils::deserialize(block_str, block_node);
parsed_blocks.push_back(std::make_pair("", block_node));
}
node.put_child("blocks", parsed_blocks);
auto txs = node.get_child("txs");
boost::property_tree::ptree all_txs;
for (auto &rpc_txs_entry : txs) {
boost::property_tree::ptree txs_for_block;
const auto &rpc_txs = rpc_txs_entry.second;
if (!rpc_txs.empty() || !rpc_txs.data().empty()) {
for (auto &tx_entry : rpc_txs) {
std::string tx_str = tx_entry.second.get_value<std::string>();
auto pos = tx_str.find(',');
if (pos != std::string::npos) {
tx_str.replace(pos, 1, "{");
tx_str += "}";
}
boost::property_tree::ptree tx_node;
gen_utils::deserialize(tx_str, tx_node);
txs_for_block.push_back(std::make_pair("", tx_node));
}
}
all_txs.push_back(std::make_pair("", txs_for_block));
}
node.put_child("txs", all_txs);
}
void monero_utils::merge_tx(const std::shared_ptr<monero_tx_wallet>& tx, std::map<std::string, std::shared_ptr<monero_tx_wallet>>& tx_map, std::map<uint64_t, std::shared_ptr<monero_block>>& block_map) {
if (tx->m_hash == boost::none) throw std::runtime_error("Tx hash is not initialized");
// merge tx
std::map<std::string, std::shared_ptr<monero_tx_wallet>>::const_iterator tx_iter = tx_map.find(*tx->m_hash);
if (tx_iter == tx_map.end()) {
tx_map[*tx->m_hash] = tx; // cache new tx
} else {
std::shared_ptr<monero_tx_wallet>& a_tx = tx_map[*tx->m_hash];
a_tx->merge(a_tx, tx); // merge with existing tx
}
// merge tx's block if confirmed
if (tx->get_height() != boost::none) {
std::map<uint64_t, std::shared_ptr<monero_block>>::const_iterator block_iter = block_map.find(tx->get_height().get());
if (block_iter == block_map.end()) {
block_map[tx->get_height().get()] = tx->m_block; // cache new block
} else {
std::shared_ptr<monero_block>& a_block = block_map[tx->get_height().get()];
a_block->merge(a_block, tx->m_block); // merge with existing block
}
}
}
bool monero_utils::tx_height_less_than(const std::shared_ptr<monero_tx>& tx1, const std::shared_ptr<monero_tx>& tx2) {
if (tx1->m_block != nullptr && tx2->m_block != nullptr) return tx1->get_height() < tx2->get_height();
else if (tx1->m_block == nullptr) return false;
else return true;
}
bool monero_utils::incoming_transfer_before(const std::shared_ptr<monero_incoming_transfer>& transfer1, const std::shared_ptr<monero_incoming_transfer>& transfer2) {
// compare by height
if (tx_height_less_than(transfer1->m_tx, transfer2->m_tx)) return true;
// compare by account and subaddress index
if (transfer1->m_account_index.get() < transfer2->m_account_index.get()) return true;
else if (transfer1->m_account_index.get() == transfer2->m_account_index.get()) return transfer1->m_subaddress_index.get() < transfer2->m_subaddress_index.get();
else return false;
}
bool monero_utils::vout_before(const std::shared_ptr<monero_output>& o1, const std::shared_ptr<monero_output>& o2) {
if (o1 == o2) return false; // ignore equal references
std::shared_ptr<monero_output_wallet> ow1 = std::static_pointer_cast<monero_output_wallet>(o1);
std::shared_ptr<monero_output_wallet> ow2 = std::static_pointer_cast<monero_output_wallet>(o2);
// compare by height
if (tx_height_less_than(ow1->m_tx, ow2->m_tx)) return true;
// compare by account index, subaddress index, output index, then key image hex
if (ow1->m_account_index.get() < ow2->m_account_index.get()) return true;
if (ow1->m_account_index.get() == ow2->m_account_index.get()) {
if (ow1->m_subaddress_index.get() < ow2->m_subaddress_index.get()) return true;
if (ow1->m_subaddress_index.get() == ow2->m_subaddress_index.get()) {
if (ow1->m_index.get() < ow2->m_index.get()) return true;
if (ow1->m_index.get() == ow2->m_index.get()) throw std::runtime_error("Should never sort outputs with duplicate indices");
}
}
return false;
}
static std::string make_uri(const std::string &address, const std::string &payment_id, uint64_t amount, const std::string &tx_description, const std::string &recipient_name, monero_network_type network_type) {
cryptonote::address_parse_info info;
if(!get_account_address_from_str(info, static_cast<cryptonote::network_type>(network_type), address)) {
throw std::runtime_error(std::string("Invalid address: ") + address);
}
if (!payment_id.empty()) {
throw std::runtime_error("Standalone payment id deprecated, use integrated address instead");
}
std::string uri = "monero:" + address;
unsigned int n_fields = 0;
if (amount > 0) {
// URI encoded amount is in decimal units, not atomic units
uri += (n_fields++ ? "&" : "?") + std::string("tx_amount=") + cryptonote::print_money(amount);
}
if (!recipient_name.empty()) {
uri += (n_fields++ ? "&" : "?") + std::string("recipient_name=") + epee::net_utils::conver_to_url_format(recipient_name);
}
if (!tx_description.empty()) {
uri += (n_fields++ ? "&" : "?") + std::string("tx_description=") + epee::net_utils::conver_to_url_format(tx_description);
}
return uri;
}
std::string monero_utils::get_payment_uri(const monero_tx_config& config, monero_network_type network_type) {
// validate config
std::vector<std::shared_ptr<monero_destination>> destinations = config.get_normalized_destinations();
if (destinations.size() != 1) throw std::runtime_error("Cannot make URI from supplied parameters: must provide exactly one destination to send funds");
if (destinations.at(0)->m_address == boost::none) throw std::runtime_error("Cannot make URI from supplied parameters: must provide destination address");
if (destinations.at(0)->m_amount == boost::none) throw std::runtime_error("Cannot make URI from supplied parameters: must provide destination amount");
// prepare wallet2 params
std::string address = destinations.at(0)->m_address.get();
std::string payment_id = config.m_payment_id == boost::none ? "" : config.m_payment_id.get();
uint64_t amount = destinations.at(0)->m_amount.get();
std::string note = config.m_note == boost::none ? "" : config.m_note.get();
std::string m_recipient_name = config.m_recipient_name == boost::none ? "" : config.m_recipient_name.get();
// make uri
std::string uri = make_uri(address, payment_id, amount, note, m_recipient_name, network_type);
if (uri.empty()) throw std::runtime_error("Cannot make URI from supplied parameters");
return uri;
}
uint64_t monero_utils::xmr_to_atomic_units(double amount_xmr) {
if (!std::isfinite(amount_xmr) || amount_xmr < 0) throw std::invalid_argument("amount must be a finite, non-negative number");
long double atomic = std::round(static_cast<long double>(amount_xmr) * static_cast<long double>(XMR_AU_MULTIPLIER));
// validate the actual rounded value about to be cast, not a precomputed "amount_xmr" threshold:
// a threshold like UINT64_MAX / XMR_AU_MULTIPLIER carries its own division rounding error and can
// let a value that overflows uint64_t slip through, and casting an out-of-range floating point
// value to uint64_t is undefined behavior (not a wraparound). 2^64 is exactly representable in any
// binary floating point type with enough exponent range (it's a pure power of two), so this bound
// check is exact regardless of long double's precision on a given platform.
static const long double MAX_ATOMIC_UNITS_EXCLUSIVE = std::ldexp(1.0L, 64); // 2^64
if (atomic < 0.0L || atomic >= MAX_ATOMIC_UNITS_EXCLUSIVE) throw std::invalid_argument("amount exceeds maximum representable atomic units");
return static_cast<uint64_t>(atomic);
}
double monero_utils::atomic_units_to_xmr(uint64_t amount_atomic_units) {
return static_cast<double>(amount_atomic_units) / static_cast<double>(XMR_AU_MULTIPLIER);
}
@@ -0,0 +1,322 @@
/**
* Copyright (c) woodser
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#pragma once
#ifndef monero_utils_h
#define monero_utils_h
#include "wallet/monero_wallet_model.h"
#include "cryptonote_basic/cryptonote_basic.h"
#include "serialization/keyvalue_serialization.h" // TODO: consolidate with other binary deps?
#include "storages/portable_storage.h"
/**
* Collection of utilities for the Monero library.
*/
namespace monero_utils
{
using namespace cryptonote;
// ------------------------------ CONSTANTS ---------------------------------
static const int RING_SIZE = 12; // network-enforced ring size
static const uint64_t XMR_AU_MULTIPLIER = 1000000000000ULL;
// -------------------------------- UTILS -----------------------------------
void set_log_level(int level);
void set_log_categories(const std::string& categories);
void configure_logging(const std::string& path, bool console);
monero_integrated_address get_integrated_address(monero_network_type network_type, const std::string& standard_address, const std::string& payment_id);
std::string get_payment_uri(const monero_tx_config& config, monero_network_type network_type);
bool is_valid_address(const std::string& address, monero_network_type network_type);
bool is_valid_private_view_key(const std::string& private_view_key);
bool is_valid_private_spend_key(const std::string& private_spend_key);
bool is_valid_public_view_key(const std::string& public_view_key);
bool is_valid_public_spend_key(const std::string& public_spend_key);
bool is_valid_payment_id(const std::string& payment_id);
bool is_valid_mnemonic(const std::string& mnemonic, const std::string& language = "");
void validate_address(const std::string& address, monero_network_type network_type);
void validate_private_view_key(const std::string& private_view_key);
void validate_private_spend_key(const std::string& private_spend_key);
void validate_public_view_key(const std::string& public_view_key);
void validate_public_spend_key(const std::string& public_spend_key);
void validate_payment_id(const std::string& payment_id);
void validate_mnemonic(const std::string& mnemonic, const std::string& language = "");
void json_to_binary(const std::string &json, std::string &bin);
void binary_to_json(const std::string &bin, std::string &json);
void binary_blocks_to_json(const std::string &bin, std::string &json);
uint64_t xmr_to_atomic_units(double amount_xmr);
double atomic_units_to_xmr(uint64_t amount_atomic_units);
// ------------------------------ RAPIDJSON ---------------------------------
/**
* Add number, string, and boolean json members using template specialization.
*
* TODO: add_json_member("key", "val", ...) treated as integer instead of string literal
*/
template <class T>
void add_json_member(std::string key, T val, rapidjson::Document::AllocatorType& allocator, rapidjson::Value& root, rapidjson::Value& field) {
rapidjson::Value field_key(key.c_str(), key.size(), allocator);
if (std::is_signed<T>::value) field.SetInt64((int64_t) val);
else field.SetUint64((uint64_t) val);
root.AddMember(field_key, field, allocator);
}
void add_json_member(std::string key, std::string val, rapidjson::Document::AllocatorType& allocator, rapidjson::Value& root, rapidjson::Value& field);
void add_json_member(std::string key, bool val, rapidjson::Document::AllocatorType& allocator, rapidjson::Value& root);
// TODO: template implementation here, could move to monero_utils.hpp per https://stackoverflow.com/questions/3040480/c-template-function-compiles-in-header-but-not-implementation
template <class T> rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator, const std::vector<std::shared_ptr<T>>& vals) {
rapidjson::Value value_arr(rapidjson::kArrayType);
for (const auto& val : vals) {
value_arr.PushBack(val->to_rapidjson_val(allocator), allocator);
}
return value_arr;
}
// TODO: template implementation here, could move to monero_utils.hpp per https://stackoverflow.com/questions/3040480/c-template-function-compiles-in-header-but-not-implementation
template <class T> rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator, const std::vector<T>& vals) {
rapidjson::Value value_arr(rapidjson::kArrayType);
for (const auto& val : vals) {
value_arr.PushBack(val.to_rapidjson_val(allocator), allocator);
}
return value_arr;
}
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator, const std::vector<std::string>& strs);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator, const std::vector<int>& nums);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator, const std::vector<uint8_t>& nums);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator, const std::vector<uint32_t>& nums);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator, const std::vector<uint64_t>& nums);
// --------------------------------------------------------------------------
/**
* Indicates if the given language is valid.
*
* @param language is the language to validate
* @return true if the language is valid, false otherwise
*/
bool is_valid_language(const std::string& language);
/**
* Convert a cryptonote::block to a block in this library's native model.
*
* @param cn_block is the block to convert
* @return a block in this library's native model
*/
std::shared_ptr<monero_block> cn_block_to_block(const cryptonote::block& cn_block);
/**
* Convert a cryptonote::transaction to a transaction in this library's
* native model.
*
* @param cn_tx is the transaction to convert
* @param init_as_tx_wallet specifies if a monero_tx xor monero_tx_wallet should be initialized
*/
std::shared_ptr<monero_tx> cn_tx_to_tx(const cryptonote::transaction& cn_tx, bool init_as_tx_wallet = false);
/**
* Modified from core_rpc_server.cpp to return a std::string.
*
* TODO: remove this duplicate, use core_rpc_server instead
*/
static std::string get_pruned_tx_json(cryptonote::transaction &tx)
{
std::stringstream ss;
json_archive<true> ar(ss);
bool r = tx.serialize_base(ar);
CHECK_AND_ASSERT_MES(r, std::string(), "Failed to serialize rct signatures base");
return ss.str();
}
void binary_blocks_to_property_tree(const std::string &bin, boost::property_tree::ptree &node);
/**
* Merges a transaction into a unique set of transactions.
*
* @param tx is the transaction to merge into the existing txs
* @param tx_map maps tx hashes to txs
* @param block_map maps block heights to blocks
*/
void merge_tx(const std::shared_ptr<monero_tx_wallet>& tx, std::map<std::string, std::shared_ptr<monero_tx_wallet>>& tx_map, std::map<uint64_t, std::shared_ptr<monero_block>>& block_map);
/**
* Returns true iff tx1's height is known to be less than tx2's height for sorting.
*/
bool tx_height_less_than(const std::shared_ptr<monero_tx>& tx1, const std::shared_ptr<monero_tx>& tx2);
/**
* Returns true iff transfer1 is ordered before transfer2 by ascending account and subaddress indices.
*/
bool incoming_transfer_before(const std::shared_ptr<monero_incoming_transfer>& transfer1, const std::shared_ptr<monero_incoming_transfer>& transfer2);
/**
* Returns true iff wallet vout1 is ordered before vout2 by ascending account and subaddress indices then index.
*/
bool vout_before(const std::shared_ptr<monero_output>& o1, const std::shared_ptr<monero_output>& o2);
// ----------------------------- GATHER BLOCKS ------------------------------
static std::vector<std::shared_ptr<monero_block>> get_blocks_from_txs(std::vector<std::shared_ptr<monero_tx_wallet>> txs) {
std::shared_ptr<monero_block> unconfirmed_block = nullptr; // placeholder for unconfirmed txs
std::vector<std::shared_ptr<monero_block>> blocks;
std::unordered_set<std::shared_ptr<monero_block>> seen_block_ptrs;
for (const std::shared_ptr<monero_tx_wallet>& tx : txs) {
if (tx->m_block == nullptr) {
if (unconfirmed_block == nullptr) unconfirmed_block = std::make_shared<monero_block>();
tx->m_block = unconfirmed_block;
unconfirmed_block->m_txs.push_back(tx);
}
std::unordered_set<std::shared_ptr<monero_block>>::const_iterator got = seen_block_ptrs.find(tx->m_block);
if (got == seen_block_ptrs.end()) {
seen_block_ptrs.insert(tx->m_block);
blocks.push_back(tx->m_block);
}
}
return blocks;
}
static std::vector<std::shared_ptr<monero_block>> get_blocks_from_transfers(std::vector<std::shared_ptr<monero_transfer>> transfers) {
std::shared_ptr<monero_block> unconfirmed_block = nullptr; // placeholder for unconfirmed txs in return json
std::vector<std::shared_ptr<monero_block>> blocks;
std::unordered_set<std::shared_ptr<monero_block>> seen_block_ptrs;
for (auto const& transfer : transfers) {
std::shared_ptr<monero_tx_wallet> tx = transfer->m_tx;
if (tx->m_block == nullptr) {
if (unconfirmed_block == nullptr) unconfirmed_block = std::make_shared<monero_block>();
tx->m_block = unconfirmed_block;
unconfirmed_block->m_txs.push_back(tx);
}
std::unordered_set<std::shared_ptr<monero_block>>::const_iterator got = seen_block_ptrs.find(tx->m_block);
if (got == seen_block_ptrs.end()) {
seen_block_ptrs.insert(tx->m_block);
blocks.push_back(tx->m_block);
}
}
return blocks;
}
static std::vector<std::shared_ptr<monero_block>> get_blocks_from_outputs(std::vector<std::shared_ptr<monero_output_wallet>> outputs) {
std::vector<std::shared_ptr<monero_block>> blocks;
std::unordered_set<std::shared_ptr<monero_block>> seen_block_ptrs;
for (auto const& output : outputs) {
std::shared_ptr<monero_tx_wallet> tx = std::static_pointer_cast<monero_tx_wallet>(output->m_tx);
if (tx->m_block == nullptr) throw std::runtime_error("Need to handle unconfirmed output");
std::unordered_set<std::shared_ptr<monero_block>>::const_iterator got = seen_block_ptrs.find(tx->m_block);
if (got == seen_block_ptrs.end()) {
seen_block_ptrs.insert(tx->m_block);
blocks.push_back(tx->m_block);
}
}
return blocks;
}
// ------------------------------ FREE MEMORY -------------------------------
static void free(std::shared_ptr<monero_block> block) {
for (std::shared_ptr<monero_tx>& tx : block->m_txs) {
tx->m_block.reset();
monero_tx_wallet* tx_wallet = dynamic_cast<monero_tx_wallet*>(tx.get());
if (tx_wallet != nullptr) {
if (tx_wallet->m_tx_set != nullptr) tx_wallet->m_tx_set.reset();
if (tx_wallet->m_outgoing_transfer != nullptr) tx_wallet->m_outgoing_transfer->m_tx.reset();
for (std::shared_ptr<monero_transfer> transfer : tx_wallet->m_incoming_transfers) transfer->m_tx.reset();
for (std::shared_ptr<monero_output> output : tx_wallet->m_outputs) output->m_tx.reset();
for (std::shared_ptr<monero_output> input : tx_wallet->m_inputs) {
input->m_key_image.reset();
input->m_tx.reset();
}
}
monero_tx_query* tx_query = dynamic_cast<monero_tx_query*>(tx.get());
if (tx_query != nullptr) {
if (tx_query->m_transfer_query != nullptr) {
tx_query->m_transfer_query->m_tx_query.reset();
tx_query->m_transfer_query.reset();
}
if (tx_query->m_output_query != nullptr) {
tx_query->m_output_query->m_tx_query.reset();
tx_query->m_output_query.reset();
}
}
}
block.reset();
}
static void free(std::vector<std::shared_ptr<monero_block>> blocks) {
for (std::shared_ptr<monero_block>& block : blocks) monero_utils::free(block);
}
static void free(std::shared_ptr<monero_tx> tx) {
if (tx->m_block == nullptr) {
std::shared_ptr<monero_block> block = std::make_shared<monero_block>();
tx->m_block = block;
block->m_txs.push_back(tx);
}
monero_utils::free(tx->m_block);
}
static void free(std::vector<std::shared_ptr<monero_tx_wallet>> txs) {
return monero_utils::free(monero_utils::get_blocks_from_txs(txs));
}
static void free(std::vector<std::shared_ptr<monero_transfer>> transfers) {
return monero_utils::free(monero_utils::get_blocks_from_transfers(transfers));
}
static void free(std::vector<std::shared_ptr<monero_output_wallet>> outputs) {
return monero_utils::free(monero_utils::get_blocks_from_outputs(outputs));
}
}
#endif /* monero_utils_h */
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,311 @@
/**
* Copyright (c) woodser
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#pragma once
#include <boost/optional.hpp>
#include <boost/thread/mutex.hpp>
#include <boost/thread/thread.hpp>
#include <boost/thread/condition_variable.hpp>
#include <atomic>
#include "monero_wallet.h"
#include "wallet/wallet2.h"
/**
* Implements a monero_wallet.h by wrapping monero-project's wallet2.
*/
namespace monero {
// -------------------------------- LISTENERS -------------------------------
// forward declaration of internal wallet2 listener
struct wallet2_listener;
// --------------------------- STATIC WALLET UTILS --------------------------
/**
* Monero wallet implementation which uses monero-project's wallet2.
*/
class monero_wallet_full : public monero_wallet {
public:
/**
* Indicates if a wallet exists at the given path.
*
* @param path is the path to check for a wallet
* @return true if a wallet exists at the given path, false otherwise
*/
static bool wallet_exists(const std::string& path);
/**
* Open an existing wallet from disk.
*
* @param path is the path to the wallet file to open
* @param password is the password of the wallet file to open
* @param network_type is the wallet's network type
* @param regtest enable regtest
* @return a pointer to the wallet instance
*/
static monero_wallet_full* open_wallet(const std::string& path, const std::string& password, const monero_network_type network_type, bool regtest = false);
/**
* Open an in-memory wallet from existing data buffers.
*
* @param password is the password of the wallet file to open
* @param network_type is the wallet's network type
* @param keys_data contains the contents of the ".keys" file
* @param cache_data contains the contents of the wallet cache file (no extension)
* @param daemon_connection is connection information to a daemon (default = an unconnected wallet)
* @param http_client_factory allows use of custom http clients
* @param regtest enable regtest
* @return a pointer to the wallet instance
*/
static monero_wallet_full* open_wallet_data(const std::string& password, const monero_network_type, const std::string& keys_data, const std::string& cache_data, const std::shared_ptr<monero_rpc_connection>& daemon_connection = std::make_shared<monero_rpc_connection>(), std::unique_ptr<epee::net_utils::http::http_client_factory> http_client_factory = nullptr, bool regtest = false);
/**
* Create a new wallet with the given configuration.
*
* @param config is the wallet configuration
* @param http_client_factory allows use of custom http clients
* @return a pointer to the wallet instance
*/
static monero_wallet_full* create_wallet(const monero_wallet_config& config, std::unique_ptr<epee::net_utils::http::http_client_factory> http_client_factory = nullptr);
/**
* Get a list of available languages for the wallet's seed.
*
* @return the available languages for the wallet's seed
*/
static std::vector<std::string> get_seed_languages();
// ----------------------------- WALLET METHODS -----------------------------
/**
* Destruct the wallet.
*/
~monero_wallet_full();
/**
* Supported wallet methods.
*/
bool is_view_only() const override { return m_w2->watch_only(); }
void set_daemon_connection(const std::string& uri, const std::string& username = "", const std::string& password = "", const std::string& proxy_uri = "", const boost::optional<bool>& is_trusted = boost::none) override;
void set_daemon_connection(const std::shared_ptr<monero_rpc_connection>& connection, const boost::optional<bool>& is_trusted = boost::none) override;
std::shared_ptr<monero_rpc_connection> get_daemon_connection() const override;
bool is_connected_to_daemon() const override;
bool is_daemon_synced() const override;
bool is_daemon_trusted() const override;
bool is_synced() const override;
monero_version get_version() const override;
std::string get_path() const override;
monero_network_type get_network_type() const override;
std::string get_seed() const override;
std::string get_seed_language() const override;
std::string get_public_view_key() const override;
std::string get_private_view_key() const override;
std::string get_public_spend_key() const override;
std::string get_private_spend_key() const override;
std::string get_address(const uint32_t account_idx, const uint32_t subaddress_idx) const override;
monero_subaddress get_address_index(const std::string& address) const override;
monero_integrated_address get_integrated_address(const std::string& standard_address = "", const std::string& payment_id = "") const override;
monero_integrated_address decode_integrated_address(const std::string& integrated_address) const override;
uint64_t get_height() const override;
uint64_t get_restore_height() const override;
void set_restore_height(uint64_t restore_height) override;
uint64_t get_daemon_height() const override;
uint64_t get_daemon_max_peer_height() const override;
uint64_t get_height_by_date(uint16_t year, uint8_t month, uint8_t day) const override;
void add_listener(monero_wallet_listener& listener) override;
void remove_listener(monero_wallet_listener& listener) override;
std::set<monero_wallet_listener*> get_listeners() override;
monero_sync_result sync() override;
monero_sync_result sync(monero_wallet_listener& listener) override;
monero_sync_result sync(uint64_t start_height) override;
monero_sync_result sync(uint64_t start_height, monero_wallet_listener& listener) override;
void start_syncing(uint64_t sync_period_in_ms) override;
void stop_syncing() override;
void scan_txs(const std::vector<std::string>& tx_hashes) override;
void rescan_spent() override;
void rescan_blockchain() override;
uint64_t get_balance() const override;
uint64_t get_balance(uint32_t account_idx) const override;
uint64_t get_balance(uint32_t account_idx, uint32_t subaddress_idx) const override;
uint64_t get_unlocked_balance() const override;
uint64_t get_unlocked_balance(uint32_t account_idx) const override;
uint64_t get_unlocked_balance(uint32_t account_idx, uint32_t subaddress_idx) const override;
std::vector<monero_account> get_accounts(bool include_subaddresses, const std::string& tag) const override;
monero_account get_account(const uint32_t account_idx, bool include_subaddresses) const override;
monero_account create_account(const std::string& label = "") override;
std::vector<monero_subaddress> get_subaddresses(const uint32_t account_idx, const std::vector<uint32_t>& subaddress_indices) const override;
monero_subaddress create_subaddress(uint32_t account_idx, const std::string& label = "") override;
void set_subaddress_label(uint32_t account_idx, uint32_t subaddress_idx, const std::string& label = "") override;
std::vector<std::shared_ptr<monero_tx_wallet>> get_txs() const override;
std::vector<std::shared_ptr<monero_tx_wallet>> get_txs(const monero_tx_query& query) const override;
std::vector<std::shared_ptr<monero_transfer>> get_transfers(const monero_transfer_query& query) const override;
std::vector<std::shared_ptr<monero_output_wallet>> get_outputs(const monero_output_query& query) const override;
std::string export_outputs(bool all = false) const override;
int import_outputs(const std::string& outputs_hex) override;
std::shared_ptr<monero_key_image_export_result> export_key_images(bool all = false) const override;
std::shared_ptr<monero_key_image_import_result> import_key_images(const std::vector<std::shared_ptr<monero_key_image>>& key_images, uint64_t offset = 0) override;
void freeze_output(const std::string& key_image) override;
void thaw_output(const std::string& key_image) override;
bool is_output_frozen(const std::string& key_image) override;
monero_tx_priority get_default_fee_priority() const override;
std::vector<std::shared_ptr<monero_tx_wallet>> create_txs(const monero_tx_config& config) override;
std::vector<std::shared_ptr<monero_tx_wallet>> sweep_unlocked(const monero_tx_config& config) override;
std::shared_ptr<monero_tx_wallet> sweep_output(const monero_tx_config& config) override;
std::vector<std::shared_ptr<monero_tx_wallet>> sweep_dust(bool relay = false) override;
std::vector<std::string> relay_txs(const std::vector<std::string>& tx_metadatas) override;
monero_tx_set describe_tx_set(const monero_tx_set& tx_set) override;
monero_tx_set sign_txs(const std::string& unsigned_tx_hex) override;
std::vector<std::string> submit_txs(const std::string& signed_tx_hex) override;
std::string sign_message(const std::string& msg, monero_message_signature_type signature_type, uint32_t account_idx = 0, uint32_t subaddress_idx = 0) const override;
monero_message_signature_result verify_message(const std::string& msg, const std::string& address, const std::string& signature) const override;
std::string get_tx_key(const std::string& tx_hash) const override;
std::shared_ptr<monero_check_tx> check_tx_key(const std::string& tx_hash, const std::string& txKey, const std::string& address) const override;
std::string get_tx_proof(const std::string& tx_hash, const std::string& address, const std::string& message) const override;
std::shared_ptr<monero_check_tx> check_tx_proof(const std::string& tx_hash, const std::string& address, const std::string& message, const std::string& signature) const override;
std::string get_spend_proof(const std::string& tx_hash, const std::string& message) const override;
bool check_spend_proof(const std::string& tx_hash, const std::string& message, const std::string& signature) const override;
std::string get_reserve_proof_wallet(const std::string& message) const override;
std::string get_reserve_proof_account(uint32_t account_idx, uint64_t amount, const std::string& message) const override;
std::shared_ptr<monero_check_reserve> check_reserve_proof(const std::string& address, const std::string& message, const std::string& signature) const override;
std::string get_tx_note(const std::string& tx_hash) const override;
std::vector<std::string> get_tx_notes(const std::vector<std::string>& tx_hashes) const override;
void set_tx_note(const std::string& tx_hash, const std::string& note) override;
void set_tx_notes(const std::vector<std::string>& tx_hashes, const std::vector<std::string>& notes) override;
std::vector<monero_address_book_entry> get_address_book_entries(const std::vector<uint64_t>& indices) const override;
uint64_t add_address_book_entry(const std::string& address, const std::string& description) override;
void edit_address_book_entry(uint64_t index, bool set_address, const std::string& address, bool set_description, const std::string& description) override;
void delete_address_book_entry(uint64_t index) override;
std::string get_payment_uri(const monero_tx_config& config) const override;
std::shared_ptr<monero_tx_config> parse_payment_uri(const std::string& uri) const override;
bool get_attribute(const std::string& key, std::string& value) const override;
void set_attribute(const std::string& key, const std::string& val) override;
void start_mining(boost::optional<uint64_t> num_threads, boost::optional<bool> background_mining, boost::optional<bool> ignore_battery) override;
void stop_mining() override;
uint64_t wait_for_next_block() override;
bool is_multisig_import_needed() const override;
monero_multisig_info get_multisig_info() const override;
std::string prepare_multisig() override;
std::string make_multisig(const std::vector<std::string>& multisig_hexes, int threshold, const std::string& password) override;
monero_multisig_init_result exchange_multisig_keys(const std::vector<std::string>& multisig_hexes, const std::string& password) override;
std::string export_multisig_hex() override;
int import_multisig_hex(const std::vector<std::string>& multisig_hexes, const bool refresh_after_import = true) override;
monero_multisig_sign_result sign_multisig_tx_hex(const std::string& multisig_tx_hex) override;
std::vector<std::string> submit_multisig_tx_hex(const std::string& signed_multisig_tx_hex) override;
void change_password(const std::string& old_password, const std::string& new_password) override;
void move_to(const std::string& path, const std::string& password) override;
void save() override;
void close(bool save = false) override;
bool is_closed() const override { return m_is_closed; }
/**
* Wallet import and export using buffers and not the file system.
*/
std::string get_keys_file_buffer(const epee::wipeable_string& password, bool view_only) const;
std::string get_cache_file_buffer() const;
// --------------------------------- PROTECTED --------------------------------
protected:
std::unique_ptr<tools::wallet2> m_w2; // internal wallet implementation
void init_common();
// ---------------------------------- PRIVATE ---------------------------------
private:
friend struct wallet2_listener;
std::unique_ptr<wallet2_listener> m_w2_listener; // internal wallet implementation listener
std::set<monero_wallet_listener*> m_listeners; // external wallet listeners
std::atomic<bool> m_is_closed;
static monero_wallet_full* create_wallet_from_seed(monero_wallet_config& config, std::unique_ptr<epee::net_utils::http::http_client_factory> http_client_factory);
static monero_wallet_full* create_wallet_from_keys(monero_wallet_config& config, std::unique_ptr<epee::net_utils::http::http_client_factory> http_client_factory);
static monero_wallet_full* create_wallet_random(monero_wallet_config& config, std::unique_ptr<epee::net_utils::http::http_client_factory> http_client_factory);
std::vector<monero_subaddress> get_subaddresses_aux(uint32_t account_idx, const std::vector<uint32_t>& subaddress_indices, const std::vector<tools::wallet2::transfer_details>& transfers) const;
std::vector<std::shared_ptr<monero_transfer>> get_transfers_aux(const monero_transfer_query& query) const;
std::vector<std::shared_ptr<monero_output_wallet>> get_outputs_aux(const monero_output_query& query) const;
std::vector<std::shared_ptr<monero_tx_wallet>> sweep_account(const monero_tx_config& config); // sweeps unlocked funds within an account; private helper to sweep_unlocked()
void assert_not_closed() const;
// serializes a wallet operation with background sync: pauses the sync loop, interrupts background refresh, and locks m_sync_mutex
struct sync_op_lock {
sync_op_lock(const monero_wallet_full& wallet);
sync_op_lock(const sync_op_lock&) = delete;
~sync_op_lock();
const monero_wallet_full& m_wallet;
};
// blockchain sync management
mutable std::atomic<bool> m_is_synced; // whether or not wallet is synced
mutable std::atomic<bool> m_is_connected; // cache connection status to avoid unecessary RPC calls
boost::condition_variable m_sync_cv; // to make sync threads woke
mutable boost::mutex m_sync_mutex; // synchronize sync and other wallet operations
mutable std::atomic<uint32_t> m_num_sync_pauses; // number of operations pausing background sync
std::atomic<bool> m_background_syncing; // whether or not a background sync pass is in progress
std::atomic<bool> m_rescan_on_sync; // whether or not to rescan on sync
std::atomic<bool> m_syncing_enabled; // whether or not auto sync is enabled
std::atomic<bool> m_sync_loop_running; // whether or not the syncing thread is shut down
std::atomic<int> m_syncing_interval; // auto sync loop interval in milliseconds
boost::thread m_syncing_thread; // thread for auto sync loop
boost::mutex m_syncing_mutex; // synchronize auto sync loop
void run_sync_loop(); // run the sync loop in a thread
monero_sync_result lock_and_sync(boost::optional<uint64_t> start_height = boost::none, bool background = false); // internal function to synchronize request to sync and rescan
monero_sync_result sync_aux(boost::optional<uint64_t> start_height = boost::none); // internal function to immediately block, sync, and report progress
};
}
@@ -0,0 +1,320 @@
/**
* Copyright (c) woodser
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#include "monero_wallet_keys.h"
#include "utils/monero_utils.h"
#include <chrono>
#include <iostream>
#include "mnemonics/electrum-words.h"
#include "mnemonics/english.h"
#include "cryptonote_basic/cryptonote_format_utils.h"
#include "cryptonote_basic/cryptonote_basic_impl.h"
#include "string_tools.h"
#include "device/device.hpp"
using namespace epee;
using namespace tools;
using namespace crypto;
/**
* Public library interface.
*/
namespace monero {
// ---------------------------- WALLET MANAGEMENT ---------------------------
monero_wallet_keys* monero_wallet_keys::create_wallet_random(const monero_wallet_config& config) {
// validate and normalize config
monero_wallet_config config_normalized = config.copy();
if (config_normalized.m_network_type == boost::none) throw std::runtime_error("Must provide wallet network type");
if (config_normalized.m_language == boost::none || config_normalized.m_language.get().empty()) config_normalized.m_language = "English";
if (!monero_utils::is_valid_language(config_normalized.m_language.get())) throw std::runtime_error("Unknown language: " + config_normalized.m_language.get());
// initialize random wallet account
monero_wallet_keys* wallet = new monero_wallet_keys();
crypto::secret_key spend_key_sk = wallet->m_account.generate();
// initialize remaining wallet
wallet->m_network_type = config_normalized.m_network_type.get();
wallet->m_language = config_normalized.m_language.get();
epee::wipeable_string wipeable_mnemonic;
if (!crypto::ElectrumWords::bytes_to_words(spend_key_sk, wipeable_mnemonic, wallet->m_language)) {
throw std::runtime_error("Failed to create mnemonic from private spend key for language: " + std::string(wallet->m_language));
}
wallet->m_seed = std::string(wipeable_mnemonic.data(), wipeable_mnemonic.size());
wallet->init_common();
return wallet;
}
monero_wallet_keys* monero_wallet_keys::create_wallet_from_seed(const monero_wallet_config& config) {
// validate config
if (config.m_is_multisig != boost::none && config.m_is_multisig.get()) throw std::runtime_error("Restoring from multisig seed not supported");
if (config.m_network_type == boost::none) throw std::runtime_error("Must provide wallet network type");
if (config.m_seed == boost::none || config.m_seed.get().empty()) throw std::runtime_error("Must provide wallet seed");
// validate mnemonic and get recovery key and language
crypto::secret_key spend_key_sk;
std::string language;
bool is_valid = crypto::ElectrumWords::words_to_bytes(config.m_seed.get(), spend_key_sk, language);
if (!is_valid) throw std::runtime_error("Invalid mnemonic");
if (language == crypto::ElectrumWords::old_language_name) language = Language::English().get_language_name();
// apply offset if given
if (config.m_seed_offset != boost::none && !config.m_seed_offset.get().empty()) spend_key_sk = cryptonote::decrypt_key(spend_key_sk, config.m_seed_offset.get());
// initialize wallet account
monero_wallet_keys* wallet = new monero_wallet_keys();
wallet->m_account = cryptonote::account_base{};
wallet->m_account.generate(spend_key_sk, true, false);
// initialize remaining wallet
wallet->m_network_type = config.m_network_type.get();
wallet->m_language = language;
epee::wipeable_string wipeable_mnemonic;
if (!crypto::ElectrumWords::bytes_to_words(spend_key_sk, wipeable_mnemonic, wallet->m_language)) {
throw std::runtime_error("Failed to create mnemonic from private spend key for language: " + std::string(wallet->m_language));
}
wallet->m_seed = std::string(wipeable_mnemonic.data(), wipeable_mnemonic.size());
wallet->init_common();
return wallet;
}
monero_wallet_keys* monero_wallet_keys::create_wallet_from_keys(const monero_wallet_config& config) {
// validate and normalize config
monero_wallet_config config_normalized = config.copy();
if (config.m_network_type == boost::none) throw std::runtime_error("Must provide wallet network type");
if (config.m_language == boost::none || config_normalized.m_language.get().empty()) config_normalized.m_language = "English";
if (config.m_private_spend_key == boost::none) config_normalized.m_private_spend_key = std::string("");
if (config.m_private_view_key == boost::none) config_normalized.m_private_view_key = std::string("");
if (!monero_utils::is_valid_language(config_normalized.m_language.get())) throw std::runtime_error("Unknown language: " + config_normalized.m_language.get());
// parse and validate private spend key
crypto::secret_key spend_key_sk;
bool has_spend_key = false;
if (!config_normalized.m_private_spend_key.get().empty()) {
cryptonote::blobdata spend_key_data;
if (!epee::string_tools::parse_hexstr_to_binbuff(config.m_private_spend_key.get(), spend_key_data) || spend_key_data.size() != sizeof(crypto::secret_key)) {
throw std::runtime_error("failed to parse secret spend key");
}
has_spend_key = true;
spend_key_sk = *reinterpret_cast<const crypto::secret_key*>(spend_key_data.data());
}
// parse and validate private view key
bool has_view_key = true;
crypto::secret_key view_key_sk;
if (config_normalized.m_private_view_key.get().empty()) {
if (has_spend_key) has_view_key = false;
else throw std::runtime_error("Neither spend key nor view key supplied");
}
if (has_view_key) {
cryptonote::blobdata view_key_data;
if (!epee::string_tools::parse_hexstr_to_binbuff(config_normalized.m_private_view_key.get(), view_key_data) || view_key_data.size() != sizeof(crypto::secret_key)) {
throw std::runtime_error("failed to parse secret view key");
}
view_key_sk = *reinterpret_cast<const crypto::secret_key*>(view_key_data.data());
}
// parse and validate address
cryptonote::address_parse_info address_info;
if (config_normalized.m_primary_address.get().empty()) {
if (has_view_key) throw std::runtime_error("must provide address if providing private view key");
} else {
if (!get_account_address_from_str(address_info, static_cast<cryptonote::network_type>(config_normalized.m_network_type.get()), config_normalized.m_primary_address.get())) throw std::runtime_error("failed to parse address");
// check the spend and view keys match the given address
crypto::public_key pkey;
if (has_spend_key) {
if (!crypto::secret_key_to_public_key(spend_key_sk, pkey)) throw std::runtime_error("failed to verify secret spend key");
if (address_info.address.m_spend_public_key != pkey) throw std::runtime_error("spend key does not match address");
}
if (has_view_key) {
if (!crypto::secret_key_to_public_key(view_key_sk, pkey)) throw std::runtime_error("failed to verify secret view key");
if (address_info.address.m_view_public_key != pkey) throw std::runtime_error("view key does not match address");
}
}
// initialize wallet account
monero_wallet_keys* wallet = new monero_wallet_keys();
if (has_spend_key && has_view_key) {
wallet->m_account.create_from_keys(address_info.address, spend_key_sk, view_key_sk);
} else if (has_spend_key) {
wallet->m_account.generate(spend_key_sk, true, false);
} else {
wallet->m_account.create_from_viewkey(address_info.address, view_key_sk);
}
// initialize remaining wallet
wallet->m_is_view_only = !has_spend_key;
wallet->m_network_type = config_normalized.m_network_type.get();
if (!config_normalized.m_private_spend_key.get().empty()) {
wallet->m_language = config_normalized.m_language.get();
epee::wipeable_string wipeable_mnemonic;
if (!crypto::ElectrumWords::bytes_to_words(spend_key_sk, wipeable_mnemonic, wallet->m_language)) {
throw std::runtime_error("Failed to create mnemonic from private spend key for language: " + std::string(wallet->m_language));
}
wallet->m_seed = std::string(wipeable_mnemonic.data(), wipeable_mnemonic.size());
}
wallet->init_common();
return wallet;
}
std::vector<std::string> monero_wallet_keys::get_seed_languages() {
std::vector<std::string> languages;
crypto::ElectrumWords::get_language_list(languages, true); // TODO: support getting names in language
return languages;
}
// ----------------------------- WALLET METHODS -----------------------------
monero_wallet_keys::~monero_wallet_keys() {
MTRACE("~monero_wallet_keys()");
close();
}
monero_version monero_wallet_keys::get_version() const {
monero_version version;
version.m_number = 65552; // same as monero-wallet-rpc v0.15.0.1 release
version.m_is_release = false; // TODO: could pull from MONERO_VERSION_IS_RELEASE in version.cpp
return version;
}
std::string monero_wallet_keys::get_address(uint32_t account_idx, uint32_t subaddress_idx) const {
hw::device &hwdev = m_account.get_device();
cryptonote::subaddress_index index{account_idx, subaddress_idx};
cryptonote::account_public_address address = hwdev.get_subaddress(m_account.get_keys(), index);
return cryptonote::get_account_address_as_str(static_cast<cryptonote::network_type>(m_network_type), !index.is_zero(), address);
}
monero_integrated_address monero_wallet_keys::get_integrated_address(const std::string& standard_address, const std::string& payment_id) const {
MTRACE("monero_wallet_keys::get_integrated_address()");
throw std::runtime_error("monero_wallet_keys::get_integrated_address() not implemented");
}
monero_integrated_address monero_wallet_keys::decode_integrated_address(const std::string& integrated_address) const {
MTRACE("monero_wallet_keys::decode_integrated_address()");
throw std::runtime_error("monero_wallet_keys::decode_integrated_address() not implemented");
}
monero_account monero_wallet_keys::get_account(uint32_t account_idx, bool include_subaddresses) const {
MTRACE("monero_wallet_keys::get_account()");
if (include_subaddresses) {
std::string err = "monero_wallet_keys::get_account(account_idx, include_subaddresses) include_subaddresses must be false";
MERROR(err);
throw std::runtime_error(err);
}
// build and return account
monero_account account;
account.m_index = account_idx;
account.m_primary_address = get_address(account_idx, 0);
return account;
}
std::vector<monero_subaddress> monero_wallet_keys::get_subaddresses(const uint32_t account_idx, const std::vector<uint32_t>& subaddress_indices) const {
// must provide subaddress indices
if (subaddress_indices.empty()) {
std::string err = "Keys-only wallet does not have enumerable set of subaddresses; specific specific subaddresses";
MERROR(err);
throw std::runtime_error(err);
}
// initialize subaddresses at indices
std::vector<monero_subaddress> subaddresses;
for (uint32_t subaddressIndicesIdx = 0; subaddressIndicesIdx < subaddress_indices.size(); subaddressIndicesIdx++) {
monero_subaddress subaddress;
subaddress.m_account_index = account_idx;
uint32_t subaddress_idx = subaddress_indices.at(subaddressIndicesIdx);
subaddress.m_index = subaddress_idx;
subaddress.m_address = get_address(account_idx, subaddress_idx);
subaddresses.push_back(subaddress);
}
return subaddresses;
}
std::string monero_wallet_keys::sign_message(const std::string& msg, monero_message_signature_type signature_type, uint32_t account_idx, uint32_t subaddress_idx) const {
MTRACE("monero_wallet_keys::sign_message()");
throw std::runtime_error("monero_wallet_keys::sign_message() not implemented");
}
monero_message_signature_result monero_wallet_keys::verify_message(const std::string& msg, const std::string& address, const std::string& signature) const {
MTRACE("monero_wallet_keys::verify_message()");
throw std::runtime_error("monero_wallet_keys::verify_message() not implemented");
}
void monero_wallet_keys::close(bool save) {
if (save) throw std::runtime_error("MoneroWalletKeys does not support saving");
// no pointers to destroy
}
// ------------------------------- PRIVATE HELPERS ----------------------------
void monero_wallet_keys::init_common() {
m_primary_address = m_account.get_public_address_str(static_cast<cryptonote::network_type>(m_network_type));
const cryptonote::account_keys& keys = m_account.get_keys();
m_pub_view_key = epee::string_tools::pod_to_hex(keys.m_account_address.m_view_public_key);
m_prv_view_key = epee::string_tools::pod_to_hex(unwrap(unwrap(keys.m_view_secret_key)));
m_pub_spend_key = epee::string_tools::pod_to_hex(keys.m_account_address.m_spend_public_key);
m_prv_spend_key = epee::string_tools::pod_to_hex(unwrap(unwrap(keys.m_spend_secret_key)));
if (m_prv_spend_key == "0000000000000000000000000000000000000000000000000000000000000000") m_prv_spend_key = "";
}
}
@@ -0,0 +1,147 @@
/**
* Copyright (c) woodser
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#pragma once
#include "monero_wallet.h"
#include "cryptonote_basic/account.h"
using namespace monero;
/**
* Public library interface.
*/
namespace monero {
/**
* Implements a Monero wallet to provide basic key management.
*/
class monero_wallet_keys : public monero_wallet {
public:
// --------------------------- STATIC WALLET UTILS --------------------------
/**
* Create a new wallet with a randomly generated seed.
*
* @param config is the wallet configuration (network type and language)
*/
static monero_wallet_keys* create_wallet_random(const monero_wallet_config& config);
/**
* Create a wallet from an existing mnemonic phrase or seed.
*
* @param config is the wallet configuration (network type, seed, seed offset, isMultisig)
*/
static monero_wallet_keys* create_wallet_from_seed(const monero_wallet_config& config);
/**
* Create a wallet from an address, view key, and spend key.
*
* @param config is the wallet configuration (network type, address, view key, spend key, language)
*/
static monero_wallet_keys* create_wallet_from_keys(const monero_wallet_config& config);
/**
* Get a list of available languages for the wallet's seed.
*
* @return the available languages for the wallet's seed
*/
static std::vector<std::string> get_seed_languages();
// ----------------------------- WALLET METHODS -----------------------------
/**
* Destruct the wallet.
*/
~monero_wallet_keys();
/**
* Supported wallet methods.
*/
bool is_view_only() const override { return m_is_view_only; }
monero_version get_version() const override;
monero_network_type get_network_type() const override { return m_network_type; }
std::string get_seed() const override { return m_seed; }
std::string get_seed_language() const override { return m_language; }
std::string get_private_view_key() const override { return m_prv_view_key; }
std::string get_private_spend_key() const override { return m_prv_spend_key; }
std::string get_public_view_key() const override { return m_pub_view_key; }
std::string get_public_spend_key() const override { return m_pub_spend_key; }
std::string get_primary_address() const override { return m_primary_address; }
std::string get_address(const uint32_t account_idx, const uint32_t subaddress_idx) const override;
monero_integrated_address get_integrated_address(const std::string& standard_address = "", const std::string& payment_id = "") const override;
monero_integrated_address decode_integrated_address(const std::string& integrated_address) const override;
monero_account get_account(const uint32_t account_idx, bool include_subaddresses) const override;
std::vector<monero_subaddress> get_subaddresses(const uint32_t account_idx, const std::vector<uint32_t>& subaddress_indices) const override;
std::string sign_message(const std::string& msg, monero_message_signature_type signature_type, uint32_t account_idx = 0, uint32_t subaddress_idx = 0) const override;
monero_message_signature_result verify_message(const std::string& msg, const std::string& address, const std::string& signature) const override;
void close(bool save = false) override;
// --------------------------------- PRIVATE --------------------------------
private:
bool m_is_view_only;
monero_network_type m_network_type;
cryptonote::account_base m_account;
std::string m_seed;
std::string m_language;
std::string m_pub_view_key;
std::string m_prv_view_key;
std::string m_pub_spend_key;
std::string m_prv_spend_key;
std::string m_primary_address;
void init_common();
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,609 @@
/**
* Copyright (c) woodser
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#pragma once
#include "common/monero_rpc_connection.h"
#include "daemon/monero_daemon_model.h"
using namespace monero;
/**
* Public library interface.
*/
namespace monero {
/**
* Configures a wallet to create.
*/
struct monero_wallet_config : public serializable_struct {
boost::optional<std::string> m_path;
boost::optional<std::string> m_password;
boost::optional<monero_network_type> m_network_type;
std::shared_ptr<monero_rpc_connection> m_server;
boost::optional<bool> m_is_trusted_daemon;
boost::optional<std::string> m_seed;
boost::optional<std::string> m_seed_offset;
boost::optional<std::string> m_primary_address;
boost::optional<std::string> m_private_view_key;
boost::optional<std::string> m_private_spend_key;
boost::optional<uint64_t> m_restore_height;
boost::optional<std::string> m_language;
boost::optional<bool> m_save_current;
boost::optional<uint64_t> m_account_lookahead;
boost::optional<uint64_t> m_subaddress_lookahead;
boost::optional<bool> m_is_multisig;
boost::optional<bool> m_regtest;
monero_wallet_config() {}
monero_wallet_config(const monero_wallet_config& config);
monero_wallet_config copy() const;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static std::shared_ptr<monero_wallet_config> deserialize(const std::string& config_json);
};
/**
* Models a result of syncing a wallet.
*/
struct monero_sync_result : public serializable_struct {
uint64_t m_num_blocks_fetched;
bool m_received_money;
monero_sync_result() {}
monero_sync_result(const uint64_t num_blocks_fetched, const bool received_money) : m_num_blocks_fetched(num_blocks_fetched), m_received_money(received_money) {}
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
};
/**
* Models a Monero subaddress.
*/
struct monero_subaddress : public serializable_struct {
boost::optional<uint32_t> m_account_index;
boost::optional<uint32_t> m_index;
boost::optional<std::string> m_address;
boost::optional<std::string> m_label;
boost::optional<uint64_t> m_balance;
boost::optional<uint64_t> m_unlocked_balance;
boost::optional<uint64_t> m_num_unspent_outputs;
boost::optional<bool> m_is_used;
boost::optional<uint64_t> m_num_blocks_to_unlock;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_subaddress>& subaddress);
};
/**
* Models a Monero account.
*/
struct monero_account : public serializable_struct {
boost::optional<uint32_t> m_index;
boost::optional<std::string> m_primary_address;
boost::optional<uint64_t> m_balance;
boost::optional<uint64_t> m_unlocked_balance;
boost::optional<std::string> m_tag;
std::vector<monero_subaddress> m_subaddresses;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_account>& account);
};
/**
* Models an outgoing transfer destination.
*/
struct monero_destination : public serializable_struct {
boost::optional<std::string> m_address;
boost::optional<uint64_t> m_amount;
monero_destination(boost::optional<std::string> address = boost::none, boost::optional<uint64_t> amount = boost::none) : m_address(address), m_amount(amount) {}
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_destination>& destination);
std::shared_ptr<monero_destination> copy(const std::shared_ptr<monero_destination>& src, const std::shared_ptr<monero_destination>& tgt) const;
};
// forward declarations
struct monero_tx_config;
struct monero_tx_wallet;
struct monero_tx_query;
struct monero_tx_set;
/**
* Models a base transfer of funds to or from the wallet.
*
* TODO: m_is_incoming for api consistency
*/
struct monero_transfer : public serializable_struct {
std::shared_ptr<monero_tx_wallet> m_tx;
boost::optional<uint64_t> m_amount;
boost::optional<uint32_t> m_account_index;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_transfer>& transfer);
virtual boost::optional<bool> is_incoming() const = 0; // derived class must implement
std::shared_ptr<monero_transfer> copy(const std::shared_ptr<monero_transfer>& src, const std::shared_ptr<monero_transfer>& tgt) const;
boost::optional<bool> is_outgoing() const {
if (is_incoming() == boost::none) return boost::none;
return !(*is_incoming());
}
void merge(const std::shared_ptr<monero_transfer>& self, const std::shared_ptr<monero_transfer>& other);
};
/**
* Models an incoming transfer of funds to the wallet.
*/
struct monero_incoming_transfer : public monero_transfer {
boost::optional<uint32_t> m_subaddress_index;
boost::optional<std::string> m_address;
boost::optional<uint64_t> m_num_suggested_confirmations;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
std::shared_ptr<monero_incoming_transfer> copy(const std::shared_ptr<monero_transfer>& src, const std::shared_ptr<monero_transfer>& tgt) const;
std::shared_ptr<monero_incoming_transfer> copy(const std::shared_ptr<monero_incoming_transfer>& src, const std::shared_ptr<monero_incoming_transfer>& tgt) const;
boost::optional<bool> is_incoming() const;
void merge(const std::shared_ptr<monero_transfer>& self, const std::shared_ptr<monero_transfer>& other);
void merge(const std::shared_ptr<monero_incoming_transfer>& self, const std::shared_ptr<monero_incoming_transfer>& other);
};
/**
* Models an outgoing transfer of funds from the wallet.
*/
struct monero_outgoing_transfer : public monero_transfer {
std::vector<uint32_t> m_subaddress_indices;
std::vector<std::string> m_addresses;
std::vector<std::shared_ptr<monero_destination>> m_destinations;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
std::shared_ptr<monero_outgoing_transfer> copy(const std::shared_ptr<monero_transfer>& src, const std::shared_ptr<monero_transfer>& tgt) const;
std::shared_ptr<monero_outgoing_transfer> copy(const std::shared_ptr<monero_outgoing_transfer>& src, const std::shared_ptr<monero_outgoing_transfer>& tgt) const;
boost::optional<bool> is_incoming() const;
void merge(const std::shared_ptr<monero_transfer>& self, const std::shared_ptr<monero_transfer>& other);
void merge(const std::shared_ptr<monero_outgoing_transfer>& self, const std::shared_ptr<monero_outgoing_transfer>& other);
};
/**
* Configures a query to retrieve transfers.
*
* All transfers are returned except those that do not meet the criteria defined in this query.
*/
struct monero_transfer_query : public monero_transfer {
boost::optional<bool> m_is_incoming;
boost::optional<std::string> m_address;
std::vector<std::string> m_addresses;
boost::optional<uint32_t> m_subaddress_index;
std::vector<uint32_t> m_subaddress_indices;
std::vector<std::shared_ptr<monero_destination>> m_destinations;
boost::optional<bool> m_has_destinations;
std::shared_ptr<monero_tx_query> m_tx_query;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_transfer_query>& transfer_query);
static std::shared_ptr<monero_transfer_query> deserialize_from_block(const std::string& transfer_query_json);
static bool is_contextual(const monero_transfer_query& query);
std::shared_ptr<monero_transfer_query> copy(const std::shared_ptr<monero_transfer>& src, const std::shared_ptr<monero_transfer>& tgt) const;
std::shared_ptr<monero_transfer_query> copy(const std::shared_ptr<monero_transfer_query>& src, const std::shared_ptr<monero_transfer_query>& tgt) const;
boost::optional<bool> is_incoming() const;
bool meets_criteria(monero_transfer* transfer, bool query_parent = true) const;
};
/**
* Models a Monero output with wallet extensions.
*/
struct monero_output_wallet : public monero_output {
boost::optional<uint32_t> m_account_index;
boost::optional<uint32_t> m_subaddress_index;
boost::optional<bool> m_is_spent;
boost::optional<bool> m_is_frozen;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_output_wallet>& output_wallet);
std::shared_ptr<monero_output_wallet> copy(const std::shared_ptr<monero_output>& src, const std::shared_ptr<monero_output>& tgt) const;
std::shared_ptr<monero_output_wallet> copy(const std::shared_ptr<monero_output_wallet>& src, const std::shared_ptr<monero_output_wallet>& tgt) const;
void merge(const std::shared_ptr<monero_output>& self, const std::shared_ptr<monero_output>& other);
void merge(const std::shared_ptr<monero_output_wallet>& self, const std::shared_ptr<monero_output_wallet>& other);
};
/**
* Configures a query to retrieve wallet outputs (i.e. outputs that the wallet has or had the
* ability to spend).
*
* All outputs are returned except those that do not meet the criteria defined in this query.
*/
struct monero_output_query : public monero_output_wallet {
std::vector<uint32_t> m_subaddress_indices;
boost::optional<uint64_t> m_min_amount;
boost::optional<uint64_t> m_max_amount;
std::shared_ptr<monero_tx_query> m_tx_query;
//boost::property_tree::ptree to_property_tree() const;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_output_query>& output_query);
static std::shared_ptr<monero_output_query> deserialize_from_block(const std::string& output_query_json);
static bool is_contextual(const monero_output_query& query);
std::shared_ptr<monero_output_query> copy(const std::shared_ptr<monero_output>& src, const std::shared_ptr<monero_output>& tgt) const;
std::shared_ptr<monero_output_query> copy(const std::shared_ptr<monero_output_wallet>& src, const std::shared_ptr<monero_output_wallet>& tgt) const; // TODO: necessary to override all super classes?
std::shared_ptr<monero_output_query> copy(const std::shared_ptr<monero_output_query>& src, const std::shared_ptr<monero_output_query>& tgt) const;
bool meets_criteria(monero_output_wallet* output, bool query_parent = true) const;
};
/**
* Models a Monero transaction in the context of a wallet.
*/
struct monero_tx_wallet : public monero_tx {
std::shared_ptr<monero_tx_set> m_tx_set;
boost::optional<bool> m_is_incoming;
boost::optional<bool> m_is_outgoing;
std::vector<std::shared_ptr<monero_incoming_transfer>> m_incoming_transfers;
std::shared_ptr<monero_outgoing_transfer> m_outgoing_transfer;
boost::optional<std::string> m_note;
boost::optional<bool> m_is_locked;
boost::optional<uint64_t> m_input_sum;
boost::optional<uint64_t> m_output_sum;
boost::optional<std::string> m_change_address;
boost::optional<uint64_t> m_change_amount;
boost::optional<uint32_t> m_num_dummy_outputs;
boost::optional<std::string> m_extra_hex;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_tx_wallet>& tx_wallet);
std::shared_ptr<monero_tx_wallet> copy(const std::shared_ptr<monero_tx>& src, const std::shared_ptr<monero_tx>& tgt) const;
std::shared_ptr<monero_tx_wallet> copy(const std::shared_ptr<monero_tx_wallet>& src, const std::shared_ptr<monero_tx_wallet>& tgt) const;
void merge(const std::shared_ptr<monero_tx>& self, const std::shared_ptr<monero_tx>& other);
void merge(const std::shared_ptr<monero_tx_wallet>& self, const std::shared_ptr<monero_tx_wallet>& other);
std::vector<std::shared_ptr<monero_transfer>> get_transfers() const;
std::vector<std::shared_ptr<monero_transfer>> get_transfers(const monero_transfer_query& query) const;
std::vector<std::shared_ptr<monero_transfer>> filter_transfers(const monero_transfer_query& query);
std::vector<std::shared_ptr<monero_output_wallet>> get_outputs_wallet() const;
std::vector<std::shared_ptr<monero_output_wallet>> get_outputs_wallet(const monero_output_query& query) const;
std::vector<std::shared_ptr<monero_output_wallet>> filter_outputs_wallet(const monero_output_query& query);
};
/**
* Configures a query to retrieve transactions.
*
* All transactions are returned except those that do not meet the criteria defined in this query.
*/
struct monero_tx_query : public monero_tx_wallet {
boost::optional<bool> m_is_outgoing;
boost::optional<bool> m_is_incoming;
std::vector<std::string> m_hashes;
boost::optional<bool> m_has_payment_id;
std::vector<std::string> m_payment_ids;
boost::optional<uint64_t> m_height;
boost::optional<uint64_t> m_min_height;
boost::optional<uint64_t> m_max_height;
boost::optional<uint64_t> m_include_outputs;
std::shared_ptr<monero_transfer_query> m_transfer_query;
std::shared_ptr<monero_output_query> m_input_query;
std::shared_ptr<monero_output_query> m_output_query;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_tx_query>& tx_query);
static std::shared_ptr<monero_tx_query> deserialize_from_block(const std::string& tx_query_json);
/**
* Remove query criteria which require looking up other transfers/outputs to
* fulfill query.
*
* @param query the query to decontextualize
* @return a reference to the query for convenience
*/
static std::shared_ptr<monero_tx_query> decontextualize(std::shared_ptr<monero_tx_query> query);
std::shared_ptr<monero_tx_query> copy(const std::shared_ptr<monero_tx>& src, const std::shared_ptr<monero_tx>& tgt) const;
std::shared_ptr<monero_tx_query> copy(const std::shared_ptr<monero_tx_wallet>& src, const std::shared_ptr<monero_tx_wallet>& tgt) const; // TODO: necessary to override all super classes?
std::shared_ptr<monero_tx_query> copy(const std::shared_ptr<monero_tx_query>& src, const std::shared_ptr<monero_tx_query>& tgt) const;
bool meets_criteria(monero_tx_wallet* tx, bool query_children = true) const;
};
/**
* Groups transactions who share common hex data which is needed in order to
* sign and submit the transactions.
*
* For example, multisig transactions created from create_txs() share a common
* hex string which is needed in order to sign and submit the multisig
* transactions.
*/
struct monero_tx_set : public serializable_struct {
std::vector<std::shared_ptr<monero_tx_wallet>> m_txs;
boost::optional<std::string> m_signed_tx_hex;
boost::optional<std::string> m_unsigned_tx_hex;
boost::optional<std::string> m_multisig_tx_hex;
//boost::property_tree::ptree to_property_tree() const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_tx_set>& set);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static monero_tx_set deserialize(const std::string& tx_set_json);
};
/**
* Monero integrated address model.
*/
struct monero_integrated_address : public serializable_struct {
std::string m_standard_address;
std::string m_payment_id;
std::string m_integrated_address;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_integrated_address>& subaddress);
};
/**
* Enumerates Monero network types.
*/
enum monero_tx_priority : uint8_t {
DEFAULT = 0,
UNIMPORTANT,
NORMAL,
ELEVATED
};
/**
* Configures a transaction to send, sweep, or create a payment URI.
*/
struct monero_tx_config : public serializable_struct {
boost::optional<std::string> m_address;
boost::optional<uint64_t> m_amount;
std::vector<std::shared_ptr<monero_destination>> m_destinations;
std::vector<uint32_t> m_subtract_fee_from;
boost::optional<std::string> m_payment_id;
boost::optional<monero_tx_priority> m_priority;
boost::optional<uint32_t> m_ring_size;
boost::optional<uint64_t> m_fee;
boost::optional<uint32_t> m_account_index;
std::vector<uint32_t> m_subaddress_indices;
boost::optional<bool> m_can_split;
boost::optional<bool> m_relay;
boost::optional<std::string> m_note;
boost::optional<std::string> m_recipient_name;
boost::optional<uint64_t> m_below_amount;
boost::optional<bool> m_sweep_each_subaddress;
boost::optional<std::string> m_key_image;
monero_tx_config() {}
monero_tx_config(const monero_tx_config& config);
monero_tx_config copy() const;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static std::shared_ptr<monero_tx_config> deserialize(const std::string& config_json);
std::vector<std::shared_ptr<monero_destination>> get_normalized_destinations() const;
};
/**
* Models results from exporting signed key images.
*/
struct monero_key_image_export_result : public serializable_struct {
boost::optional<uint64_t> m_offset;
std::vector<std::shared_ptr<monero_key_image>> m_key_images;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static std::shared_ptr<monero_key_image_export_result> deserialize(const std::string& result_json);
};
/**
* Models results from importing key images.
*/
struct monero_key_image_import_result : public serializable_struct {
boost::optional<uint64_t> m_height;
boost::optional<uint64_t> m_spent_amount;
boost::optional<uint64_t> m_unspent_amount;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_key_image_import_result>& result);
};
/**
* Enumerates message verification results.
*/
enum monero_message_signature_type : uint8_t {
SIGN_WITH_SPEND_KEY = 0,
SIGN_WITH_VIEW_KEY
};
/**
* Models results from message verification.
*/
struct monero_message_signature_result : public serializable_struct {
bool m_is_good;
uint32_t m_version;
bool m_is_old;
monero_message_signature_type m_signature_type;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_message_signature_result> result);
};
/**
* Base class for results from checking a transaction or reserve proof.
*/
struct monero_check : public serializable_struct {
bool m_is_good;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
};
/**
* Results from checking a transaction key.
*/
struct monero_check_tx : public monero_check {
boost::optional<bool> m_in_tx_pool;
boost::optional<uint64_t> m_num_confirmations;
boost::optional<uint64_t> m_received_amount;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_check_tx>& check);
};
/**
* Results from checking a reserve proof.
*/
struct monero_check_reserve : public monero_check {
boost::optional<uint64_t> m_total_amount;
boost::optional<uint64_t> m_unconfirmed_spent_amount;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_check_reserve>& check);
};
/**
* Models information about a multisig wallet.
*/
struct monero_multisig_info : public serializable_struct {
bool m_is_multisig;
bool m_is_ready;
uint32_t m_threshold;
uint32_t m_num_participants;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_multisig_info>& info);
};
/**
* Models the result of initializing a multisig wallet which results in the
* multisig wallet's address xor another multisig hex to share with
* participants to create the wallet.
*/
struct monero_multisig_init_result : public serializable_struct {
boost::optional<std::string> m_address;
boost::optional<std::string> m_multisig_hex;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_multisig_init_result>& res);
};
/**
* Models the result of signing multisig tx hex.
*/
struct monero_multisig_sign_result : public serializable_struct {
boost::optional<std::string> m_signed_multisig_tx_hex;
std::vector<std::string> m_tx_hashes;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_multisig_sign_result>& res);
};
/**
* Monero address book entry model.
*/
struct monero_address_book_entry : public serializable_struct {
boost::optional<uint64_t> m_index; // TODO: not boost::optional
boost::optional<std::string> m_address;
boost::optional<std::string> m_description;
boost::optional<std::string> m_payment_id;
monero_address_book_entry() {}
monero_address_book_entry(uint64_t index, const std::string& address, const std::string& description) : m_index(index), m_address(address), m_description(description) {}
monero_address_book_entry(uint64_t index, const std::string& address, const std::string& description, const std::string& payment_id) : m_index(index), m_address(address), m_description(description), m_payment_id(payment_id) {}
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_address_book_entry>& entry);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
};
/**
* Models a Monero address type.
*/
enum monero_address_type : uint8_t {
PRIMARY_ADDRESS = 0,
INTEGRATED_ADDRESS,
SUBADDRESS
};
/**
* Compares two transactions by their height.
*/
struct monero_tx_height_comparator {
bool operator()(const std::shared_ptr<monero_tx>& tx1, const std::shared_ptr<monero_tx>& tx2) const;
};
/**
* Compares two transfers by ascending account and subaddress indices.
*/
struct monero_incoming_transfer_comparator {
bool operator()(const std::shared_ptr<monero_incoming_transfer>& t1, const std::shared_ptr<monero_incoming_transfer>& t2) const;
bool operator()(const monero_incoming_transfer& t1, const monero_incoming_transfer& t2) const;
};
/**
* Compares two outputs by ascending account and subaddress indices.
*/
struct monero_output_comparator {
bool operator()(const monero_output_wallet& o1, const monero_output_wallet& o2) const;
};
/**
* Maintains metadata for a decoded address.
*/
struct monero_decoded_address : public serializable_struct {
std::string m_address;
monero_address_type m_address_type;
monero_network_type m_network_type;
monero_decoded_address(const std::string& address, monero_address_type address_type, monero_network_type network_type);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Represents an account tag.
*/
struct monero_account_tag : public serializable_struct {
boost::optional<std::string> m_tag;
boost::optional<std::string> m_label;
std::vector<uint32_t> m_account_indices;
monero_account_tag() { }
monero_account_tag(const std::string& tag, const std::string& label): m_tag(tag), m_label(label) { }
monero_account_tag(const std::string& tag, const std::string& label, const std::vector<uint32_t>& account_indices): m_tag(tag), m_label(label), m_account_indices(account_indices) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_account_tag>& account_tag);
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,282 @@
/**
* Copyright (c) everoddandeven
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2025-2026 woodser
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#pragma once
#include "monero_wallet.h"
/**
* Implements a monero_wallet.h by wrapping monero-project's monero-wallet-rpc.
*/
namespace monero {
// forward declaration of internal wallet poller
class monero_wallet_poller;
/**
* Implements a Monero wallet using monero-wallet-rpc.
*/
class monero_wallet_rpc : public monero_wallet {
public:
/**
* Destruct the wallet.
*/
~monero_wallet_rpc();
monero_wallet_rpc(const std::shared_ptr<monero_rpc_connection>& rpc_connection);
monero_wallet_rpc(const std::string& uri = "", const std::string& username = "", const std::string& password = "", const std::string& proxy_uri = "", const std::string& zmq_uri = "", const boost::optional<uint32_t>& timeout = boost::none);
/**
* Set the wallet poll period.
*
* @param period_ms poll period in milliseconds.
*/
void set_poll_period_in_ms(uint64_t period_ms);
/**
* Open an existing wallet from rpc server.
*
* @param config is the wallet configuration
* @return a pointer to the wallet instance
*/
monero_wallet_rpc* open_wallet(const std::shared_ptr<monero_wallet_config> &config);
/**
* Open an existing wallet from rpc server.
*
* @param name is the wallet's name to open
* @param password is the password of the wallet file to open
* @return a pointer to the wallet instance
*/
monero_wallet_rpc* open_wallet(const std::string& name, const std::string& password);
/**
* Create a new wallet with the given configuration.
*
* @param config is the wallet configuration
* @return a pointer to the wallet instance
*/
monero_wallet_rpc* create_wallet(const std::shared_ptr<monero_wallet_config> &config);
/**
* Get the wallet's RPC connection.
*
* @return the wallet's rpc connection
*/
std::shared_ptr<monero_rpc_connection> get_rpc_connection() const { return m_rpc; }
/**
* Get a list of available languages for the wallet's seed.
*
* @return the available languages for the wallet's seed
*/
std::vector<std::string> get_seed_languages() const;
/**
* Save and close the current wallet and stop the RPC server.
*/
void stop();
/**
* Supported wallet methods.
*/
void add_listener(monero_wallet_listener& listener) override;
void remove_listener(monero_wallet_listener& listener) override;
std::set<monero_wallet_listener*> get_listeners() override;
bool is_view_only() const override;
std::shared_ptr<monero_rpc_connection> get_daemon_connection() const override;
void set_daemon_connection(const std::shared_ptr<monero_rpc_connection>& connection, bool is_trusted, const boost::optional<ssl_options>& ssl_options);
void set_daemon_connection(const std::shared_ptr<monero_rpc_connection>& connection, const boost::optional<bool>& is_trusted = boost::none) override;
void set_daemon_connection(const std::string& uri, const std::string& username = "", const std::string& password = "", const std::string& proxy_uri = "", const boost::optional<bool>& is_trusted = boost::none) override;
bool is_connected_to_daemon() const override;
monero_version get_version() const override;
std::string get_path() const override;
std::string get_seed() const override;
std::string get_seed_language() const override;
std::string get_public_view_key() const override;
std::string get_private_view_key() const override;
std::string get_public_spend_key() const override;
std::string get_private_spend_key() const override;
std::string get_address(const uint32_t account_idx, const uint32_t subaddress_idx) const override;
monero_subaddress get_address_index(const std::string& address) const override;
monero_integrated_address get_integrated_address(const std::string& standard_address = "", const std::string& payment_id = "") const override;
monero_integrated_address decode_integrated_address(const std::string& integrated_address) const override;
uint64_t get_height() const override;
uint64_t get_daemon_height() const override;
uint64_t get_height_by_date(uint16_t year, uint8_t month, uint8_t day) const override;
monero_sync_result sync() override;
monero_sync_result sync(monero_wallet_listener& listener) override;
monero_sync_result sync(uint64_t start_height, monero_wallet_listener& listener) override;
monero_sync_result sync(uint64_t start_height) override;
void start_syncing(uint64_t sync_period_in_ms = 10000) override;
void stop_syncing() override;
void scan_txs(const std::vector<std::string>& tx_hashes) override;
void rescan_spent() override;
void rescan_blockchain() override;
uint64_t get_balance() const override;
uint64_t get_balance(uint32_t account_index) const override;
uint64_t get_balance(uint32_t account_idx, uint32_t subaddress_idx) const override;
uint64_t get_unlocked_balance() const override;
uint64_t get_unlocked_balance(uint32_t account_index) const override;
uint64_t get_unlocked_balance(uint32_t account_idx, uint32_t subaddress_idx) const override;
monero_account get_account(const uint32_t account_idx, bool include_subaddresses) const override;
monero_account get_account(const uint32_t account_idx, bool include_subaddresses, bool skip_balances) const;
std::vector<monero_account> get_accounts(bool include_subaddresses, const std::string& tag) const override;
std::vector<monero_account> get_accounts(bool include_subaddresses, const std::string& tag, bool skip_balances) const;
monero_account create_account(const std::string& label = "") override;
std::vector<monero_subaddress> get_subaddresses(const uint32_t account_idx, const std::vector<uint32_t>& subaddress_indices, bool skip_balances) const;
std::vector<monero_subaddress> get_subaddresses(uint32_t account_idx, const std::vector<uint32_t>& subaddress_indices) const override;
std::vector<monero_subaddress> get_subaddresses(const uint32_t account_idx) const override;
monero_subaddress get_subaddress(const uint32_t account_idx, const uint32_t subaddress_idx) const override;
monero_subaddress create_subaddress(uint32_t account_idx, const std::string& label = "") override;
void set_subaddress_label(uint32_t account_idx, uint32_t subaddress_idx, const std::string& label = "") override;
std::vector<std::shared_ptr<monero_tx_wallet>> get_txs() const override;
std::vector<std::shared_ptr<monero_tx_wallet>> get_txs(const monero_tx_query& query) const override;
std::vector<std::shared_ptr<monero_transfer>> get_transfers(const monero_transfer_query& query) const override;
std::vector<std::shared_ptr<monero_output_wallet>> get_outputs(const monero_output_query& query) const override;
std::string export_outputs(bool all = false) const override;
int import_outputs(const std::string& outputs_hex) override;
std::shared_ptr<monero_key_image_export_result> export_key_images(bool all = false) const override;
std::shared_ptr<monero_key_image_import_result> import_key_images(const std::vector<std::shared_ptr<monero_key_image>>& key_images, uint64_t offset = 0) override;
void freeze_output(const std::string& key_image) override;
void thaw_output(const std::string& key_image) override;
bool is_output_frozen(const std::string& key_image) override;
monero_tx_priority get_default_fee_priority() const override;
std::vector<std::shared_ptr<monero_tx_wallet>> create_txs(const monero_tx_config& conf) override;
std::shared_ptr<monero_tx_wallet> sweep_output(const monero_tx_config& config) override;
std::vector<std::shared_ptr<monero_tx_wallet>> sweep_dust(bool relay = false) override;
std::vector<std::shared_ptr<monero_tx_wallet>> sweep_unlocked(const monero_tx_config& config) override;
std::vector<std::string> relay_txs(const std::vector<std::string>& tx_metadatas) override;
monero_tx_set describe_tx_set(const monero_tx_set& tx_set) override;
monero_tx_set sign_txs(const std::string& unsigned_tx_hex) override;
std::vector<std::string> submit_txs(const std::string& signed_tx_hex) override;
std::string sign_message(const std::string& msg, monero_message_signature_type signature_type, uint32_t account_idx = 0, uint32_t subaddress_idx = 0) const override;
monero_message_signature_result verify_message(const std::string& msg, const std::string& address, const std::string& signature) const override;
std::string get_tx_key(const std::string& tx_hash) const override;
std::shared_ptr<monero_check_tx> check_tx_key(const std::string& tx_hash, const std::string& tx_key, const std::string& address) const override;
std::string get_tx_proof(const std::string& tx_hash, const std::string& address, const std::string& message) const override;
std::shared_ptr<monero_check_tx> check_tx_proof(const std::string& tx_hash, const std::string& address, const std::string& message, const std::string& signature) const override;
std::string get_spend_proof(const std::string& tx_hash, const std::string& message) const override;
bool check_spend_proof(const std::string& tx_hash, const std::string& message, const std::string& signature) const override;
std::string get_reserve_proof_wallet(const std::string& message) const override;
std::string get_reserve_proof_account(uint32_t account_idx, uint64_t amount, const std::string& message) const override;
std::shared_ptr<monero_check_reserve> check_reserve_proof(const std::string& address, const std::string& message, const std::string& signature) const override;
std::string get_tx_note(const std::string& tx_hash) const override;
std::vector<std::string> get_tx_notes(const std::vector<std::string>& tx_hashes) const override;
void set_tx_note(const std::string& tx_hashes, const std::string& notes) override;
void set_tx_notes(const std::vector<std::string>& tx_hashes, const std::vector<std::string>& notes) override;
std::vector<monero_address_book_entry> get_address_book_entries(const std::vector<uint64_t>& indices) const override;
uint64_t add_address_book_entry(const std::string& address, const std::string& description) override;
void edit_address_book_entry(uint64_t index, bool set_address, const std::string& address, bool set_description, const std::string& description) override;
void delete_address_book_entry(uint64_t index) override;
void tag_accounts(const std::string& tag, const std::vector<uint32_t>& account_indices) override;
void untag_accounts(const std::vector<uint32_t>& account_indices) override;
std::vector<std::shared_ptr<monero_account_tag>> get_account_tags() const override;
void set_account_tag_label(const std::string& tag, const std::string& label) override;
std::string get_payment_uri(const monero_tx_config& config) const override;
std::shared_ptr<monero_tx_config> parse_payment_uri(const std::string& uri) const override;
void set_attribute(const std::string& key, const std::string& val) override;
bool get_attribute(const std::string& key, std::string& value) const override;
void start_mining(boost::optional<uint64_t> num_threads, boost::optional<bool> background_mining, boost::optional<bool> ignore_battery) override;
void stop_mining() override;
bool is_multisig_import_needed() const override;
monero_multisig_info get_multisig_info() const override;
std::string prepare_multisig() override;
std::string make_multisig(const std::vector<std::string>& multisig_hexes, int threshold, const std::string& password) override;
monero_multisig_init_result exchange_multisig_keys(const std::vector<std::string>& multisig_hexes, const std::string& password) override;
std::string export_multisig_hex() override;
int import_multisig_hex(const std::vector<std::string>& multisig_hexes, const bool refresh_after_import = true) override;
monero_multisig_sign_result sign_multisig_tx_hex(const std::string& multisig_tx_hex) override;
std::vector<std::string> submit_multisig_tx_hex(const std::string& signed_multisig_tx_hex) override;
void change_password(const std::string& old_password, const std::string& new_password) override;
void save() override;
bool is_closed() const override;
void close(bool save = false) override;
std::shared_ptr<monero_subaddress> get_balances(const boost::optional<uint32_t>& account_idx, const boost::optional<uint32_t>& subaddress_idx) const;
// --------------------------------- PRIVATE --------------------------------
private:
friend class monero_wallet_poller;
boost::optional<uint64_t> m_sync_period_in_ms;
std::string m_path = "";
std::shared_ptr<monero_rpc_connection> m_rpc;
std::shared_ptr<monero_rpc_connection> m_daemon_connection;
mutable boost::recursive_mutex m_listeners_mutex;
std::set<monero_wallet_listener*> m_listeners;
mutable boost::recursive_mutex m_sync_mutex;
mutable boost::mutex m_address_cache_mutex;
mutable std::unordered_map<uint32_t, std::unordered_map<uint32_t, std::string>> m_address_cache;
boost::mutex m_poller_mutex;
std::unique_ptr<monero_wallet_poller> m_poller;
monero_wallet_rpc* create_wallet_random(const std::shared_ptr<monero_wallet_config> &config);
monero_wallet_rpc* create_wallet_from_seed(const std::shared_ptr<monero_wallet_config> &config);
monero_wallet_rpc* create_wallet_from_keys(const std::shared_ptr<monero_wallet_config> &config);
monero_sync_result refresh(const std::shared_ptr<serializable_struct>& params);
std::map<uint32_t, std::vector<uint32_t>> get_account_indices(bool get_subaddress_indices) const;
std::vector<uint32_t> get_subaddress_indices(uint32_t account_idx) const;
std::vector<std::shared_ptr<monero_output_wallet>> get_outputs_aux(const monero_output_query& query) const;
std::vector<std::shared_ptr<monero_transfer>> get_transfers_aux(const monero_transfer_query& query) const;
std::string query_key(const std::string& key_type) const;
std::vector<std::shared_ptr<monero_tx_wallet>> sweep_account(const monero_tx_config &conf);
void clear_address_cache();
void refresh_listening();
void wait_for_listener_callbacks_idle();
void poll();
void clear();
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,500 @@
/**
* Copyright (c) everoddandeven
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2025-2026 woodser
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#pragma once
#include "daemon/monero_daemon_rpc_model.h"
#include "monero_wallet_model.h"
/**
* Internal data model for monero_wallet_rpc.
*/
namespace monero {
/**
* key-value struct which can be serialized.
*/
struct key_value : public serializable_struct {
boost::optional<std::string> m_key;
boost::optional<std::string> m_value;
key_value() { }
key_value(const std::string& key): m_key(key) { }
key_value(const std::string& key, const std::string& value): m_key(key), m_value(value) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<key_value>& attributes);
};
/**
* Models a RPC Monero key image.
*/
struct monero_rpc_key_image : public monero_key_image {
monero_rpc_key_image(const std::shared_ptr<monero_key_image> &key_image);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
// ------------------------------ JSON-RPC Params ---------------------------------
struct monero_query_key_params : public serializable_struct {
boost::optional<std::string> m_key_type;
monero_query_key_params(const std::string& key_type): m_key_type(key_type) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_query_output_params : public serializable_struct {
boost::optional<std::string> m_key_image;
monero_query_output_params(const std::string& key_image): m_key_image(key_image) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_get_address_params : public serializable_struct {
boost::optional<std::string> m_address;
boost::optional<uint32_t> m_account_index;
std::vector<uint32_t> m_subaddress_indices;
monero_get_address_params(const std::string& address): m_address(address) { }
monero_get_address_params(uint32_t account_index): m_account_index(account_index) { }
monero_get_address_params(uint32_t account_index, const std::vector<uint32_t>& subaddress_indices): m_account_index(account_index), m_subaddress_indices(subaddress_indices) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_integrated_address_params : public serializable_struct {
boost::optional<std::string> m_integrated_address;
boost::optional<std::string> m_standard_address;
boost::optional<std::string> m_payment_id;
monero_integrated_address_params(const std::string& integrated_address): m_integrated_address(integrated_address) {}
monero_integrated_address_params(const std::string& standard_address, const std::string& payment_id): m_standard_address(standard_address), m_payment_id(payment_id) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_multisig_params : public serializable_struct {
// TODO monero-docs document this parameter
boost::optional<bool> m_enable_multisig_experimental;
boost::optional<bool> m_refresh_after_import;
std::vector<std::string> m_multisig_info;
std::vector<std::string> m_multisig_hexes;
boost::optional<std::string> m_multisig_tx_hex;
boost::optional<int> m_threshold;
boost::optional<std::string> m_password;
monero_multisig_params(bool enable_multisig_experimental = true): m_enable_multisig_experimental(enable_multisig_experimental) { }
monero_multisig_params(const std::vector<std::string>& multisig_hexes, const std::string& password): m_multisig_info(multisig_hexes), m_password(password) { }
monero_multisig_params(const std::vector<std::string>& multisig_hexes, const char* password): monero_multisig_params(multisig_hexes, std::string(password)) { }
monero_multisig_params(const std::vector<std::string>& multisig_hexes, int threshold, const std::string& password): m_multisig_info(multisig_hexes), m_threshold(threshold), m_password(password) { }
monero_multisig_params(const std::vector<std::string>& multisig_hexes, const bool refresh_after_import): m_multisig_hexes(multisig_hexes), m_refresh_after_import(refresh_after_import) { }
monero_multisig_params(const std::string& multisig_tx_hex): m_multisig_tx_hex(multisig_tx_hex) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_payment_uri_params : public serializable_struct {
boost::optional<std::string> m_uri;
boost::optional<std::string> m_address;
boost::optional<uint64_t> m_amount;
boost::optional<std::string> m_payment_id;
boost::optional<std::string> m_recipient_name;
boost::optional<std::string> m_tx_description;
monero_payment_uri_params() { }
monero_payment_uri_params(const std::string& uri): m_uri(uri) { }
monero_payment_uri_params(const monero_tx_config& config);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_get_balance_params : public serializable_struct {
boost::optional<uint32_t> m_account_idx;
std::vector<uint32_t> m_address_indices;
boost::optional<bool> m_all_accounts;
boost::optional<bool> m_strict;
monero_get_balance_params(bool all_accounts, bool strict = false): m_all_accounts(all_accounts), m_strict(strict) { }
monero_get_balance_params(uint32_t account_idx, const std::vector<uint32_t>& address_indices, bool all_accounts = false, bool strict = false): m_account_idx(account_idx), m_address_indices(address_indices), m_all_accounts(all_accounts), m_strict(strict) { }
monero_get_balance_params(uint32_t account_idx, boost::optional<uint32_t> address_idx, bool all_accounts = false, bool strict = false);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_close_wallet_params : public serializable_struct {
boost::optional<bool> m_save;
monero_close_wallet_params(bool save = false): m_save(save) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_change_wallet_password_params : public serializable_struct {
boost::optional<std::string> m_old_password;
boost::optional<std::string> m_new_password;
monero_change_wallet_password_params(const std::string& old_password, const std::string& new_password): m_old_password(old_password), m_new_password(new_password) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_set_daemon_params : public serializable_struct {
boost::optional<std::string> m_address;
boost::optional<std::string> m_username;
boost::optional<std::string> m_password;
boost::optional<std::string> m_proxy;
boost::optional<bool> m_trusted;
boost::optional<std::string> m_ssl_support;
boost::optional<ssl_options> m_ssl_options;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_account_tag_params : public serializable_struct {
std::vector<uint32_t> m_account_indices;
boost::optional<std::string> m_tag;
boost::optional<std::string> m_label;
boost::optional<std::string> m_description;
monero_account_tag_params() {}
monero_account_tag_params(const std::string& tag, const std::string& description = ""): m_tag(tag), m_description(description) { }
monero_account_tag_params(const std::vector<uint32_t>& account_indices): m_account_indices(account_indices) { }
monero_account_tag_params(const std::string& tag, const std::vector<uint32_t>& account_indices): m_tag(tag), m_account_indices(account_indices) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_tx_notes_params : public serializable_struct {
std::vector<std::string> m_tx_hashes;
std::vector<std::string> m_notes;
monero_tx_notes_params(const std::vector<std::string>& tx_hashes): m_tx_hashes(tx_hashes) { }
monero_tx_notes_params(const std::vector<std::string>& tx_hashes, const std::vector<std::string>& notes): m_tx_hashes(tx_hashes), m_notes(notes) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_address_book_entry_params : public serializable_struct {
boost::optional<uint64_t> m_index; // TODO: not boost::optional
boost::optional<bool> m_set_address;
boost::optional<std::string> m_address;
boost::optional<bool> m_set_description;
boost::optional<std::string> m_description;
std::vector<uint64_t> m_entries;
monero_address_book_entry_params(uint64_t index): m_index(index) { }
monero_address_book_entry_params(const std::vector<uint64_t>& entries): m_entries(entries) { }
monero_address_book_entry_params(uint64_t index, bool set_address, const std::string& address, bool set_description, const std::string& description): m_index(index), m_set_address(set_address), m_address(address), m_set_description(set_description), m_description(description) { }
monero_address_book_entry_params(const std::string& address, const std::string& description): m_address(address), m_description(description) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_verify_sign_message_params : public serializable_struct {
boost::optional<std::string> m_data;
boost::optional<std::string> m_address;
boost::optional<std::string> m_signature;
boost::optional<monero_message_signature_type> m_signature_type;
boost::optional<uint32_t> m_account_index;
boost::optional<uint32_t> m_address_index;
monero_verify_sign_message_params(const std::string &data, const std::string &address, const std::string& signature): m_data(data), m_address(address), m_signature(signature) { }
monero_verify_sign_message_params(const std::string &data, monero_message_signature_type signature_type, uint32_t account_index, uint32_t address_index): m_data(data), m_signature_type(signature_type), m_account_index(account_index), m_address_index(address_index) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_check_tx_key_params : public serializable_struct {
boost::optional<std::string> m_tx_hash;
boost::optional<std::string> m_address;
boost::optional<std::string> m_tx_key;
monero_check_tx_key_params(const std::string &tx_hash): m_tx_hash(tx_hash) { }
monero_check_tx_key_params(const std::string &tx_hash, const std::string &tx_key, const std::string &address): m_tx_hash(tx_hash), m_tx_key(tx_key), m_address(address) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_sign_describe_transfer_params : public serializable_struct {
boost::optional<std::string> m_unsigned_txset;
boost::optional<std::string> m_multisig_txset;
boost::optional<bool> m_export_raw;
boost::optional<bool> m_get_tx_keys;
monero_sign_describe_transfer_params() { }
monero_sign_describe_transfer_params(const std::string &unsigned_txset) : m_unsigned_txset(unsigned_txset) { }
monero_sign_describe_transfer_params(const std::string &unsigned_txset, const std::string &multisig_txset) : m_unsigned_txset(unsigned_txset), m_multisig_txset(multisig_txset) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_wallet_relay_tx_params : public serializable_struct {
boost::optional<std::string> m_hex;
boost::optional<std::string> m_signed_tx_hex;
monero_wallet_relay_tx_params() {}
monero_wallet_relay_tx_params(const std::string &hex): m_hex(hex) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_sweep_params : public serializable_struct {
boost::optional<std::string> m_address;
boost::optional<uint32_t> m_account_index;
std::vector<uint32_t> m_subaddr_indices;
boost::optional<std::string> m_key_image;
boost::optional<bool> m_relay;
boost::optional<monero_tx_priority> m_priority;
boost::optional<std::string> m_payment_id;
boost::optional<uint64_t> m_below_amount;
boost::optional<bool> m_get_tx_key;
boost::optional<bool> m_get_tx_keys;
boost::optional<bool> m_get_tx_hex;
boost::optional<bool> m_get_tx_metadata;
monero_sweep_params(bool relay = false): m_relay(relay) { }
monero_sweep_params(const monero_tx_config& config);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_create_edit_subaddress_params : public serializable_struct {
boost::optional<std::string> m_label;
boost::optional<uint32_t> m_account_index;
boost::optional<uint32_t> m_subaddress_index;
monero_create_edit_subaddress_params(uint32_t account_idx, const std::string& label): m_account_index(account_idx), m_label(label) { }
monero_create_edit_subaddress_params(uint32_t account_idx, uint32_t subaddress_idx, const std::string& label): m_account_index(account_idx), m_subaddress_index(subaddress_idx), m_label(label) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_wallet_data_params : public serializable_struct {
boost::optional<bool> m_all;
std::vector<std::shared_ptr<monero_key_image>> m_key_images;
boost::optional<uint64_t> m_offset;
boost::optional<std::string> m_outputs_hex;
monero_wallet_data_params(const std::vector<std::shared_ptr<monero_key_image>> &key_images, uint64_t offset): m_key_images(key_images), m_offset(offset) { };
monero_wallet_data_params(bool all): m_all(all) { }
monero_wallet_data_params(const std::string& outputs_hex): m_outputs_hex(outputs_hex) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_create_open_wallet_params : public serializable_struct {
boost::optional<std::string> m_filename;
boost::optional<std::string> m_password;
boost::optional<std::string> m_language;
boost::optional<std::string> m_seed;
boost::optional<std::string> m_seed_offset;
boost::optional<uint64_t> m_restore_height;
boost::optional<bool> m_autosave_current;
boost::optional<bool> m_enable_multisig_experimental;
boost::optional<std::string> m_address;
boost::optional<std::string> m_view_key;
boost::optional<std::string> m_spend_key;
monero_create_open_wallet_params(const boost::optional<std::string>& filename, const boost::optional<std::string> &password): m_filename(filename), m_password(password), m_autosave_current(false) { }
monero_create_open_wallet_params(const boost::optional<std::string>& filename, const boost::optional<std::string> &password, const boost::optional<std::string> &language): m_filename(filename), m_password(password), m_language(language), m_autosave_current(false) { }
monero_create_open_wallet_params(const boost::optional<std::string>& filename, const boost::optional<std::string> &password, const boost::optional<std::string> &seed, const boost::optional<std::string> &seed_offset, const boost::optional<uint64_t> &restore_height, const boost::optional<std::string> &language, const boost::optional<bool> &autosave_current, const boost::optional<bool> &enable_multisig_experimental): m_filename(filename), m_password(password), m_seed(seed), m_seed_offset(seed_offset), m_restore_height(restore_height), m_language(language), m_autosave_current(autosave_current), m_enable_multisig_experimental(enable_multisig_experimental) { }
monero_create_open_wallet_params(const boost::optional<std::string>& filename, const boost::optional<std::string> &password, const boost::optional<std::string> &address, const boost::optional<std::string> &view_key, const boost::optional<std::string> &spend_key, const boost::optional<uint64_t> &restore_height, const boost::optional<bool> &autosave_current): m_filename(filename), m_password(password), m_address(address), m_view_key(view_key), m_spend_key(spend_key), m_restore_height(restore_height), m_autosave_current(autosave_current) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_reserve_proof_params : public serializable_struct {
boost::optional<bool> m_all;
boost::optional<std::string> m_message;
boost::optional<std::string> m_tx_hash;
boost::optional<uint32_t> m_account_index;
boost::optional<uint64_t> m_amount;
boost::optional<std::string> m_address;
boost::optional<std::string> m_signature;
monero_reserve_proof_params(const std::string &message, bool all = true): m_all(all), m_message(message) { }
monero_reserve_proof_params(const std::string &address, const std::string &message, const std::string &signature): m_address(address), m_message(message), m_signature(signature) { }
monero_reserve_proof_params(const std::string &tx_hash, const std::string &address, const std::string &message, const std::string &signature): m_tx_hash(tx_hash), m_address(address), m_message(message), m_signature(signature) { }
monero_reserve_proof_params(const std::string &tx_hash, const std::string &message): m_tx_hash(tx_hash), m_message(message) { }
monero_reserve_proof_params(uint32_t account_index, uint64_t amount, const std::string &message): m_account_index(account_index), m_amount(amount), m_message(message) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_wallet_refresh_params : public serializable_struct {
boost::optional<bool> m_enable;
boost::optional<uint64_t> m_period;
boost::optional<uint64_t> m_start_height;
monero_wallet_refresh_params() { }
monero_wallet_refresh_params(bool enable): m_enable(enable) { }
monero_wallet_refresh_params(bool enable, uint64_t period): m_enable(enable), m_period(period) { }
monero_wallet_refresh_params(uint64_t start_height): m_start_height(start_height) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_transfer_params : public serializable_struct {
std::vector<uint32_t> m_subtract_fee_from_outputs;
boost::optional<uint32_t> m_account_index;
std::vector<uint32_t> m_subaddress_indices;
boost::optional<std::string> m_payment_id;
boost::optional<bool> m_do_not_relay;
boost::optional<int> m_priority;
boost::optional<bool> m_get_tx_hex;
boost::optional<bool> m_get_tx_metadata;
boost::optional<bool> m_get_tx_keys;
boost::optional<bool> m_get_tx_key;
std::vector<std::shared_ptr<monero_destination>> m_destinations;
monero_transfer_params(const monero_tx_config &config);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_get_transfers_params : public serializable_struct {
boost::optional<bool> m_in;
boost::optional<bool> m_out;
boost::optional<bool> m_pool;
boost::optional<bool> m_pending;
boost::optional<bool> m_failed;
boost::optional<uint64_t> m_min_height;
boost::optional<uint64_t> m_max_height;
boost::optional<bool> m_all_accounts;
boost::optional<uint32_t> m_account_index;
std::vector<uint32_t> m_subaddr_indices;
bool filter_by_height() const { return m_min_height != boost::none || m_max_height != boost::none; }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_get_incoming_transfers_params : public serializable_struct {
boost::optional<std::string> m_transfer_type;
boost::optional<bool> m_verbose;
boost::optional<uint32_t> m_account_index;
std::vector<uint32_t> m_subaddr_indices;
monero_get_incoming_transfers_params(const std::string& transfer_type, bool verbose = true): m_transfer_type(transfer_type), m_verbose(verbose) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
// ------------------------------ JSON-RPC Response ---------------------------------
struct monero_multisig_response {
boost::optional<int> m_num_outputs;
boost::optional<std::string> m_multisig_info;
std::vector<std::string> m_tx_hashes;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_multisig_response>& response);
};
struct monero_get_balance_response {
boost::optional<uint64_t> m_balance;
boost::optional<uint64_t> m_unlocked_balance;
boost::optional<bool> m_multisig_import_needed;
boost::optional<uint64_t> m_time_to_unlock;
boost::optional<uint64_t> m_blocks_to_unlock;
std::vector<std::shared_ptr<monero_subaddress>> m_per_subaddress;
monero_get_balance_response(): m_balance(0), m_unlocked_balance(0) { }
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_get_balance_response>& response);
};
// ------------------------------ RPC Deserialization ---------------------------------
void deserialize_subaddress(const boost::property_tree::ptree& node, const std::shared_ptr<monero_subaddress>& subaddress);
void deserialize_subaddresses(const boost::property_tree::ptree& node, std::vector<std::shared_ptr<monero_subaddress>>& subaddresses);
void deserialize_account(const boost::property_tree::ptree& node, monero_account& account);
void deserialize_accounts(const boost::property_tree::ptree& node, std::vector<monero_account>& accounts);
void init_sent_tx(const monero_tx_config &config, std::shared_ptr<monero_tx_wallet> &tx, bool copy_destinations);
void deserialize_tx_with_transfer_and_merge(const boost::property_tree::ptree& node, std::map<std::string, std::shared_ptr<monero_tx_wallet>>& tx_map, std::map<uint64_t, std::shared_ptr<monero_block>>& block_map);
void deserialize_tx_with_output_and_merge(const boost::property_tree::ptree& node, std::map<std::string, std::shared_ptr<monero_tx_wallet>>& tx_map, std::map<uint64_t, std::shared_ptr<monero_block>>& block_map);
void deserialize_tx_set(const boost::property_tree::ptree& node, const std::shared_ptr<monero_tx_set>& set);
void deserialize_tx_set(const boost::property_tree::ptree& node, const std::shared_ptr<monero_tx_set>& set, const std::shared_ptr<monero_tx_wallet> &tx, bool is_outgoing, const monero_tx_config &config);
void deserialize_sent_tx_set(const boost::property_tree::ptree& node, const std::shared_ptr<monero_tx_set>& set);
void deserialize_sent_tx_set(const boost::property_tree::ptree& node, const std::shared_ptr<monero_tx_set>& set, std::vector<std::shared_ptr<monero_tx_wallet>> &txs, const boost::optional<monero_tx_config> &conf);
void deserialize_described_tx_set(const boost::property_tree::ptree& node, const std::shared_ptr<monero_tx_set>& set);
void deserialize_submitted_tx_hashes(const boost::property_tree::ptree& node, std::vector<std::string>& tx_hashes);
void deserialize_relayed_tx_hash(const boost::property_tree::ptree& node, std::vector<std::string>& tx_hashes);
int deserialize_num_created_txs(const boost::property_tree::ptree& node, bool can_split);
std::string deserialize_tx_key(const boost::property_tree::ptree& node);
void deserialize_tx_notes(const boost::property_tree::ptree& node, std::vector<std::string>& tx_notes);
void deserialize_integrated_address(const boost::property_tree::ptree& node, monero_integrated_address& subaddress);
void deserialize_key_image_export_result(const boost::property_tree::ptree& node, const std::shared_ptr<monero_key_image_export_result>& result);
void deserialize_key_image_import_result(const boost::property_tree::ptree& node, const std::shared_ptr<monero_key_image_import_result>& result);
void deserialize_message_signature_result(const boost::property_tree::ptree& node, monero_message_signature_result& result);
void deserialize_check_tx(const boost::property_tree::ptree& node, const std::shared_ptr<monero_check_tx>& check);
void deserialize_check_reserve(const boost::property_tree::ptree& node, const std::shared_ptr<monero_check_reserve>& check);
void deserialize_multisig_info(const boost::property_tree::ptree& node, monero_multisig_info& info);
void deserialize_multisig_init_result(const boost::property_tree::ptree& node, monero_multisig_init_result& res);
void deserialize_multisig_sign_result(const boost::property_tree::ptree& node, monero_multisig_sign_result& res);
void deserialize_address_book_entries(const boost::property_tree::ptree& node, std::vector<monero_address_book_entry>& entries);
void deserialize_seed_languages(const boost::property_tree::ptree& node, std::vector<std::string>& languages);
uint64_t deserialize_address_book_index(const boost::property_tree::ptree& node);
void deserialize_account_tags(const boost::property_tree::ptree& node, std::vector<std::shared_ptr<monero_account_tag>>& account_tags);
std::string deserialize_signature(const boost::property_tree::ptree& node);
std::string deserialize_exported_outputs(const boost::property_tree::ptree& node);
uint64_t deserialize_block_height(const boost::property_tree::ptree& node);
int deserialize_num_imported_outputs(const boost::property_tree::ptree& node);
bool deserialize_frozen_output_info(const boost::property_tree::ptree& node);
monero_tx_priority deserialize_tx_priority(const boost::property_tree::ptree& node);
std::string deserialize_payment_uri(const boost::property_tree::ptree& node);
void deserialize_payment_uri(const boost::property_tree::ptree& node, const std::shared_ptr<monero_tx_config>& tx_config);
void deserialize_sync_result(const boost::property_tree::ptree& node, monero_sync_result& sync_result);
}
Binary file not shown.
Binary file not shown.
+385
View File
@@ -0,0 +1,385 @@
import Foundation
import os
/// On-device verification against vectors generated from the reference
/// implementation (xrpl.js + ripple-keypairs + ripple-binary-codec). Runs only
/// when the app is launched with `-koinsSelfTest`.
enum SelfTest {
private static let log = OSLog(subsystem: "com.koins.app", category: "selftest")
/// Writes to both stdout and the unified log so results are visible however
/// the app was launched.
private static var report: [String] = []
private static func emit(_ line: String) {
print(line)
os_log("%{public}@", log: log, type: .default, line)
report.append(line)
}
/// Writes the collected lines into the app container so the result can be
/// read back off the device (console output is not always reachable).
private static func flushReport() {
guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
let url = documents.appendingPathComponent("koins-selftest.txt")
try? report.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8)
}
static func runIfRequested() {
if ProcessInfo.processInfo.arguments.contains("-koinsSelfTest") {
emit("KOINS_SELFTEST begin")
run()
emit("KOINS_SELFTEST end")
}
if ProcessInfo.processInfo.arguments.contains("-koinsSelfTestNetwork") {
runNetwork()
}
if ProcessInfo.processInfo.arguments.contains("-koinsSelfTestSwap") {
runSwapCheckIfRequested()
}
}
/// End-to-end check of the live network paths (XRP RPC + native Monero
/// wallet). Triggered with `-koinsSelfTestNetwork`; cleans up after itself.
static func runNetwork() {
// Detached so the test does not inherit the main actor: the UI thread must
// stay free, otherwise the launch-time call would deadlock.
Task.detached(priority: .userInitiated) {
emit("KOINS_NET begin")
// The test creates temporary wallets, so snapshot the real ones and put
// them back afterwards instead of wiping them.
let backup = (xrp: WalletStore.wallet(.xrp),
xmr: WalletStore.wallet(.xmr),
xrpSecret: WalletStore.secret(.xrp),
xmrSecret: WalletStore.secret(.xmr))
func restoreWallets() {
if let xrp = backup.xrp { WalletStore.saveWallet(xrp, chain: .xrp) } else { WalletStore.removeWallet(.xrp) }
if let secret = backup.xrpSecret { WalletStore.saveSecret(secret, chain: .xrp) }
if let xmr = backup.xmr { WalletStore.saveWallet(xmr, chain: .xmr) } else { WalletStore.removeWallet(.xmr) }
if let secret = backup.xmrSecret { WalletStore.saveSecret(secret, chain: .xmr) }
emit("KOINS_NET restored saved wallets")
}
do {
let (record, phrase) = try XRPWalletService.shared.createWallet(name: "koins-net-selftest")
emit("KOINS_NET xrp address \(record.address) phraseWords \(phrase.split(separator: " ").count)")
let balance = try await XRPWalletService.shared.balance(address: record.address)
emit("KOINS_NET xrp funded=\(balance.funded) balance=\(balance.total)")
let history = try await XRPWalletService.shared.transactions(address: record.address)
emit("KOINS_NET xrp transactions=\(history.count)")
// A known funded account, to exercise the funded-balance path.
for reference in ["rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", "rHR6xtDW7BxeQN8VB8R9HzXzyMeS42n7C"] {
let reference = try await XRPWalletService.shared.balance(address: reference)
emit("KOINS_NET xrp reference funded=\(reference.funded) balance=\(reference.total)")
let referenceHistory = try await XRPWalletService.shared.transactions(address: "rHR6xtDW7BxeQN8VB8R9HzXzyMeS42n7C")
emit("KOINS_NET xrp reference transactions=\(referenceHistory.count)")
}
} catch {
emit("KOINS_NET xrp FAIL \(error)")
}
do {
let created = try await MoneroService.shared.createWallet(name: "koins-net-selftest")
let words = created.mnemonic.split(separator: " ").count
emit("KOINS_NET xmr address \(created.address) mnemonicWords \(words)")
let started = try await MoneroService.shared.startSync()
emit("KOINS_NET xmr startedSync \(started)")
for attempt in 1...6 {
try? await Task.sleep(nanoseconds: 5_000_000_000)
let status = try await MoneroService.shared.syncStatus()
emit("KOINS_NET xmr status \(attempt) height=\(status.height) daemon=\(status.daemonHeight) connected=\(status.connected) syncing=\(status.syncing)")
}
let balance = try await MoneroService.shared.balance()
emit("KOINS_NET xmr balance \(balance.total) unlocked \(balance.unlocked)")
let accounts = try await MoneroService.shared.accounts()
emit("KOINS_NET xmr accounts \(accounts.count) subaddresses \(accounts.first?.subaddresses.count ?? 0)")
let account = try await MoneroService.shared.createAccount(label: "Savings")
emit("KOINS_NET xmr new account index \(account.index) address \(account.address.prefix(12))")
let subaddress = try await MoneroService.shared.createSubaddress(accountIndex: 0, label: "From Alice")
emit("KOINS_NET xmr new subaddress index \(subaddress.index) address \(subaddress.address.prefix(12))")
let reloaded = try await MoneroService.shared.accounts()
emit("KOINS_NET xmr accounts after \(reloaded.count) subaddresses \(reloaded.first?.subaddresses.count ?? 0)")
await MoneroService.shared.stopSync()
await MoneroService.shared.removeWallet(name: "koins-net-selftest")
} catch {
emit("KOINS_NET xmr FAIL \(error)")
}
restoreWallets()
emit("KOINS_NET end")
flushReport()
}
}
/// Temporary: exercise the kyc.rip estimate path with real values.
static func runSwapCheckIfRequested() {
guard ProcessInfo.processInfo.arguments.contains("-koinsSelfTestSwap") else { return }
Task.detached {
do {
let estimate = try await SwapService.shared.estimate(from: "xmr", to: "xrp", amount: 1)
emit("KOINS_SWAP amountTo \(estimate.amountTo)")
for route in estimate.routes.prefix(3) {
emit("KOINS_SWAP route \(route.provider) (\(route.engine)) -> \(route.amountTo) eta \(route.eta ?? -1)")
}
} catch {
emit("KOINS_SWAP FAIL \(error)")
}
flushReport()
}
}
static func run() {
var failures = 0
func expect(_ name: String, _ actual: String, _ wanted: String) {
let ok = actual.lowercased() == wanted.lowercased()
if !ok { failures += 1 }
emit("KOINS_SELFTEST \(name): \(ok ? "PASS" : "FAIL") \(actual)")
}
func expectTrue(_ name: String, _ value: Bool) {
if !value { failures += 1 }
emit("KOINS_SELFTEST \(name): \(value ? "PASS" : "FAIL")")
}
expect("ripemd160", Hex.encode(RIPEMD160.hash(Array("abc".utf8))),
"8eb208f7e05d987a9b044a8e98c6b087f15a0bfc")
// ed25519 vector
let edSeed = "sEdSPx8CLbFUna3DJdvJ74aQPLdemqu"
let edAddress = "rEhh6f9rj5UUBhFzGGaxS5zYU2CCqKFXBC"
let edPublicKey = "EDF9096653463E3A0B79A67E69D9E66AB83D93D4E867335ADE5DAFE8467F4F1734"
if let keypair = try? XRPKey.keypair(fromSeed: edSeed) {
expect("ed25519 pubkey", Hex.encode(keypair.publicKey), edPublicKey)
expect("ed25519 address", keypair.address, edAddress)
} else {
failures += 1
emit("KOINS_SELFTEST ed25519: FAIL keypair threw")
}
// secp256k1 vector: exercises the OpenSSL-backed derivation on device.
let secpSeed = "spFbtYoPd7Hg7AD9XZn7CBwUKC5Qe"
let secpPublicKey = "0322DC9BB14903F71186BC46B0107D55A069D23CD97BBD205491A992EC68FA0ABE"
let secpAddress = "rpsRYc8DbXzfVN32w3hZjUtyuF1K89hu47"
do {
let keypair = try XRPKey.keypair(fromSeed: secpSeed)
expect("secp256k1 algorithm", keypair.algorithm.rawValue, "secp256k1")
expect("secp256k1 pubkey", Hex.encode(keypair.publicKey), secpPublicKey)
expect("secp256k1 address", keypair.address, secpAddress)
} catch {
failures += 1
emit("KOINS_SELFTEST secp256k1: FAIL \(error)")
}
// Serialization + hashing reference vector.
let signingHex = "53545800120000220000000024000030392E00000309201B05E69F3B61400000000016E36068400000000000000C7321EDF9096653463E3A0B79A67E69D9E66AB83D93D4E867335ADE5DAFE8467F4F173481149A26172134BFDA9708A73305AF2776A26DB7B4F5831492891F44F454D156327AA24F4C8BB72540E31BF1"
let referenceSignature = "F560672EA3103D2E43179302CD66ACF563A4EDFBA90ADE24E378B743135FD989EE132FA4B703021C2DD1979E770A6CEBC99556EA330AF9E6E7186673F837CB0D"
let expectedBlob = "120000220000000024000030392E00000309201B05E69F3B61400000000016E36068400000000000000C7321EDF9096653463E3A0B79A67E69D9E66AB83D93D4E867335ADE5DAFE8467F4F17347440F560672EA3103D2E43179302CD66ACF563A4EDFBA90ADE24E378B743135FD989EE132FA4B703021C2DD1979E770A6CEBC99556EA330AF9E6E7186673F837CB0D81149A26172134BFDA9708A73305AF2776A26DB7B4F5831492891F44F454D156327AA24F4C8BB72540E31BF1"
let expectedHash = "F853B5391CD6317EA370830EEA3C047AC8D21E9FA00E4116D94C48E360B0434F"
var payment = XRPPayment(account: edAddress,
destination: "rNMovRR3WPbFLVaSbETCCR71XsqyxhJ9P6",
amountDrops: 1_500_000,
feeDrops: 12,
sequence: 12345,
lastLedgerSequence: 99000123,
destinationTag: 777,
signingPublicKey: Hex.decode(edPublicKey) ?? [],
signature: Hex.decode(referenceSignature))
if let payload = try? payment.signingPayload() {
expect("signing payload", Hex.encode(payload), signingHex)
} else {
failures += 1
}
if let blob = try? payment.txBlob() {
expect("tx blob", Hex.encode(blob), expectedBlob)
if let hash = try? payment.transactionHash() {
expect("tx hash", Hex.encode(hash), expectedHash)
} else {
failures += 1
}
} else {
failures += 1
}
// BIP-39 / BIP-32 (m/44'/144'/0'/0/0): the recovery phrase used for new XRP
// wallets. Vectors come from @scure/bip39 + @scure/bip32 + ripple-keypairs.
let zeroMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
expect("bip39 encode", BIP39.mnemonic(from: [UInt8](repeating: 0, count: 16)), zeroMnemonic)
expect("bip39 entropy", Hex.encode(BIP39.entropy(from: zeroMnemonic) ?? []), String(repeating: "00", count: 16))
expect("bip39 rejects bad checksum",
BIP39.entropy(from: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon") == nil ? "rejected" : "accepted",
"rejected")
// BIP-32 intermediate values, to isolate derivation problems.
if let master = BIP32.masterKey(fromSeed: BIP39.seed(from: zeroMnemonic)) {
expect("bip32 master key", Hex.encode(master.privateKey),
"1837c1be8e2995ec11cda2b066151be2cfb48adf9e47b151d46adab3a21cdf67")
expect("bip32 master chain code", Hex.encode(master.chainCode),
"7923408dadd3c7b56eed15567707ae5e5dca089de972e07f3b860450e2a3b70e")
let H = BIP32.hardenedBit
for (level, path, expected) in [
(1, [H | 44], "cd46049fb82a4fccbd967c5234e61152f4df87d01d1ef833420814c67ee4b7ea"),
(2, [H | 44, H | 144], "a12250f1f38fb41b62f0f1a4ec9d3555d24788843cb30e6d98a777fe540adc1d"),
(3, [H | 44, H | 144, H | 0], "54bac451906ecd52648a49126a2606c9177761f14d31dda079c0f768ac3501fd"),
(4, [H | 44, H | 144, H | 0, 0], "e582ce1724e93eebe3f38f1d42c01620b66d136946fec450dfe71b62e65de54f"),
(5, BIP32.xrpPath, "90802a50aa84efb6cdb225f17c27616ea94048c179142fecf03f4712a07ea7a4"),
] {
if let child = BIP32.derive(path, from: master) {
expect("bip32 level \(level)", Hex.encode(child.privateKey), expected)
} else {
failures += 1
emit("KOINS_SELFTEST bip32 level \(level): FAIL derivation returned nil")
}
}
} else {
failures += 1
emit("KOINS_SELFTEST bip32 master: FAIL")
}
let mnemonicCases: [(String, String, String)] = [
(zeroMnemonic,
"031d68bc1a142e6766b2bdfb006ccfe135ef2e0e2e94abb5cf5c9ab6104776fbae",
"rHsMGQEkVNJmpGWs8XUBoTBiAAbwxZN5v3"),
("legal winner thank year wave sausage worth useful legal winner thank yellow",
"0283e0926eb0ab7bb8f5c0b5637d9e2526bdc398763af79cd904f013178961692d",
"rpaLPza5oxhJ7V8nFDwPm9F7kmZ16uc8do"),
]
for (position, vector) in mnemonicCases.enumerated() {
do {
let keypair = try XRPKey.keypair(fromMnemonic: vector.0)
expect("bip44 pubkey #\(position)", Hex.encode(keypair.publicKey), vector.1)
expect("bip44 address #\(position)", keypair.address, vector.2)
} catch {
failures += 1
emit("KOINS_SELFTEST bip44 #\(position): FAIL \(error)")
}
}
// QR round trip: generate an image, read it back, parse the payment URI.
let xmrAddress = "46cRPeU77sBVGSVXPyCjvJUdC9AjqvETWb6giUGskdnWN8ZifindmrH7mcztKU52iKgvWXY4dyPujg9LYUY1Q7dvDiwHNCE"
let xrpAddress = "rHR6xtDW7BxeQN8VB8R9HzXzyMeS42n7C"
func roundTrip(_ name: String, _ text: String, checks: [(String, String)]) {
guard let image = QRCodeImage.make(text) else {
failures += 1
emit("KOINS_SELFTEST \(name): FAIL could not render QR image")
return
}
guard let decoded = QRCodeReader.decodeText(in: image) else {
failures += 1
emit("KOINS_SELFTEST \(name): FAIL vision could not read the QR image (\(Int(image.size.width))px)")
return
}
guard let payload = QRCodeReader.parse(decoded) else {
failures += 1
emit("KOINS_SELFTEST \(name): FAIL parse of '\(decoded)'")
return
}
for (label, expected) in checks {
let actual: String
switch label {
case "address": actual = payload.address
case "amount": actual = payload.amount ?? ""
case "tag": actual = payload.tag.map(String.init) ?? ""
case "paymentId": actual = payload.paymentId ?? ""
case "memo": actual = payload.memo ?? ""
default: actual = ""
}
expect("\(name) \(label)", actual, expected)
}
}
roundTrip("qr monero uri",
"monero:\(xmrAddress)?tx_amount=1.5&tx_payment_id=abc123&tx_description=Rent",
checks: [("address", xmrAddress), ("amount", "1.5"), ("paymentId", "abc123"), ("memo", "Rent")])
roundTrip("qr monero plain", xmrAddress, checks: [("address", xmrAddress)])
roundTrip("qr xrp uri",
"ripple:\(xrpAddress)?amount=12.5&dt=99",
checks: [("address", xrpAddress), ("amount", "12.5"), ("tag", "99")])
roundTrip("qr xrp plain", xrpAddress, checks: [("address", xrpAddress)])
expectTrue("qr monero detected", AddressFormat.isMonero(xmrAddress))
expectTrue("qr xrp detected", AddressFormat.isXRP(xrpAddress))
expectTrue("qr rejects junk", QRCodeReader.parse("hello world")?.address == "hello world")
expectTrue("qr not monero", !AddressFormat.isMonero(xrpAddress))
expectTrue("qr not xrp", !AddressFormat.isXRP(xmrAddress))
emit("biometrics available=\(Biometrics.isAvailable) label=\(Biometrics.label)")
// secp256k1 signing must produce a low-S DER signature.
if let keypair = try? XRPKey.keypair(fromSeed: secpSeed),
let message = Hex.decode(signingHex),
let signature = try? XRPKey.sign(message: message, keypair: keypair) {
let isDer = signature.count > 8 && signature[0] == 0x30
expect("secp256k1 signature is DER", isDer ? "yes" : "no", "yes")
if let order = BigNumber.fromHex("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141"),
let half = order.dividedByTwo(),
let s = DER.signatureScalar(signature) {
expect("secp256k1 signature is low-S", s.lessThanOrEqual(half) ? "yes" : "no", "yes")
}
emit("KOINS_SELFTEST secp256k1 signature len: \(signature.count)")
} else {
failures += 1
emit("KOINS_SELFTEST secp256k1 sign: FAIL")
}
emit("KOINS_SELFTEST summary: \(failures == 0 ? "ALL PASS" : "\(failures) FAILURE(S)")")
flushReport()
}
}
/// Minimal big-endian integer helper used only for the low-S check.
struct BigNumber {
let bytes: [UInt8]
static func fromHex(_ hex: String) -> BigNumber? {
Hex.decode(hex).map { BigNumber(bytes: $0) }
}
func dividedByTwo() -> BigNumber? {
var result: [UInt8] = []
var carry: UInt16 = 0
for byte in bytes {
let value = carry * 256 + UInt16(byte)
result.append(UInt8(value / 2))
carry = value % 2
}
return BigNumber(bytes: result)
}
func lessThanOrEqual(_ other: BigNumber) -> Bool {
let left = trimmed()
let right = other.trimmed()
if left.count != right.count { return left.count < right.count }
for (a, b) in zip(left, right) where a != b { return a < b }
return true
}
private func trimmed() -> [UInt8] {
var value = bytes
while value.first == 0 && value.count > 1 { value.removeFirst() }
return value
}
}
enum DER {
/// Extracts the `s` component from a DER-encoded ECDSA signature.
static func signatureScalar(_ der: [UInt8]) -> BigNumber? {
guard der.count > 8, der[0] == 0x30 else { return nil }
var index = 2
guard der[index] == 0x02 else { return nil }
let rLength = Int(der[index + 1])
index += 2 + rLength
guard index + 2 <= der.count, der[index] == 0x02 else { return nil }
let sLength = Int(der[index + 1])
let start = index + 2
guard start + sLength <= der.count else { return nil }
return BigNumber(bytes: Array(der[start..<(start + sLength)]))
}
}
+92
View File
@@ -0,0 +1,92 @@
import CryptoKit
import Foundation
/// BIP-32 key derivation for the XRP account path `m/44'/144'/0'/0/0`. The
/// first three steps are hardened, the last two are not, so both cases are
/// supported (hardened steps sign with the parent private key, normal steps
/// with the parent public key).
enum BIP32 {
static let hardenedBit: UInt32 = 0x8000_0000
/// Standard XRP derivation path: 44' / 144' / 0' / 0 / 0.
static let xrpPath: [UInt32] = [
hardenedBit | 44,
hardenedBit | 144,
hardenedBit | 0,
0,
0,
]
struct Key {
var privateKey: [UInt8]
var chainCode: [UInt8]
var publicKey: [UInt8]
}
static func masterKey(fromSeed seed: [UInt8]) -> Key? {
let derived = hmac(key: Array("Bitcoin seed".utf8), data: seed)
guard derived.count == 64 else { return nil }
let privateKey = Array(derived[0..<32])
guard isValidScalar(privateKey),
let publicKey = Secp256k1Backend.publicKey?(Data(privateKey)) else { return nil }
return Key(privateKey: privateKey,
chainCode: Array(derived[32..<64]),
publicKey: Array(publicKey))
}
static func derive(_ path: [UInt32], from key: Key) -> Key? {
var current = key
for index in path {
guard let next = deriveChild(current, index: index) else { return nil }
current = next
}
return current
}
private static func deriveChild(_ parent: Key, index: UInt32) -> Key? {
let isHardened = index & hardenedBit != 0
var data: [UInt8] = []
if isHardened {
data.append(0x00)
data += parent.privateKey
} else {
data += parent.publicKey
}
data += [
UInt8((index >> 24) & 0xff),
UInt8((index >> 16) & 0xff),
UInt8((index >> 8) & 0xff),
UInt8(index & 0xff),
]
let derived = hmac(key: parent.chainCode, data: data)
guard derived.count == 64 else { return nil }
let left = Array(derived[0..<32])
guard isValidScalar(left),
let sum = Secp256k1Backend.addScalar?(Data(left), Data(parent.privateKey)) else { return nil }
let privateKey = Array(sum)
// The child public key is the same whether it is computed from the child
// private key or by point addition, so derive it directly.
guard isValidScalar(privateKey),
let publicKey = Secp256k1Backend.publicKey?(Data(privateKey)) else { return nil }
return Key(privateKey: privateKey,
chainCode: Array(derived[32..<64]),
publicKey: Array(publicKey))
}
/// A key is usable when 0 < key < the secp256k1 group order.
private static func isValidScalar(_ bytes: [UInt8]) -> Bool {
guard bytes.count == 32 else { return false }
let orderHex = "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"
guard let order = Hex.decode(orderHex) else { return false }
if bytes.allSatisfy({ $0 == 0 }) { return false }
for (left, right) in zip(bytes, order) where left != right { return left < right }
return false
}
private static func hmac(key: [UInt8], data: [UInt8]) -> [UInt8] {
Array(HMAC<SHA512>.authenticationCode(for: Data(data), using: SymmetricKey(data: Data(key))))
}
}
+94
View File
@@ -0,0 +1,94 @@
import CryptoKit
import Foundation
/// BIP-39 mnemonics. The XRP wallet uses a 12 word phrase as its recovery
/// secret, matching what hardware and mobile wallets expect.
enum BIP39 {
static let wordCount = 12
static let entropyBytes = 16
private static let checksumBits = entropyBytes / 4
private static let index: [String: Int] = {
var map: [String: Int] = [:]
for (position, word) in BIP39Wordlist.words.enumerated() { map[word] = position }
return map
}()
static func generateMnemonic() -> String {
var entropy = [UInt8](repeating: 0, count: entropyBytes)
for position in entropy.indices { entropy[position] = UInt8.random(in: 0...255) }
return mnemonic(from: entropy)
}
/// 16 bytes of entropy plus its 4 bit SHA-256 checksum, as 12 words.
static func mnemonic(from entropy: [UInt8]) -> String {
precondition(entropy.count == entropyBytes, "BIP-39 entropy must be 16 bytes")
var bits = entropy.flatMap { byte in (0..<8).map { (byte >> (7 - $0)) & 1 } }
let checksum = XRPHash.sha256(entropy)
bits += (0..<checksumBits).map { (checksum[0] >> (7 - $0)) & 1 }
var words: [String] = []
for start in stride(from: 0, to: bits.count, by: 11) {
var value = 0
for offset in 0..<11 { value = (value << 1) | Int(bits[start + offset]) }
words.append(BIP39Wordlist.words[value])
}
return words.joined(separator: " ")
}
static func entropy(from mnemonic: String) -> [UInt8]? {
let words = normalize(mnemonic).split(separator: " ").map(String.init)
guard words.count == wordCount else { return nil }
var bits: [Int] = []
for word in words {
guard let position = index[word] else { return nil }
bits += (0..<11).map { (position >> (10 - $0)) & 1 }
}
var entropy: [UInt8] = []
for start in stride(from: 0, to: entropyBytes * 8, by: 8) {
var value: UInt8 = 0
for offset in 0..<8 { value = (value << 1) | UInt8(bits[start + offset]) }
entropy.append(value)
}
let checksum = XRPHash.sha256(entropy)
for offset in 0..<checksumBits {
let expected = (checksum[0] >> (7 - offset)) & 1
guard bits[entropyBytes * 8 + offset] == expected else { return nil }
}
return entropy
}
/// 64 byte BIP-39 seed (PBKDF2-HMAC-SHA512, empty passphrase).
static func seed(from mnemonic: String) -> [UInt8] {
let password = Array(normalize(mnemonic).utf8)
let salt = Array("mnemonic".utf8)
// Derived length equals the hash length, so a single PBKDF2 block is needed.
let block = salt + [0, 0, 0, 1]
var previous = hmacSHA512(key: password, data: block)
var result = previous
for _ in 1..<2_048 {
previous = hmacSHA512(key: password, data: previous)
for position in result.indices { result[position] ^= previous[position] }
}
return result
}
static func isMnemonic(_ secret: String) -> Bool {
normalize(secret).split(separator: " ").count == wordCount
}
private static func normalize(_ text: String) -> String {
text.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
.precomposedStringWithCompatibilityMapping
}
private static func hmacSHA512(key: [UInt8], data: [UInt8]) -> [UInt8] {
let authentication = HMAC<SHA512>.authenticationCode(for: Data(data),
using: SymmetricKey(data: Data(key)))
return Array(authentication)
}
}
+263
View File
@@ -0,0 +1,263 @@
import Foundation
/// BIP-39 English word list (2048 words), in the canonical order.
enum BIP39Wordlist {
static let words: [String] = [
"abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract",
"absurd", "abuse", "access", "accident", "account", "accuse", "achieve", "acid",
"acoustic", "acquire", "across", "act", "action", "actor", "actress", "actual",
"adapt", "add", "addict", "address", "adjust", "admit", "adult", "advance",
"advice", "aerobic", "affair", "afford", "afraid", "again", "age", "agent",
"agree", "ahead", "aim", "air", "airport", "aisle", "alarm", "album",
"alcohol", "alert", "alien", "all", "alley", "allow", "almost", "alone",
"alpha", "already", "also", "alter", "always", "amateur", "amazing", "among",
"amount", "amused", "analyst", "anchor", "ancient", "anger", "angle", "angry",
"animal", "ankle", "announce", "annual", "another", "answer", "antenna", "antique",
"anxiety", "any", "apart", "apology", "appear", "apple", "approve", "april",
"arch", "arctic", "area", "arena", "argue", "arm", "armed", "armor",
"army", "around", "arrange", "arrest", "arrive", "arrow", "art", "artefact",
"artist", "artwork", "ask", "aspect", "assault", "asset", "assist", "assume",
"asthma", "athlete", "atom", "attack", "attend", "attitude", "attract", "auction",
"audit", "august", "aunt", "author", "auto", "autumn", "average", "avocado",
"avoid", "awake", "aware", "away", "awesome", "awful", "awkward", "axis",
"baby", "bachelor", "bacon", "badge", "bag", "balance", "balcony", "ball",
"bamboo", "banana", "banner", "bar", "barely", "bargain", "barrel", "base",
"basic", "basket", "battle", "beach", "bean", "beauty", "because", "become",
"beef", "before", "begin", "behave", "behind", "believe", "below", "belt",
"bench", "benefit", "best", "betray", "better", "between", "beyond", "bicycle",
"bid", "bike", "bind", "biology", "bird", "birth", "bitter", "black",
"blade", "blame", "blanket", "blast", "bleak", "bless", "blind", "blood",
"blossom", "blouse", "blue", "blur", "blush", "board", "boat", "body",
"boil", "bomb", "bone", "bonus", "book", "boost", "border", "boring",
"borrow", "boss", "bottom", "bounce", "box", "boy", "bracket", "brain",
"brand", "brass", "brave", "bread", "breeze", "brick", "bridge", "brief",
"bright", "bring", "brisk", "broccoli", "broken", "bronze", "broom", "brother",
"brown", "brush", "bubble", "buddy", "budget", "buffalo", "build", "bulb",
"bulk", "bullet", "bundle", "bunker", "burden", "burger", "burst", "bus",
"business", "busy", "butter", "buyer", "buzz", "cabbage", "cabin", "cable",
"cactus", "cage", "cake", "call", "calm", "camera", "camp", "can",
"canal", "cancel", "candy", "cannon", "canoe", "canvas", "canyon", "capable",
"capital", "captain", "car", "carbon", "card", "cargo", "carpet", "carry",
"cart", "case", "cash", "casino", "castle", "casual", "cat", "catalog",
"catch", "category", "cattle", "caught", "cause", "caution", "cave", "ceiling",
"celery", "cement", "census", "century", "cereal", "certain", "chair", "chalk",
"champion", "change", "chaos", "chapter", "charge", "chase", "chat", "cheap",
"check", "cheese", "chef", "cherry", "chest", "chicken", "chief", "child",
"chimney", "choice", "choose", "chronic", "chuckle", "chunk", "churn", "cigar",
"cinnamon", "circle", "citizen", "city", "civil", "claim", "clap", "clarify",
"claw", "clay", "clean", "clerk", "clever", "click", "client", "cliff",
"climb", "clinic", "clip", "clock", "clog", "close", "cloth", "cloud",
"clown", "club", "clump", "cluster", "clutch", "coach", "coast", "coconut",
"code", "coffee", "coil", "coin", "collect", "color", "column", "combine",
"come", "comfort", "comic", "common", "company", "concert", "conduct", "confirm",
"congress", "connect", "consider", "control", "convince", "cook", "cool", "copper",
"copy", "coral", "core", "corn", "correct", "cost", "cotton", "couch",
"country", "couple", "course", "cousin", "cover", "coyote", "crack", "cradle",
"craft", "cram", "crane", "crash", "crater", "crawl", "crazy", "cream",
"credit", "creek", "crew", "cricket", "crime", "crisp", "critic", "crop",
"cross", "crouch", "crowd", "crucial", "cruel", "cruise", "crumble", "crunch",
"crush", "cry", "crystal", "cube", "culture", "cup", "cupboard", "curious",
"current", "curtain", "curve", "cushion", "custom", "cute", "cycle", "dad",
"damage", "damp", "dance", "danger", "daring", "dash", "daughter", "dawn",
"day", "deal", "debate", "debris", "decade", "december", "decide", "decline",
"decorate", "decrease", "deer", "defense", "define", "defy", "degree", "delay",
"deliver", "demand", "demise", "denial", "dentist", "deny", "depart", "depend",
"deposit", "depth", "deputy", "derive", "describe", "desert", "design", "desk",
"despair", "destroy", "detail", "detect", "develop", "device", "devote", "diagram",
"dial", "diamond", "diary", "dice", "diesel", "diet", "differ", "digital",
"dignity", "dilemma", "dinner", "dinosaur", "direct", "dirt", "disagree", "discover",
"disease", "dish", "dismiss", "disorder", "display", "distance", "divert", "divide",
"divorce", "dizzy", "doctor", "document", "dog", "doll", "dolphin", "domain",
"donate", "donkey", "donor", "door", "dose", "double", "dove", "draft",
"dragon", "drama", "drastic", "draw", "dream", "dress", "drift", "drill",
"drink", "drip", "drive", "drop", "drum", "dry", "duck", "dumb",
"dune", "during", "dust", "dutch", "duty", "dwarf", "dynamic", "eager",
"eagle", "early", "earn", "earth", "easily", "east", "easy", "echo",
"ecology", "economy", "edge", "edit", "educate", "effort", "egg", "eight",
"either", "elbow", "elder", "electric", "elegant", "element", "elephant", "elevator",
"elite", "else", "embark", "embody", "embrace", "emerge", "emotion", "employ",
"empower", "empty", "enable", "enact", "end", "endless", "endorse", "enemy",
"energy", "enforce", "engage", "engine", "enhance", "enjoy", "enlist", "enough",
"enrich", "enroll", "ensure", "enter", "entire", "entry", "envelope", "episode",
"equal", "equip", "era", "erase", "erode", "erosion", "error", "erupt",
"escape", "essay", "essence", "estate", "eternal", "ethics", "evidence", "evil",
"evoke", "evolve", "exact", "example", "excess", "exchange", "excite", "exclude",
"excuse", "execute", "exercise", "exhaust", "exhibit", "exile", "exist", "exit",
"exotic", "expand", "expect", "expire", "explain", "expose", "express", "extend",
"extra", "eye", "eyebrow", "fabric", "face", "faculty", "fade", "faint",
"faith", "fall", "false", "fame", "family", "famous", "fan", "fancy",
"fantasy", "farm", "fashion", "fat", "fatal", "father", "fatigue", "fault",
"favorite", "feature", "february", "federal", "fee", "feed", "feel", "female",
"fence", "festival", "fetch", "fever", "few", "fiber", "fiction", "field",
"figure", "file", "film", "filter", "final", "find", "fine", "finger",
"finish", "fire", "firm", "first", "fiscal", "fish", "fit", "fitness",
"fix", "flag", "flame", "flash", "flat", "flavor", "flee", "flight",
"flip", "float", "flock", "floor", "flower", "fluid", "flush", "fly",
"foam", "focus", "fog", "foil", "fold", "follow", "food", "foot",
"force", "forest", "forget", "fork", "fortune", "forum", "forward", "fossil",
"foster", "found", "fox", "fragile", "frame", "frequent", "fresh", "friend",
"fringe", "frog", "front", "frost", "frown", "frozen", "fruit", "fuel",
"fun", "funny", "furnace", "fury", "future", "gadget", "gain", "galaxy",
"gallery", "game", "gap", "garage", "garbage", "garden", "garlic", "garment",
"gas", "gasp", "gate", "gather", "gauge", "gaze", "general", "genius",
"genre", "gentle", "genuine", "gesture", "ghost", "giant", "gift", "giggle",
"ginger", "giraffe", "girl", "give", "glad", "glance", "glare", "glass",
"glide", "glimpse", "globe", "gloom", "glory", "glove", "glow", "glue",
"goat", "goddess", "gold", "good", "goose", "gorilla", "gospel", "gossip",
"govern", "gown", "grab", "grace", "grain", "grant", "grape", "grass",
"gravity", "great", "green", "grid", "grief", "grit", "grocery", "group",
"grow", "grunt", "guard", "guess", "guide", "guilt", "guitar", "gun",
"gym", "habit", "hair", "half", "hammer", "hamster", "hand", "happy",
"harbor", "hard", "harsh", "harvest", "hat", "have", "hawk", "hazard",
"head", "health", "heart", "heavy", "hedgehog", "height", "hello", "helmet",
"help", "hen", "hero", "hidden", "high", "hill", "hint", "hip",
"hire", "history", "hobby", "hockey", "hold", "hole", "holiday", "hollow",
"home", "honey", "hood", "hope", "horn", "horror", "horse", "hospital",
"host", "hotel", "hour", "hover", "hub", "huge", "human", "humble",
"humor", "hundred", "hungry", "hunt", "hurdle", "hurry", "hurt", "husband",
"hybrid", "ice", "icon", "idea", "identify", "idle", "ignore", "ill",
"illegal", "illness", "image", "imitate", "immense", "immune", "impact", "impose",
"improve", "impulse", "inch", "include", "income", "increase", "index", "indicate",
"indoor", "industry", "infant", "inflict", "inform", "inhale", "inherit", "initial",
"inject", "injury", "inmate", "inner", "innocent", "input", "inquiry", "insane",
"insect", "inside", "inspire", "install", "intact", "interest", "into", "invest",
"invite", "involve", "iron", "island", "isolate", "issue", "item", "ivory",
"jacket", "jaguar", "jar", "jazz", "jealous", "jeans", "jelly", "jewel",
"job", "join", "joke", "journey", "joy", "judge", "juice", "jump",
"jungle", "junior", "junk", "just", "kangaroo", "keen", "keep", "ketchup",
"key", "kick", "kid", "kidney", "kind", "kingdom", "kiss", "kit",
"kitchen", "kite", "kitten", "kiwi", "knee", "knife", "knock", "know",
"lab", "label", "labor", "ladder", "lady", "lake", "lamp", "language",
"laptop", "large", "later", "latin", "laugh", "laundry", "lava", "law",
"lawn", "lawsuit", "layer", "lazy", "leader", "leaf", "learn", "leave",
"lecture", "left", "leg", "legal", "legend", "leisure", "lemon", "lend",
"length", "lens", "leopard", "lesson", "letter", "level", "liar", "liberty",
"library", "license", "life", "lift", "light", "like", "limb", "limit",
"link", "lion", "liquid", "list", "little", "live", "lizard", "load",
"loan", "lobster", "local", "lock", "logic", "lonely", "long", "loop",
"lottery", "loud", "lounge", "love", "loyal", "lucky", "luggage", "lumber",
"lunar", "lunch", "luxury", "lyrics", "machine", "mad", "magic", "magnet",
"maid", "mail", "main", "major", "make", "mammal", "man", "manage",
"mandate", "mango", "mansion", "manual", "maple", "marble", "march", "margin",
"marine", "market", "marriage", "mask", "mass", "master", "match", "material",
"math", "matrix", "matter", "maximum", "maze", "meadow", "mean", "measure",
"meat", "mechanic", "medal", "media", "melody", "melt", "member", "memory",
"mention", "menu", "mercy", "merge", "merit", "merry", "mesh", "message",
"metal", "method", "middle", "midnight", "milk", "million", "mimic", "mind",
"minimum", "minor", "minute", "miracle", "mirror", "misery", "miss", "mistake",
"mix", "mixed", "mixture", "mobile", "model", "modify", "mom", "moment",
"monitor", "monkey", "monster", "month", "moon", "moral", "more", "morning",
"mosquito", "mother", "motion", "motor", "mountain", "mouse", "move", "movie",
"much", "muffin", "mule", "multiply", "muscle", "museum", "mushroom", "music",
"must", "mutual", "myself", "mystery", "myth", "naive", "name", "napkin",
"narrow", "nasty", "nation", "nature", "near", "neck", "need", "negative",
"neglect", "neither", "nephew", "nerve", "nest", "net", "network", "neutral",
"never", "news", "next", "nice", "night", "noble", "noise", "nominee",
"noodle", "normal", "north", "nose", "notable", "note", "nothing", "notice",
"novel", "now", "nuclear", "number", "nurse", "nut", "oak", "obey",
"object", "oblige", "obscure", "observe", "obtain", "obvious", "occur", "ocean",
"october", "odor", "off", "offer", "office", "often", "oil", "okay",
"old", "olive", "olympic", "omit", "once", "one", "onion", "online",
"only", "open", "opera", "opinion", "oppose", "option", "orange", "orbit",
"orchard", "order", "ordinary", "organ", "orient", "original", "orphan", "ostrich",
"other", "outdoor", "outer", "output", "outside", "oval", "oven", "over",
"own", "owner", "oxygen", "oyster", "ozone", "pact", "paddle", "page",
"pair", "palace", "palm", "panda", "panel", "panic", "panther", "paper",
"parade", "parent", "park", "parrot", "party", "pass", "patch", "path",
"patient", "patrol", "pattern", "pause", "pave", "payment", "peace", "peanut",
"pear", "peasant", "pelican", "pen", "penalty", "pencil", "people", "pepper",
"perfect", "permit", "person", "pet", "phone", "photo", "phrase", "physical",
"piano", "picnic", "picture", "piece", "pig", "pigeon", "pill", "pilot",
"pink", "pioneer", "pipe", "pistol", "pitch", "pizza", "place", "planet",
"plastic", "plate", "play", "please", "pledge", "pluck", "plug", "plunge",
"poem", "poet", "point", "polar", "pole", "police", "pond", "pony",
"pool", "popular", "portion", "position", "possible", "post", "potato", "pottery",
"poverty", "powder", "power", "practice", "praise", "predict", "prefer", "prepare",
"present", "pretty", "prevent", "price", "pride", "primary", "print", "priority",
"prison", "private", "prize", "problem", "process", "produce", "profit", "program",
"project", "promote", "proof", "property", "prosper", "protect", "proud", "provide",
"public", "pudding", "pull", "pulp", "pulse", "pumpkin", "punch", "pupil",
"puppy", "purchase", "purity", "purpose", "purse", "push", "put", "puzzle",
"pyramid", "quality", "quantum", "quarter", "question", "quick", "quit", "quiz",
"quote", "rabbit", "raccoon", "race", "rack", "radar", "radio", "rail",
"rain", "raise", "rally", "ramp", "ranch", "random", "range", "rapid",
"rare", "rate", "rather", "raven", "raw", "razor", "ready", "real",
"reason", "rebel", "rebuild", "recall", "receive", "recipe", "record", "recycle",
"reduce", "reflect", "reform", "refuse", "region", "regret", "regular", "reject",
"relax", "release", "relief", "rely", "remain", "remember", "remind", "remove",
"render", "renew", "rent", "reopen", "repair", "repeat", "replace", "report",
"require", "rescue", "resemble", "resist", "resource", "response", "result", "retire",
"retreat", "return", "reunion", "reveal", "review", "reward", "rhythm", "rib",
"ribbon", "rice", "rich", "ride", "ridge", "rifle", "right", "rigid",
"ring", "riot", "ripple", "risk", "ritual", "rival", "river", "road",
"roast", "robot", "robust", "rocket", "romance", "roof", "rookie", "room",
"rose", "rotate", "rough", "round", "route", "royal", "rubber", "rude",
"rug", "rule", "run", "runway", "rural", "sad", "saddle", "sadness",
"safe", "sail", "salad", "salmon", "salon", "salt", "salute", "same",
"sample", "sand", "satisfy", "satoshi", "sauce", "sausage", "save", "say",
"scale", "scan", "scare", "scatter", "scene", "scheme", "school", "science",
"scissors", "scorpion", "scout", "scrap", "screen", "script", "scrub", "sea",
"search", "season", "seat", "second", "secret", "section", "security", "seed",
"seek", "segment", "select", "sell", "seminar", "senior", "sense", "sentence",
"series", "service", "session", "settle", "setup", "seven", "shadow", "shaft",
"shallow", "share", "shed", "shell", "sheriff", "shield", "shift", "shine",
"ship", "shiver", "shock", "shoe", "shoot", "shop", "short", "shoulder",
"shove", "shrimp", "shrug", "shuffle", "shy", "sibling", "sick", "side",
"siege", "sight", "sign", "silent", "silk", "silly", "silver", "similar",
"simple", "since", "sing", "siren", "sister", "situate", "six", "size",
"skate", "sketch", "ski", "skill", "skin", "skirt", "skull", "slab",
"slam", "sleep", "slender", "slice", "slide", "slight", "slim", "slogan",
"slot", "slow", "slush", "small", "smart", "smile", "smoke", "smooth",
"snack", "snake", "snap", "sniff", "snow", "soap", "soccer", "social",
"sock", "soda", "soft", "solar", "soldier", "solid", "solution", "solve",
"someone", "song", "soon", "sorry", "sort", "soul", "sound", "soup",
"source", "south", "space", "spare", "spatial", "spawn", "speak", "special",
"speed", "spell", "spend", "sphere", "spice", "spider", "spike", "spin",
"spirit", "split", "spoil", "sponsor", "spoon", "sport", "spot", "spray",
"spread", "spring", "spy", "square", "squeeze", "squirrel", "stable", "stadium",
"staff", "stage", "stairs", "stamp", "stand", "start", "state", "stay",
"steak", "steel", "stem", "step", "stereo", "stick", "still", "sting",
"stock", "stomach", "stone", "stool", "story", "stove", "strategy", "street",
"strike", "strong", "struggle", "student", "stuff", "stumble", "style", "subject",
"submit", "subway", "success", "such", "sudden", "suffer", "sugar", "suggest",
"suit", "summer", "sun", "sunny", "sunset", "super", "supply", "supreme",
"sure", "surface", "surge", "surprise", "surround", "survey", "suspect", "sustain",
"swallow", "swamp", "swap", "swarm", "swear", "sweet", "swift", "swim",
"swing", "switch", "sword", "symbol", "symptom", "syrup", "system", "table",
"tackle", "tag", "tail", "talent", "talk", "tank", "tape", "target",
"task", "taste", "tattoo", "taxi", "teach", "team", "tell", "ten",
"tenant", "tennis", "tent", "term", "test", "text", "thank", "that",
"theme", "then", "theory", "there", "they", "thing", "this", "thought",
"three", "thrive", "throw", "thumb", "thunder", "ticket", "tide", "tiger",
"tilt", "timber", "time", "tiny", "tip", "tired", "tissue", "title",
"toast", "tobacco", "today", "toddler", "toe", "together", "toilet", "token",
"tomato", "tomorrow", "tone", "tongue", "tonight", "tool", "tooth", "top",
"topic", "topple", "torch", "tornado", "tortoise", "toss", "total", "tourist",
"toward", "tower", "town", "toy", "track", "trade", "traffic", "tragic",
"train", "transfer", "trap", "trash", "travel", "tray", "treat", "tree",
"trend", "trial", "tribe", "trick", "trigger", "trim", "trip", "trophy",
"trouble", "truck", "true", "truly", "trumpet", "trust", "truth", "try",
"tube", "tuition", "tumble", "tuna", "tunnel", "turkey", "turn", "turtle",
"twelve", "twenty", "twice", "twin", "twist", "two", "type", "typical",
"ugly", "umbrella", "unable", "unaware", "uncle", "uncover", "under", "undo",
"unfair", "unfold", "unhappy", "uniform", "unique", "unit", "universe", "unknown",
"unlock", "until", "unusual", "unveil", "update", "upgrade", "uphold", "upon",
"upper", "upset", "urban", "urge", "usage", "use", "used", "useful",
"useless", "usual", "utility", "vacant", "vacuum", "vague", "valid", "valley",
"valve", "van", "vanish", "vapor", "various", "vast", "vault", "vehicle",
"velvet", "vendor", "venture", "venue", "verb", "verify", "version", "very",
"vessel", "veteran", "viable", "vibrant", "vicious", "victory", "video", "view",
"village", "vintage", "violin", "virtual", "virus", "visa", "visit", "visual",
"vital", "vivid", "vocal", "voice", "void", "volcano", "volume", "vote",
"voyage", "wage", "wagon", "wait", "walk", "wall", "walnut", "want",
"warfare", "warm", "warrior", "wash", "wasp", "waste", "water", "wave",
"way", "wealth", "weapon", "wear", "weasel", "weather", "web", "wedding",
"weekend", "weird", "welcome", "west", "wet", "whale", "what", "wheat",
"wheel", "when", "where", "whip", "whisper", "wide", "width", "wife",
"wild", "will", "win", "window", "wine", "wing", "wink", "winner",
"winter", "wire", "wisdom", "wise", "wish", "witness", "wolf", "woman",
"wonder", "wood", "wool", "word", "work", "world", "worry", "worth",
"wrap", "wreck", "wrestle", "wrist", "write", "wrong", "yard", "year",
"yellow", "you", "young", "youth", "zebra", "zero", "zone", "zoo",
]
}
+76
View File
@@ -0,0 +1,76 @@
import Foundation
/// Base58 with the XRP Ledger alphabet and Base58Check helpers.
enum XRPBase58 {
static let alphabet: [Character] = Array("rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz")
private static let valueOf: [Character: Int] = {
var map: [Character: Int] = [:]
for (index, character) in alphabet.enumerated() { map[character] = index }
return map
}()
static func encode(_ bytes: [UInt8]) -> String {
var zeros = 0
for byte in bytes {
if byte == 0 { zeros += 1 } else { break }
}
var digits: [Int] = []
for byte in bytes[zeros...] {
var carry = Int(byte)
for index in digits.indices {
carry += digits[index] << 8
digits[index] = carry % 58
carry /= 58
}
while carry > 0 {
digits.append(carry % 58)
carry /= 58
}
}
var output = String(repeating: String(alphabet[0]), count: zeros)
for digit in digits.reversed() { output.append(alphabet[digit]) }
return output
}
static func decode(_ string: String) throws -> [UInt8] {
let characters = Array(string)
var zeros = 0
for character in characters {
if character == alphabet[0] { zeros += 1 } else { break }
}
var bytes: [Int] = []
for character in characters[zeros...] {
guard let value = valueOf[character] else { throw XRPError.invalidBase58 }
var carry = value
for index in bytes.indices {
carry += bytes[index] * 58
bytes[index] = carry & 0xff
carry >>= 8
}
while carry > 0 {
bytes.append(carry & 0xff)
carry >>= 8
}
}
return [UInt8](repeating: 0, count: zeros) + bytes.reversed().map { UInt8($0) }
}
static func checkEncode(version: [UInt8], payload: [UInt8]) -> String {
let body = version + payload
return encode(body + XRPHash.checksum(body))
}
static func checkDecode(_ string: String, versionLength: Int) throws -> [UInt8] {
let decoded = try decode(string)
guard decoded.count > versionLength + 4 else { throw XRPError.invalidAddress }
let body = Array(decoded[0..<(decoded.count - 4)])
let checksum = Array(decoded[(decoded.count - 4)...])
guard XRPHash.checksum(body) == checksum else { throw XRPError.invalidAddress }
return Array(body[versionLength...])
}
}
+25
View File
@@ -0,0 +1,25 @@
import Foundation
import LocalAuthentication
/// Face ID / Touch ID gating for the app lock and for sensitive actions.
enum Biometrics {
static var isAvailable: Bool {
LAContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
}
static var label: String {
let context = LAContext()
_ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
return context.biometryType == .faceID ? "Face ID" : "Touch ID"
}
/// Biometrics with the system passcode fallback (the alert offers "Use
/// Passcode" after a failed face scan).
static func authenticate(reason: String) async -> Bool {
guard isAvailable else { return false }
let context = LAContext()
context.localizedCancelTitle = "Cancel"
return (try? await context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
localizedReason: reason)) ?? false
}
}
+10
View File
@@ -0,0 +1,10 @@
#include <libkern/OSCacheControl.h>
#include <stddef.h>
// randomx (pulled in by monero's proof-of-work code) calls the compiler-rt
// builtin __clear_cache from its AArch64 JIT. libSystem exports it on macOS and
// the simulator, but not on iOS devices, so provide the same implementation
// compiler-rt uses on Darwin.
void __clear_cache(void *start, void *end) {
sys_icache_invalidate(start, (size_t)((char *)end - (char *)start));
}
+68
View File
@@ -0,0 +1,68 @@
import CryptoKit
import Foundation
enum XRPError: LocalizedError {
case invalidBase58
case invalidAddress
case invalidSeed
case unsupportedSeed
case rpc(String)
case network(String)
var errorDescription: String? {
switch self {
case .invalidBase58: return "Invalid Base58 text"
case .invalidAddress: return "Invalid XRP address"
case .invalidSeed: return "Check the recovery phrase or secret"
case .unsupportedSeed: return "Unsupported XRP secret"
case .rpc(let message): return message
case .network(let message): return message
}
}
}
enum XRPHash {
static func sha256(_ bytes: [UInt8]) -> [UInt8] {
Array(SHA256.hash(data: Data(bytes)))
}
static func sha512(_ bytes: [UInt8]) -> [UInt8] {
Array(SHA512.hash(data: Data(bytes)))
}
/// First 32 bytes of SHA-512, used by the XRP Ledger everywhere.
static func sha512Half(_ bytes: [UInt8]) -> [UInt8] {
Array(sha512(bytes).prefix(32))
}
static func ripemd160(_ bytes: [UInt8]) -> [UInt8] {
RIPEMD160.hash(bytes)
}
/// Base58Check double-SHA-256 checksum.
static func checksum(_ bytes: [UInt8]) -> [UInt8] {
Array(sha256(sha256(bytes)).prefix(4))
}
}
enum Hex {
static func encode(_ bytes: [UInt8]) -> String {
bytes.map { String(format: "%02x", $0) }.joined()
}
static func decode(_ string: String) -> [UInt8]? {
var text = string.trimmingCharacters(in: .whitespacesAndNewlines)
if text.hasPrefix("0x") || text.hasPrefix("0X") { text = String(text.dropFirst(2)) }
guard text.count % 2 == 0 else { return nil }
var bytes: [UInt8] = []
bytes.reserveCapacity(text.count / 2)
var index = text.startIndex
while index < text.endIndex {
let next = text.index(index, offsetBy: 2)
guard let byte = UInt8(text[index..<next], radix: 16) else { return nil }
bytes.append(byte)
index = next
}
return bytes
}
}
+106
View File
@@ -0,0 +1,106 @@
import Foundation
/// Fiat prices for the two tickers the wallet supports, fetched from a public
/// price API. Cached briefly in memory and in UserDefaults so screens do not
/// refetch on every refresh, and so the currency conversion shows up instantly
/// on a cold start.
final class PriceService {
static let shared = PriceService()
struct Prices: Codable {
var xrp: Double?
var xmr: Double?
var currency: String
var fetchedAt: Date?
}
private static let cacheLifetime: TimeInterval = 60
private static let defaultsKey = "koins.prices"
private var memory: Prices?
private var inFlight: Task<Prices, Never>?
private init() {
if let data = UserDefaults.standard.data(forKey: Self.defaultsKey),
let saved = try? JSONDecoder().decode(Prices.self, from: data) {
memory = saved
}
}
func prices(currency: String) async -> Prices {
if let memory,
memory.currency == currency,
let fetchedAt = memory.fetchedAt,
Date().timeIntervalSince(fetchedAt) < Self.cacheLifetime {
return memory
}
if let inFlight { return await inFlight.value }
let task = Task<Prices, Never> { [weak self] in
guard let self else { return Prices(currency: currency, fetchedAt: nil) }
let fetched = await self.fetch(currency: currency)
self.memory = fetched
if let data = try? JSONEncoder().encode(fetched) {
UserDefaults.standard.set(data, forKey: Self.defaultsKey)
}
return fetched
}
inFlight = task
let result = await task.value
inFlight = nil
return result
}
private func fetch(currency: String) async -> Prices {
if let prices = try? await fromCoinGecko(currency: currency) { return prices }
return (try? await fromKraken(currency: currency)) ?? Prices(currency: currency, fetchedAt: nil)
}
private func fromCoinGecko(currency: String) async throws -> Prices {
let code = currency.lowercased()
guard let url = URL(string: "https://api.coingecko.com/api/v3/simple/price?ids=monero,ripple&vs_currencies=\(code)") else {
throw XRPError.network("Invalid price endpoint")
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw XRPError.network("CoinGecko request failed")
}
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
return Prices(
xrp: (json["ripple"] as? [String: Any]).flatMap { ($0[code] as? NSNumber)?.doubleValue },
xmr: (json["monero"] as? [String: Any]).flatMap { ($0[code] as? NSNumber)?.doubleValue },
currency: currency,
fetchedAt: Date())
}
/// Kraken only lists USD and EUR for both assets; anything else just yields
/// no price rather than a wrong one.
private func fromKraken(currency: String) async throws -> Prices {
let supported: [String: (xrp: String, xmr: String)] = [
"USD": ("XXRPZUSD", "XXMRZUSD"),
"EUR": ("XXRPZEUR", "XXMRZEUR"),
]
guard let pairs = supported[currency] else {
throw XRPError.network("Kraken has no \(currency) pairs")
}
guard let url = URL(string: "https://api.kraken.com/0/public/Ticker?pair=\(pairs.xrp),\(pairs.xmr)") else {
throw XRPError.network("Invalid price endpoint")
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw XRPError.network("Kraken request failed")
}
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
let result = json["result"] as? [String: Any] ?? [:]
func lastTrade(_ key: String) -> Double? {
(result[key] as? [String: Any])
.flatMap { entry in (entry["c"] as? [Any])?.first }
.flatMap { ($0 as? String).flatMap(Double.init) }
}
return Prices(xrp: lastTrade(pairs.xrp),
xmr: lastTrade(pairs.xmr),
currency: currency,
fetchedAt: Date())
}
}
+135
View File
@@ -0,0 +1,135 @@
import CoreImage
import CoreImage.CIFilterBuiltins
import Foundation
import UIKit
import Vision
/// Renders a QR image (used by the UI and by the self-test, which round-trips
/// an image through the reader).
enum QRCodeImage {
static func make(_ text: String, scale: CGFloat = 10) -> UIImage? {
guard !text.isEmpty else { return nil }
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(text.utf8)
filter.correctionLevel = "M"
guard let output = filter.outputImage else { return nil }
let scaled = output.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
let context = CIContext()
guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { return nil }
return UIImage(cgImage: cgImage)
}
}
/// A payment request decoded from a QR code: the address plus whatever else the
/// code carried.
struct QRPayload {
var address: String
var amount: String?
var tag: UInt32?
var paymentId: String?
var memo: String?
}
enum QRCodeReader {
/// Reads the first QR code found in an image (used for photo-library import).
static func decodeText(in data: Data) -> String? {
guard let image = UIImage(data: data), let cgImage = image.cgImage else { return nil }
return decodeText(in: cgImage)
}
static func decodeText(in image: UIImage) -> String? {
guard let cgImage = image.cgImage else { return nil }
return decodeText(in: cgImage)
}
private static func decodeText(in cgImage: CGImage) -> String? {
if let text = decodeWithVision(cgImage) { return text }
// Vision's barcode detector does not work on the iOS simulator, so fall
// back to CoreImage, which does (and adds redundancy on device).
return decodeWithCoreImage(cgImage)
}
private static func decodeWithVision(_ cgImage: CGImage) -> String? {
let request = VNDetectBarcodesRequest()
request.symbologies = [.qr]
let handler = VNImageRequestHandler(cgImage: cgImage, orientation: .up)
try? handler.perform([request])
for observation in request.results ?? [] {
if let payload = observation.payloadStringValue, !payload.isEmpty {
return payload
}
}
return nil
}
private static func decodeWithCoreImage(_ cgImage: CGImage) -> String? {
let detector = CIDetector(ofType: CIDetectorTypeQRCode,
context: nil,
options: [CIDetectorAccuracy: CIDetectorAccuracyHigh])
let features = detector?.features(in: CIImage(cgImage: cgImage)) ?? []
for feature in features {
if let code = feature as? CIQRCodeFeature, let message = code.messageString, !message.isEmpty {
return message
}
}
return nil
}
/// Understands plain addresses as well as the payment URIs wallets emit:
/// `monero:` (tx_amount, tx_payment_id, tx_description) and `ripple:`
/// (amount, dt).
static func parse(_ raw: String) -> QRPayload? {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
var body = text
var query: [String: String] = [:]
if let separator = text.firstIndex(of: "?") {
body = String(text[text.startIndex..<separator])
let queryText = String(text[text.index(after: separator)...])
for pair in queryText.split(separator: "&") {
let parts = pair.split(separator: "=", maxSplits: 1)
guard let key = parts.first else { continue }
let value = parts.count > 1 ? String(parts[1]) : ""
query[key.lowercased()] = value.removingPercentEncoding ?? value
}
}
let lowered = body.lowercased()
if let range = lowered.range(of: "monero:") {
body = String(body[range.upperBound...])
} else if let range = lowered.range(of: "ripple:") {
body = String(body[range.upperBound...])
}
while body.hasPrefix("/") { body.removeFirst() }
let address = body.trimmingCharacters(in: .whitespacesAndNewlines)
guard !address.isEmpty else { return nil }
var payload = QRPayload(address: address)
payload.amount = query["tx_amount"] ?? query["amount"]
payload.memo = query["tx_description"] ?? query["name"] ?? query["message"]
payload.paymentId = query["tx_payment_id"] ?? query["payment_id"] ?? query["pid"]
if let tag = query["dt"] ?? query["destination_tag"] ?? query["destinationtag"] {
payload.tag = UInt32(tag)
}
return payload
}
}
enum AddressFormat {
private static let moneroAlphabet = Set("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz")
/// Monero addresses are 95 characters (primary/subaddress) or 106
/// (integrated), all base58.
static func isMonero(_ address: String) -> Bool {
let text = address.trimmingCharacters(in: .whitespacesAndNewlines)
guard text.count == 95 || text.count == 106 else { return false }
return text.allSatisfy { moneroAlphabet.contains($0) }
}
static func isXRP(_ address: String) -> Bool {
XRPKey.isValidAddress(address.trimmingCharacters(in: .whitespacesAndNewlines))
}
}
+114
View File
@@ -0,0 +1,114 @@
import Foundation
/// RIPEMD-160, needed for XRP account id derivation (there is no CryptoKit /
/// CommonCrypto implementation of this digest on iOS).
enum RIPEMD160 {
private static let roundLeft: [Int] = [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
7, 4, 13, 1, 10, 6, 15, 3, 12, 0, 9, 5, 2, 14, 11, 8,
3, 10, 14, 4, 9, 15, 8, 1, 2, 7, 0, 6, 13, 11, 5, 12,
1, 9, 11, 10, 0, 8, 12, 4, 13, 3, 7, 15, 14, 5, 6, 2,
4, 0, 5, 9, 7, 12, 2, 10, 14, 1, 3, 8, 11, 6, 15, 13,
]
private static let roundRight: [Int] = [
5, 14, 7, 0, 9, 2, 11, 4, 13, 6, 15, 8, 1, 10, 3, 12,
6, 11, 3, 7, 0, 13, 5, 10, 14, 15, 8, 12, 4, 9, 1, 2,
15, 5, 1, 3, 7, 14, 6, 9, 11, 8, 12, 2, 10, 0, 4, 13,
8, 6, 4, 1, 3, 11, 15, 0, 5, 12, 2, 13, 9, 7, 10, 14,
12, 15, 10, 4, 1, 5, 8, 7, 6, 2, 13, 14, 0, 3, 9, 11,
]
private static let shiftsLeft: [UInt32] = [
11, 14, 15, 12, 5, 8, 7, 9, 11, 13, 14, 15, 6, 7, 9, 8,
7, 6, 8, 13, 11, 9, 7, 15, 7, 12, 15, 9, 11, 7, 13, 12,
11, 13, 6, 7, 14, 9, 13, 15, 14, 8, 13, 6, 5, 12, 7, 5,
11, 12, 14, 15, 14, 15, 9, 8, 9, 14, 5, 6, 8, 6, 5, 12,
9, 15, 5, 11, 6, 8, 13, 12, 5, 12, 13, 14, 11, 8, 5, 6,
]
private static let shiftsRight: [UInt32] = [
8, 9, 9, 11, 13, 15, 15, 5, 7, 7, 8, 11, 14, 14, 12, 6,
9, 13, 15, 7, 12, 8, 9, 11, 7, 7, 12, 7, 6, 15, 13, 11,
9, 7, 15, 11, 8, 6, 6, 14, 12, 13, 5, 14, 13, 13, 7, 5,
15, 5, 8, 11, 14, 14, 6, 14, 6, 9, 12, 9, 12, 5, 15, 8,
8, 5, 12, 9, 12, 5, 14, 6, 8, 13, 6, 5, 15, 13, 11, 11,
]
private static let constantsLeft: [UInt32] = [0x0000_0000, 0x5A82_7999, 0x6ED9_EBA1, 0x8F1B_BCDC, 0xA953_FD4E]
private static let constantsRight: [UInt32] = [0x50A2_8BE6, 0x5C4D_D124, 0x6D70_3EF3, 0x7A6D_76E9, 0x0000_0000]
static func hash(_ message: [UInt8]) -> [UInt8] {
var padded = message
let bitLength = UInt64(message.count) * 8
padded.append(0x80)
while padded.count % 64 != 56 { padded.append(0) }
for shift in stride(from: 0, through: 56, by: 8) {
padded.append(UInt8((bitLength >> UInt64(shift)) & 0xff))
}
var h0: UInt32 = 0x6745_2301
var h1: UInt32 = 0xEFCD_AB89
var h2: UInt32 = 0x98BA_DCFE
var h3: UInt32 = 0x1032_5476
var h4: UInt32 = 0xC3D2_E1F0
var offset = 0
while offset < padded.count {
var words = [UInt32](repeating: 0, count: 16)
for index in 0..<16 {
let base = offset + index * 4
words[index] = UInt32(padded[base])
| (UInt32(padded[base + 1]) << 8)
| (UInt32(padded[base + 2]) << 16)
| (UInt32(padded[base + 3]) << 24)
}
var al = h0, bl = h1, cl = h2, dl = h3, el = h4
var ar = h0, br = h1, cr = h2, dr = h3, er = h4
for step in 0..<80 {
let round = step / 16
var leftTemp = rotate(al &+ f(round, bl, cl, dl) &+ words[roundLeft[step]] &+ constantsLeft[round], by: shiftsLeft[step]) &+ el
al = el; el = dl; dl = rotate(cl, by: 10); cl = bl; bl = leftTemp
let rightRound = 4 - round
leftTemp = rotate(ar &+ f(rightRound, br, cr, dr) &+ words[roundRight[step]] &+ constantsRight[round], by: shiftsRight[step]) &+ er
ar = er; er = dr; dr = rotate(cr, by: 10); cr = br; br = leftTemp
}
let temp = h1 &+ cl &+ dr
h1 = h2 &+ dl &+ er
h2 = h3 &+ el &+ ar
h3 = h4 &+ al &+ br
h4 = h0 &+ bl &+ cr
h0 = temp
offset += 64
}
var digest: [UInt8] = []
for word in [h0, h1, h2, h3, h4] {
digest.append(UInt8(word & 0xff))
digest.append(UInt8((word >> 8) & 0xff))
digest.append(UInt8((word >> 16) & 0xff))
digest.append(UInt8((word >> 24) & 0xff))
}
return digest
}
private static func f(_ round: Int, _ x: UInt32, _ y: UInt32, _ z: UInt32) -> UInt32 {
switch round {
case 0: return x ^ y ^ z
case 1: return (x & y) | (~x & z)
case 2: return (x | ~y) ^ z
case 3: return (x & z) | (y & ~z)
default: return x ^ (y | ~z)
}
}
private static func rotate(_ value: UInt32, by amount: UInt32) -> UInt32 {
(value << amount) | (value >> (32 - amount))
}
}
+116
View File
@@ -0,0 +1,116 @@
import Foundation
import Security
/// Small Keychain wrapper for wallet secrets. Secrets never touch UserDefaults.
enum Keychain {
private static let service = "com.koins.app"
static func set(_ value: String, for key: String) {
guard let data = value.data(using: .utf8) else { return }
remove(key)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
SecItemAdd(query as CFDictionary, nil)
}
static func get(_ key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data,
let value = String(data: data, encoding: .utf8) else {
return nil
}
return value
}
static func remove(_ key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
SecItemDelete(query as CFDictionary)
}
}
enum WalletStore {
private static let defaults = UserDefaults.standard
private static func walletKey(_ chain: Chain) -> String { "koins.wallet.\(chain.rawValue)" }
private static func secretKey(_ chain: Chain) -> String { "koins.secret.\(chain.rawValue)" }
static func wallet(_ chain: Chain) -> WalletRecord? {
guard let data = defaults.data(forKey: walletKey(chain)) else { return nil }
return try? JSONDecoder().decode(WalletRecord.self, from: data)
}
static func saveWallet(_ wallet: WalletRecord, chain: Chain) {
guard let data = try? JSONEncoder().encode(wallet) else { return }
defaults.set(data, forKey: walletKey(chain))
}
static func removeWallet(_ chain: Chain) {
defaults.removeObject(forKey: walletKey(chain))
defaults.removeObject(forKey: cacheKey(chain))
Keychain.remove(secretKey(chain))
}
// MARK: - Cached state (balance, transactions, accounts)
private static func cacheKey(_ chain: Chain) -> String { "koins.cache.\(chain.rawValue)" }
static func cachedState(_ chain: Chain) -> ChainCache? {
guard let data = defaults.data(forKey: cacheKey(chain)) else { return nil }
return try? JSONDecoder().decode(ChainCache.self, from: data)
}
static func saveCachedState(_ state: ChainCache, chain: Chain) {
guard let data = try? JSONEncoder().encode(state) else { return }
defaults.set(data, forKey: cacheKey(chain))
}
// MARK: - Swap orders (kyc.rip)
private static let swapOrdersKey = "koins.swapOrders"
static func swapOrders() -> [SwapOrder] {
guard let data = defaults.data(forKey: swapOrdersKey),
let orders = try? JSONDecoder().decode([SwapOrder].self, from: data) else { return [] }
return orders
}
static func saveSwapOrder(_ order: SwapOrder) {
var orders = swapOrders()
orders.removeAll { $0.id == order.id }
orders.insert(order, at: 0)
guard let data = try? JSONEncoder().encode(orders) else { return }
defaults.set(data, forKey: swapOrdersKey)
}
static func removeSwapOrder(id: String) {
var orders = swapOrders()
orders.removeAll { $0.id == id }
guard let data = try? JSONEncoder().encode(orders) else { return }
defaults.set(data, forKey: swapOrdersKey)
}
static func secret(_ chain: Chain) -> String? {
Keychain.get(secretKey(chain))
}
static func saveSecret(_ secret: String, chain: Chain) {
Keychain.set(secret, for: secretKey(chain))
}
}
+115
View File
@@ -0,0 +1,115 @@
import SwiftUI
/// A stack of account cards, Cake Wallet style: the front card is full size and
/// interactive, the rest peek from behind (scaled down and offset upwards).
/// Drag the front card sideways or down to send it to the back of the deck.
struct AccountStack: View {
var accounts: [MoneroAccount]
/// Converts an account's XMR amount to the chosen fiat currency, if a price
/// is available.
var fiat: (String) -> String?
var onQR: (MoneroAccount) -> Void
@State private var frontIndex = 0
@State private var dragOffset = CGSize.zero
private let maxDepth = 3
var body: some View {
ZStack(alignment: .top) {
ForEach(Array(accounts.enumerated()), id: \.element.id) { index, account in
let depth = depth(of: index)
accountCard(account, depth: depth)
.offset(x: depth == 0 ? dragOffset.width : 0,
y: depth == 0 ? dragOffset.height : 0)
.gesture(depth == 0 ? frontDrag : nil)
.allowsHitTesting(depth == 0)
.zIndex(Double(accounts.count - depth))
}
}
.frame(height: 190 + CGFloat(min(accounts.count - 1, maxDepth)) * 12)
.accessibilityHint("Drag the front card to cycle accounts")
}
// MARK: - Layout
/// How far behind the front an account sits (0 = front).
private func depth(of index: Int) -> Int {
(index - frontIndex + accounts.count) % accounts.count
}
private func accountCard(_ account: MoneroAccount, depth: Int) -> some View {
Card {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text(account.title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
Spacer()
Text("\(depth + 1) of \(accounts.count)")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
VStack(alignment: .leading, spacing: 3) {
Text("\(account.balance) XMR")
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundStyle(Theme.text)
if let fiat = fiat(account.balance) {
Text(fiat)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
}
if account.unlocked != account.balance {
Text("\(account.unlocked) XMR unlocked")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
HStack(spacing: 8) {
Text(account.address)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Theme.muted)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 6)
Button {
onQR(account)
} label: {
Image(systemName: "qrcode")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.accent)
.frame(width: 34, height: 34)
.background(Theme.surfaceAlt)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
}
}
// Cards behind the front one peek from the top, slightly smaller.
.scaleEffect(1 - 0.045 * CGFloat(min(depth, maxDepth)))
.offset(y: -CGFloat(min(depth, maxDepth)) * 12)
.opacity(depth > maxDepth ? 0 : 1)
}
// MARK: - Interaction
private var frontDrag: some Gesture {
DragGesture()
.onChanged { value in
dragOffset = value.translation
}
.onEnded { value in
let threshold: CGFloat = 90
let shouldAdvance = abs(value.translation.width) > threshold
|| value.translation.height > threshold
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
dragOffset = .zero
if shouldAdvance {
frontIndex = (frontIndex + 1) % accounts.count
}
}
}
}
}
+151
View File
@@ -0,0 +1,151 @@
import SwiftUI
struct AccountsView: View {
@EnvironmentObject private var model: AppModel
@EnvironmentObject private var drawers: DrawerCoordinator
@Binding var route: Route
@State private var error: String?
@State private var busy = false
@State private var showAccountPrompt = false
@State private var showSubaddressPrompt = false
@State private var pendingLabel = ""
@State private var subaddressAccount = 0
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: "Accounts & addresses",
subtitle: "Each address gets its own QR code",
onBack: { route = .home })
PrimaryButton(title: busy ? "Working…" : "New account", disabled: busy) {
showAccountPrompt = true
}
if let error {
MessageBanner(message: error, isError: true)
}
ForEach(model.moneroAccounts) { account in
accountCard(account)
}
if model.moneroAccounts.isEmpty {
Text("No accounts yet. Accounts and addresses appear after the wallet opens.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
}
.task(id: model.moneroReady) { await model.loadAccounts() }
.alert("Account name", isPresented: $showAccountPrompt) {
TextField("e.g. Savings", text: $pendingLabel)
Button("Create") { createAccount() }
Button("Cancel", role: .cancel) { pendingLabel = "" }
}
.alert("Address label", isPresented: $showSubaddressPrompt) {
TextField("e.g. From Alice", text: $pendingLabel)
Button("Create") { createSubaddress() }
Button("Cancel", role: .cancel) { pendingLabel = "" }
}
}
private func accountCard(_ account: MoneroAccount) -> some View {
Card {
VStack(alignment: .leading, spacing: 10) {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(account.title)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.text)
Text("\(account.balance) XMR")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
Spacer()
}
addressRow(title: "Primary address",
address: account.address,
isUsed: true)
if !account.subaddresses.isEmpty {
Divider().overlay(Theme.border)
ForEach(account.subaddresses.filter { $0.index != 0 }) { subaddress in
addressRow(title: subaddress.label.isEmpty ? "Address \(subaddress.index)" : subaddress.label,
address: subaddress.address,
isUsed: subaddress.isUsed,
balance: subaddress.balance)
}
}
SecondaryButton(title: "New address in this account") {
subaddressAccount = account.index
pendingLabel = ""
showSubaddressPrompt = true
}
}
}
}
private func addressRow(title: String, address: String, isUsed: Bool, balance: String? = nil) -> some View {
Button {
drawers.qr = AddressQR(title: title, address: address)
} label: {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Text(address.isEmpty ? "—" : address)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Theme.text)
.lineLimit(1)
.truncationMode(.middle)
if let balance {
Text("\(balance) XMR\(isUsed ? "" : " · unused")")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
Spacer(minLength: 6)
Image(systemName: "qrcode")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Theme.accent)
.frame(width: 32, height: 32)
.background(Theme.surfaceAlt)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(address.isEmpty)
}
private func createAccount() {
busy = true
error = nil
Task {
defer { busy = false }
do {
try await model.addAccount(label: pendingLabel)
pendingLabel = ""
} catch {
self.error = error.localizedDescription
}
}
}
private func createSubaddress() {
busy = true
error = nil
Task {
defer { busy = false }
do {
try await model.addSubaddress(accountIndex: subaddressAccount, label: pendingLabel)
pendingLabel = ""
} catch {
self.error = error.localizedDescription
}
}
}
}
+46
View File
@@ -0,0 +1,46 @@
import SwiftUI
struct AddressQR: Identifiable {
let id = UUID()
let title: String
let address: String
}
/// One address as a scannable QR, shown inside the shared bottom drawer.
struct AddressQRSheet: View {
var title: String
var address: String
var onDone: () -> Void
@State private var copied = false
var body: some View {
VStack(spacing: 16) {
Text(title)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Theme.text)
.frame(maxWidth: .infinity, alignment: .leading)
QRCodeView(text: address)
.frame(maxWidth: 220)
Text(address)
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(Theme.muted)
.multilineTextAlignment(.center)
.lineLimit(4)
.truncationMode(.middle)
.textSelection(.enabled)
SecondaryButton(title: copied ? "Copied" : "Copy address") {
UIPasteboard.general.string = address
copied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false }
}
PrimaryButton(title: "Done", action: onDone)
}
.padding(.horizontal, 22)
.preferredColorScheme(.dark)
}
}
+101
View File
@@ -0,0 +1,101 @@
import SwiftUI
import UIKit
extension View {
/// Presents a bottom-anchored drawer: it fills to the screen's bottom edge
/// (no gap), shows a dimmed backdrop, and can be dragged down to dismiss.
func bottomDrawer<Content: View>(isPresented: Binding<Bool>,
@ViewBuilder content: @escaping () -> Content) -> some View {
BottomDrawerHost(host: self, isPresented: isPresented, content: content)
}
}
/// One place for the drawers shown above every screen. Attaching them at the
/// root keeps them anchored to the window instead of to scrolled content.
@MainActor
final class DrawerCoordinator: ObservableObject {
@Published var transaction: TransactionRecord?
@Published var qr: AddressQR?
@Published var settings = false
}
private struct BottomDrawerHost<Host: View, Content: View>: View {
let host: Host
@Binding var isPresented: Bool
@ViewBuilder var content: () -> Content
@State private var dragOffset: CGFloat = 0
private var bottomInset: CGFloat {
for scene in UIApplication.shared.connectedScenes {
guard let windowScene = scene as? UIWindowScene else { continue }
if let window = windowScene.keyWindow {
return window.safeAreaInsets.bottom
}
}
return 0
}
private var maxHeight: CGFloat {
for scene in UIApplication.shared.connectedScenes {
guard let windowScene = scene as? UIWindowScene else { continue }
if let window = windowScene.keyWindow {
return window.bounds.height * 0.92
}
}
return 800
}
var body: some View {
ZStack(alignment: .bottom) {
host
if isPresented {
Color.black.opacity(0.5)
.ignoresSafeArea()
.onTapGesture { dismiss() }
.transition(.opacity)
VStack(spacing: 0) {
Capsule()
.fill(Theme.muted.opacity(0.55))
.frame(width: 36, height: 5)
.padding(.top, 8)
content()
.padding(.top, 14)
.padding(.bottom, 16)
}
.frame(maxWidth: .infinity, alignment: .top)
.frame(maxHeight: maxHeight)
.background(Theme.surface)
.clipShape(UnevenRoundedRectangle(topLeadingRadius: 20, topTrailingRadius: 20, style: .continuous))
// Push the panel's background down to the physical screen edge without
// taking the host content out of its safe area.
.offset(y: dragOffset + bottomInset)
.gesture(dismissDrag)
.transition(.move(edge: .bottom))
}
}
.animation(.spring(response: 0.32, dampingFraction: 0.86), value: isPresented)
}
private func dismiss() {
isPresented = false
dragOffset = 0
}
private var dismissDrag: some Gesture {
DragGesture()
.onChanged { value in
dragOffset = max(0, value.translation.height)
}
.onEnded { value in
if value.translation.height > 120 || value.predictedEndTranslation.height > 200 {
dismiss()
} else {
dragOffset = 0
}
}
}
}
+378
View File
@@ -0,0 +1,378 @@
import CoreImage.CIFilterBuiltins
import SwiftUI
enum Theme {
static let background = Color(red: 0.035, green: 0.035, blue: 0.05)
static let surface = Color(red: 0.09, green: 0.09, blue: 0.12)
static let surfaceAlt = Color(red: 0.13, green: 0.13, blue: 0.17)
static let border = Color.white.opacity(0.08)
static let accent = Color(red: 1.0, green: 0.56, blue: 0.16)
static let accentSoft = Color(red: 1.0, green: 0.56, blue: 0.16).opacity(0.15)
static let text = Color.white
static let muted = Color.white.opacity(0.55)
static let danger = Color(red: 1.0, green: 0.36, blue: 0.36)
static let success = Color(red: 0.35, green: 0.85, blue: 0.55)
}
struct PrimaryButton: View {
var title: String
var disabled: Bool = false
var action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.system(size: 16, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 15)
.background(disabled ? Theme.surfaceAlt : Theme.accent)
.foregroundStyle(disabled ? Theme.muted : Color.black)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.disabled(disabled)
}
}
struct SecondaryButton: View {
var title: String
var action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.system(size: 15, weight: .medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Theme.surface)
.foregroundStyle(Theme.text)
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
}
/// A full-width button that opens a web page, styled like SecondaryButton.
struct LinkButton: View {
var title: String
var systemImage: String = "safari"
var url: URL
var body: some View {
Link(destination: url) {
Label(title, systemImage: systemImage)
.font(.system(size: 15, weight: .medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Theme.surface)
.foregroundStyle(Theme.text)
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
}
struct Card<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
content
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Theme.surface)
.overlay(RoundedRectangle(cornerRadius: 20, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
}
}
struct FieldLabel: View {
var text: String
var body: some View {
Text(text)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
.padding(.leading, 4)
}
}
struct KoinsTextField: View {
var placeholder: String
@Binding var text: String
var multiline = false
var keyboard: UIKeyboardType = .default
var autocapitalization: TextInputAutocapitalization = .never
var body: some View {
Group {
if multiline {
TextEditor(text: $text)
.scrollContentBackground(.hidden)
.frame(minHeight: 96)
.padding(8)
} else {
TextField(placeholder, text: $text)
.keyboardType(keyboard)
.textInputAutocapitalization(autocapitalization)
.autocorrectionDisabled()
.padding(.horizontal, 14)
.padding(.vertical, 13)
}
}
.font(.system(size: 15))
.foregroundStyle(Theme.text)
.background(Theme.surface)
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
struct MessageBanner: View {
var message: String
var isError: Bool
var body: some View {
Text(message)
.font(.system(size: 13))
.foregroundStyle(isError ? Theme.danger : Theme.success)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background((isError ? Theme.danger : Theme.success).opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
struct ChainPicker: View {
@Binding var selection: Chain
var body: some View {
HStack(spacing: 6) {
ForEach(Chain.allCases, id: \.self) { chain in
Button {
selection = chain
} label: {
Text(chain.title)
.font(.system(size: 13, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(selection == chain ? Theme.accent : Color.clear)
.foregroundStyle(selection == chain ? Color.black : Theme.muted)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
}
.padding(4)
.background(Theme.surface)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
/// Picks which Monero account a payment leaves from. Each account has its own
/// balance, so "Max" and the wallet's spend checks need the matching index.
struct MoneroAccountPicker: View {
var accounts: [MoneroAccount]
@Binding var selection: Int
var onSelect: (() -> Void)?
private var selected: MoneroAccount? {
accounts.first(where: { $0.index == selection }) ?? accounts.first
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: "Send from")
Menu {
ForEach(accounts) { account in
Button {
selection = account.index
onSelect?()
} label: {
Text("\(account.title) · \(account.balance) XMR")
}
}
} label: {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text(selected?.title ?? "Primary account")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(Theme.text)
Text("\(selected?.unlocked ?? "0") XMR available")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
Spacer()
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.muted)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(Theme.surface)
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
}
}
struct QRCodeView: View {
var text: String
var body: some View {
if let image = QRCodeView.makeImage(from: text) {
Image(uiImage: image)
.interpolation(.none)
.resizable()
.scaledToFit()
.padding(12)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
} else {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Theme.surfaceAlt)
.aspectRatio(1, contentMode: .fit)
}
}
private static func makeImage(from text: String) -> UIImage? {
QRCodeImage.make(text)
}
}
struct TransactionRow: View {
var record: TransactionRecord
var symbol: String
private var subtitle: String {
if let timestamp = record.timestamp {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
return record.confirmed ? formatter.string(from: timestamp) : "Pending confirmation"
}
// Monero transfers carry no timestamp; the block height is the honest label.
if let height = record.height, height > 0 {
return record.confirmed ? "Block \(height)" : "In the transaction pool"
}
return record.confirmed ? "Confirmed" : "Pending"
}
var body: some View {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(record.direction == .incoming ? Theme.success.opacity(0.15) : Theme.accentSoft)
.frame(width: 38, height: 38)
Image(systemName: record.direction == .incoming ? "arrow.down.left" : "arrow.up.right")
.font(.system(size: 15, weight: .bold))
.foregroundStyle(record.direction == .incoming ? Theme.success : Theme.accent)
}
VStack(alignment: .leading, spacing: 3) {
Text(record.direction == .incoming ? "Received" : "Sent")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Theme.text)
Text(subtitle)
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
Spacer()
VStack(alignment: .trailing, spacing: 3) {
Text("\(record.direction == .incoming ? "+" : "-")\(record.amount) \(symbol)")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(record.direction == .incoming ? Theme.success : Theme.text)
if let fee = record.fee, !fee.isEmpty, record.direction == .outgoing {
Text("fee \(fee)")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
}
.padding(.vertical, 6)
// Buttons only respond where the label has content; the middle of the row
// is a Spacer, so without this the gap does not accept taps.
.contentShape(Rectangle())
}
}
struct ScreenHeader: View {
var title: String
var subtitle: String?
var onBack: (() -> Void)?
var body: some View {
HStack(alignment: .center, spacing: 12) {
if let onBack {
Button(action: onBack) {
Image(systemName: "chevron.left")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Theme.text)
.frame(width: 34, height: 34)
.background(Theme.surface)
.clipShape(Circle())
}
}
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.system(size: 22, weight: .bold))
.foregroundStyle(Theme.text)
if let subtitle {
Text(subtitle)
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
}
Spacer()
}
}
}
/// Monero's chain sync as its own section, rather than buried in the wallet card.
struct SyncCard: View {
@EnvironmentObject private var model: AppModel
var body: some View {
Card {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Sync")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
Spacer()
if let status = model.syncStatus { SyncBadge(status: status) }
}
if let status = model.syncStatus {
SyncProgress(status: status, eta: model.syncEta)
} else {
Text("Sync status is not available yet.")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
SecondaryButton(title: model.syncStatus?.syncing == true ? "Syncing…" : "Sync now") {
Task { await model.startMoneroSync() }
}
}
}
}
}
/// A small circular icon button for utility actions (refresh, delete, …).
struct IconButton: View {
var systemName: String
var destructive = false
var action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(destructive ? Theme.danger : Theme.muted)
.frame(width: 42, height: 42)
.background(Theme.surface)
.overlay(Circle().stroke(Theme.border))
.clipShape(Circle())
}
}
}
+337
View File
@@ -0,0 +1,337 @@
import PhotosUI
import SwiftUI
struct DashboardView: View {
@EnvironmentObject private var model: AppModel
@EnvironmentObject private var drawers: DrawerCoordinator
@Binding var route: Route
@State private var showSeed = false
@State private var revealedSeed: String?
@State private var seedError: String?
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: model.wallet?.name ?? "Wallet",
subtitle: model.chain.title,
onBack: { route = .home })
Card {
VStack(alignment: .leading, spacing: 10) {
Text("Balance")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Theme.muted)
Text("\(model.balance.total) \(model.symbol)")
.font(.system(size: 32, weight: .bold, design: .rounded))
.foregroundStyle(Theme.text)
if let fiat = model.fiatBalance {
Text("\(fiat)")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Theme.muted)
}
if model.chain == .xmr, model.balance.unlocked != model.balance.total {
Text("\(model.balance.unlocked) \(model.symbol) spendable now")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
AddressRow(address: model.displayAddress)
}
}
if model.chain == .xmr {
Card {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Sync")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.text)
Spacer()
if let status = model.syncStatus { SyncBadge(status: status) }
}
if let status = model.syncStatus {
SyncProgress(status: status, eta: model.syncEta)
} else {
Text("Sync status is not available yet.")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
}
}
HStack(spacing: 10) {
PrimaryButton(title: "Send") { route = .send }
SecondaryButton(title: "Receive") { route = .receive }
}
if !model.transactions.isEmpty {
Card {
VStack(alignment: .leading, spacing: 10) {
Text("Activity")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
ForEach(Array(model.transactions.enumerated()), id: \.offset) { index, record in
Button {
drawers.transaction = record
} label: {
TransactionRow(record: record, symbol: model.symbol)
}
.buttonStyle(.plain)
if index < model.transactions.count - 1 {
Divider().overlay(Theme.border)
}
}
}
}
}
if let message = model.errorMessage {
MessageBanner(message: message, isError: true)
}
if let message = model.statusMessage {
MessageBanner(message: message, isError: false)
}
SecondaryButton(title: "Reveal recovery phrase") {
Task {
seedError = nil
do {
revealedSeed = try await model.revealSeed()
showSeed = true
} catch {
seedError = error.localizedDescription
}
}
}
if let seedError {
MessageBanner(message: seedError, isError: true)
}
}
.sheet(isPresented: $showSeed) {
if let revealedSeed {
SeedRevealView(seed: revealedSeed, symbol: model.symbol) { showSeed = false }
}
}
}
}
struct SendView: View {
@EnvironmentObject private var model: AppModel
@Binding var route: Route
@State private var destination = ""
@State private var amount = ""
@State private var tag = ""
@State private var priority: TransactionPriority = .defaultPriority
@State private var accountIndex = 0
@State private var accountPicked = false
@State private var localError: String?
@State private var status: String?
@State private var showScanner = false
@State private var photoItem: PhotosPickerItem?
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: "Send \(model.symbol)",
subtitle: "Balance \(model.balance.total) \(model.symbol)",
onBack: { route = .home })
HStack(spacing: 10) {
SecondaryButton(title: "Scan QR") { showScanner = true }
PhotosPicker(selection: $photoItem, matching: .images, photoLibrary: .shared()) {
Text("Photo")
.font(.system(size: 15, weight: .medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Theme.surface)
.foregroundStyle(Theme.text)
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
if model.chain == .xmr, model.moneroAccounts.count > 1 {
MoneroAccountPicker(accounts: model.moneroAccounts,
selection: $accountIndex,
onSelect: { accountPicked = true })
}
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: model.chain == .xrp ? "Destination address" : "Recipient address")
KoinsTextField(placeholder: model.chain == .xrp ? "r…" : "4…", text: $destination)
}
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: "Amount (\(model.symbol))")
KoinsTextField(placeholder: "0.0", text: $amount, keyboard: .decimalPad)
}
if model.chain == .xrp {
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: "Destination tag (optional)")
KoinsTextField(placeholder: "e.g. 12345", text: $tag, keyboard: .numberPad)
}
}
if model.chain == .xmr {
VStack(alignment: .leading, spacing: 8) {
FieldLabel(text: "Priority")
Picker("Priority", selection: $priority) {
ForEach([TransactionPriority.low, .medium, .high, .defaultPriority], id: \.rawValue) { item in
Text(item.label).tag(item)
}
}
.pickerStyle(.segmented)
}
}
if let message = localError ?? model.errorMessage {
MessageBanner(message: message, isError: true)
}
if let message = status {
MessageBanner(message: message, isError: false)
}
if let message = model.statusMessage {
MessageBanner(message: message, isError: false)
}
PrimaryButton(title: model.isBusy ? "Sending…" : "Send", disabled: model.isBusy) {
Task {
localError = nil
model.clearMessages()
do {
try await model.send(to: destination,
amount: amount,
tag: tag,
priority: priority,
accountIndex: accountIndex)
amount = ""
tag = ""
route = .dashboard
} catch {
localError = error.localizedDescription
}
}
}
Spacer(minLength: 0)
}
.sheet(isPresented: $showScanner) {
QRScannerView(onScan: { value in
showScanner = false
applyScanned(value)
}, onCancel: {
showScanner = false
})
.ignoresSafeArea()
}
.onChange(of: photoItem) { item in
guard let item else { return }
Task {
if let data = try? await item.loadTransferable(type: Data.self) {
if let value = QRCodeReader.decodeText(in: data) {
applyScanned(value)
} else {
localError = "No QR code found in that image"
}
} else {
localError = "That image could not be read"
}
photoItem = nil
}
}
.onAppear {
if !accountPicked { accountIndex = model.defaultMoneroAccountIndex }
}
.onChange(of: model.moneroAccounts.count) { _ in
if !accountPicked { accountIndex = model.defaultMoneroAccountIndex }
}
}
/// Fills the form from a scanned code, accepting both plain addresses and
/// wallet payment URIs (`monero:…?tx_amount=`, `ripple:…?amount=&dt=`).
private func applyScanned(_ value: String) {
localError = nil
status = nil
guard let payload = QRCodeReader.parse(value) else {
localError = "That QR code is not a payment request"
return
}
let isForThisChain = model.chain == .xrp
? AddressFormat.isXRP(payload.address)
: AddressFormat.isMonero(payload.address)
guard isForThisChain else {
localError = model.chain == .xrp
? "That looks like a Monero address. Switch to Monero to use it."
: "That looks like an XRP address. Switch to XRP to use it."
return
}
destination = payload.address
if let scannedAmount = payload.amount, !scannedAmount.isEmpty {
amount = scannedAmount
}
if model.chain == .xrp, let scannedTag = payload.tag {
tag = String(scannedTag)
}
var filled: [String] = ["address"]
if payload.amount?.isEmpty == false { filled.append("amount") }
if payload.tag != nil { filled.append("tag") }
if payload.paymentId?.isEmpty == false { filled.append("payment ID") }
status = "Filled \(filled.joined(separator: ", ")) from the QR code"
}
}
struct ReceiveView: View {
@EnvironmentObject private var model: AppModel
@Binding var route: Route
private var address: String {
model.displayAddress
}
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: "Receive \(model.symbol)",
subtitle: "Share this address to get paid",
onBack: { route = .home })
Card {
VStack(spacing: 16) {
QRCodeView(text: address)
.frame(maxWidth: 240)
Text(address)
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(Theme.muted)
.multilineTextAlignment(.center)
.lineLimit(3)
.truncationMode(.middle)
}
.frame(maxWidth: .infinity)
}
SecondaryButton(title: "Copy address") {
UIPasteboard.general.string = address
}
if model.chain == .xrp {
Card {
Text("XRP accounts must hold at least 1 XRP as a reserve to be active. Only send XRP on the XRP Ledger to this address.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
} else {
Card {
Text("Send only Monero (XMR) to this address. Transfers need confirmations before the balance becomes spendable.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
}
Spacer(minLength: 0)
}
}
}
+30
View File
@@ -0,0 +1,30 @@
import SwiftUI
/// Full-screen biometric lock, overlaid on the app whenever `model.locked`.
struct LockScreen: View {
var unlock: () -> Void
var body: some View {
ZStack {
Theme.background.ignoresSafeArea()
VStack(spacing: 16) {
Spacer()
Image(systemName: "lock.shield")
.font(.system(size: 44, weight: .light))
.foregroundStyle(Theme.accent)
Text("Koins is locked")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(Theme.text)
Text("Authenticate to continue")
.font(.system(size: 13))
.foregroundStyle(Theme.muted)
Spacer()
PrimaryButton(title: "Unlock with \(Biometrics.label)", action: unlock)
Spacer()
}
.padding(24)
}
.preferredColorScheme(.dark)
.task { unlock() }
}
}
+229
View File
@@ -0,0 +1,229 @@
import AVFoundation
import SwiftUI
import UIKit
/// Camera QR scanner, presented as a sheet.
///
/// Notes on the shape of this code, since a black preview is easy to ship:
/// the preview layer is only created once the camera is authorised, which can
/// happen *after* the last layout pass — so its frame is applied at creation
/// time as well as in `viewDidLayoutSubviews`, otherwise it stays zero-sized
/// and renders as a black rectangle.
struct QRScannerView: UIViewControllerRepresentable {
var onScan: (String) -> Void
var onCancel: () -> Void
func makeUIViewController(context: Context) -> ScannerViewController {
let controller = ScannerViewController()
controller.onScan = onScan
controller.onCancel = onCancel
return controller
}
func updateUIViewController(_ controller: ScannerViewController, context: Context) {}
}
final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
var onScan: ((String) -> Void)?
var onCancel: (() -> Void)?
private let session = AVCaptureSession()
private let sessionQueue = DispatchQueue(label: "com.koins.qr.session")
private var previewLayer: AVCaptureVideoPreviewLayer?
private var isConfigured = false
private var hasScanned = false
private let statusLabel = UILabel()
private let settingsButton = UIButton(type: .system)
private let viewfinder = UIView()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
let cancel = UIButton(type: .system)
cancel.setTitle("Cancel", for: .normal)
cancel.setTitleColor(.white, for: .normal)
cancel.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
cancel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(cancel)
viewfinder.layer.borderColor = UIColor.white.withAlphaComponent(0.6).cgColor
viewfinder.layer.borderWidth = 2
viewfinder.layer.cornerRadius = 18
viewfinder.isUserInteractionEnabled = false
viewfinder.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(viewfinder)
statusLabel.numberOfLines = 0
statusLabel.textAlignment = .center
statusLabel.textColor = .white
statusLabel.font = .systemFont(ofSize: 14)
statusLabel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(statusLabel)
settingsButton.setTitle("Open Settings", for: .normal)
settingsButton.setTitleColor(.systemOrange, for: .normal)
settingsButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold)
settingsButton.addTarget(self, action: #selector(openSettings), for: .touchUpInside)
settingsButton.isHidden = true
settingsButton.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(settingsButton)
NSLayoutConstraint.activate([
cancel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
cancel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
viewfinder.centerXAnchor.constraint(equalTo: view.centerXAnchor),
viewfinder.centerYAnchor.constraint(equalTo: view.centerYAnchor),
viewfinder.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.68),
viewfinder.heightAnchor.constraint(equalTo: viewfinder.widthAnchor),
statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32),
statusLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32),
settingsButton.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 16),
settingsButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
])
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
begin()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// Keeps the preview sized even if it was created after the last layout pass.
previewLayer?.frame = view.layer.bounds
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
sessionQueue.async { [session] in
if session.isRunning { session.stopRunning() }
}
}
@objc private func cancelTapped() {
onCancel?()
}
@objc private func openSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
// MARK: - Session
private func begin() {
guard AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) != nil else {
showHelp("This device has no camera. Use “Photo” to read a QR from your library.")
return
}
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
start()
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
DispatchQueue.main.async {
guard let self else { return }
if granted {
self.start()
} else {
self.showHelp("Camera access is off. Turn it on in Settings, or use “Photo”.")
self.settingsButton.isHidden = false
}
}
}
default:
showHelp("Camera access is off. Turn it on in Settings, or use “Photo”.")
settingsButton.isHidden = false
}
}
private func start() {
statusLabel.isHidden = true
settingsButton.isHidden = true
if !isConfigured { configure() }
guard isConfigured else { return }
sessionQueue.async { [session] in
if !session.isRunning { session.startRunning() }
}
}
private func configure() {
session.beginConfiguration()
session.sessionPreset = .high
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else {
session.commitConfiguration()
showHelp("The camera could not be started. Use “Photo” instead.")
return
}
session.addInput(input)
let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else {
session.commitConfiguration()
showHelp("The camera could not be started. Use “Photo” instead.")
return
}
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main)
// Reading a type that is not available raises an Objective-C exception, so
// check first (it is only populated once the session has an input).
if output.availableMetadataObjectTypes.contains(.qr) {
output.metadataObjectTypes = [.qr]
} else {
session.commitConfiguration()
showHelp("This camera cannot read QR codes.")
return
}
session.commitConfiguration()
let layer = AVCaptureVideoPreviewLayer(session: session)
layer.videoGravity = .resizeAspectFill
layer.frame = view.layer.bounds
view.layer.insertSublayer(layer, at: 0)
previewLayer = layer
isConfigured = true
view.setNeedsLayout()
NotificationCenter.default.addObserver(self,
selector: #selector(sessionFailed),
name: .AVCaptureSessionRuntimeError,
object: session)
}
@objc private func sessionFailed(_ note: Notification) {
showHelp("The camera stopped unexpectedly. Close and try again.")
}
private func showHelp(_ text: String) {
statusLabel.text = text
statusLabel.isHidden = false
viewfinder.isHidden = true
}
// MARK: - Detection
func metadataOutput(_ output: AVCaptureMetadataOutput,
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection) {
guard !hasScanned else { return }
guard let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
let value = object.stringValue, !value.isEmpty else { return }
hasScanned = true
sessionQueue.async { [session] in
if session.isRunning { session.stopRunning() }
}
UINotificationFeedbackGenerator().notificationOccurred(.success)
onScan?(value)
}
}
+453
View File
@@ -0,0 +1,453 @@
import SwiftUI
enum Route: Equatable {
case home
case create
case importWallet
case dashboard
case send
case receive
case accounts
case swap
}
struct SeedBox: Identifiable {
let id = UUID()
let seed: String
let symbol: String
}
struct RootView: View {
@EnvironmentObject private var model: AppModel
@Environment(\.scenePhase) private var scenePhase
@State private var route: Route = RootView.initialRoute
private static var initialRoute: Route {
let arguments = ProcessInfo.processInfo.arguments
if arguments.contains("-koinsScreenAccounts") { return .accounts }
if arguments.contains("-koinsScreenSend") { return .send }
if arguments.contains("-koinsScreenReceive") { return .receive }
if arguments.contains("-koinsScreenSwap") { return .swap }
return .home
}
@State private var seedBox: SeedBox?
@StateObject private var drawers = DrawerCoordinator()
var body: some View {
ZStack {
Theme.background.ignoresSafeArea()
ScrollView {
VStack(spacing: 18) {
switch route {
case .home:
HomeView(route: $route)
case .create:
CreateWalletView(route: $route, seedBox: $seedBox)
case .importWallet:
ImportWalletView(route: $route)
case .dashboard:
DashboardView(route: $route)
case .send:
SendView(route: $route)
case .receive:
ReceiveView(route: $route)
case .accounts:
AccountsView(route: $route)
case .swap:
SwapView(route: $route)
}
}
.padding(.horizontal, 20)
.padding(.top, 12)
.padding(.bottom, 40)
}
.scrollDismissesKeyboard(.interactively)
}
.preferredColorScheme(.dark)
.task { await model.bootstrap() }
.sheet(item: $seedBox) { box in
SeedRevealView(seed: box.seed, symbol: box.symbol) { seedBox = nil }
}
.bottomDrawer(isPresented: Binding(
get: { drawers.transaction != nil },
set: { if !$0 { drawers.transaction = nil } }
)) {
if let record = drawers.transaction {
TransactionDetailView(record: record,
symbol: model.symbol,
chain: model.chain) { drawers.transaction = nil }
}
}
.bottomDrawer(isPresented: Binding(
get: { drawers.qr != nil },
set: { if !$0 { drawers.qr = nil } }
)) {
if let item = drawers.qr {
AddressQRSheet(title: item.title, address: item.address) { drawers.qr = nil }
}
}
.bottomDrawer(isPresented: $drawers.settings) {
SettingsView()
}
.environmentObject(drawers)
// Above the drawers so a lock always hides whatever was on screen.
.overlay {
if model.locked, model.faceIDEnabled {
LockScreen {
Task { await model.authenticate() }
}
}
}
.onChange(of: model.chain) { _ in
if route == .dashboard { route = .home }
}
.onChange(of: scenePhase) { phase in
if phase == .background, model.faceIDEnabled, Biometrics.isAvailable {
model.locked = true
}
if phase != .active {
Task { await model.persistWallet() }
}
}
}
}
struct HomeView: View {
@EnvironmentObject private var model: AppModel
@EnvironmentObject private var drawers: DrawerCoordinator
@Binding var route: Route
var body: some View {
VStack(spacing: 18) {
header
if model.hasWallet, model.chain == .xrp || model.moneroReady {
walletCard
} else if model.hasWallet {
moneroUnavailableCard
} else {
emptyState
}
if let message = model.errorMessage {
MessageBanner(message: message, isError: true)
}
if let message = model.statusMessage {
MessageBanner(message: message, isError: false)
}
}
}
private var header: some View {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text("Koins")
.font(.system(size: 30, weight: .heavy))
.foregroundStyle(Theme.text)
Text("Monero and XRP wallet")
.font(.system(size: 13))
.foregroundStyle(Theme.muted)
}
Spacer()
Button {
drawers.settings = true
} label: {
Image(systemName: "gearshape")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(Theme.muted)
.frame(width: 36, height: 36)
.background(Theme.surface)
.clipShape(Circle())
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var walletCard: some View {
VStack(spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(model.wallet?.name ?? "Wallet")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Theme.text)
Text(model.chain.title)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Theme.muted)
}
Spacer()
}
VStack(alignment: .leading, spacing: 4) {
Text("Balance")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Theme.muted)
Text("\(model.balance.total) \(model.symbol)")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(Theme.text)
if let fiat = model.fiatBalance {
Text("\(fiat)")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Theme.muted)
}
if model.chain == .xmr, model.balance.unlocked != model.balance.total {
Text("\(model.balance.unlocked) \(model.symbol) unlocked")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
if model.chain == .xrp {
AddressRow(address: model.displayAddress)
}
}
}
if model.chain == .xmr, !model.moneroAccounts.isEmpty {
// Cake Wallet style: a deck of account cards as its own section, drag
// the front card away to bring the next one up.
AccountStack(accounts: model.moneroAccounts,
fiat: { model.fiatValue(of: $0, chain: .xmr) },
onQR: { drawers.qr = AddressQR(title: $0.title, address: $0.address) })
}
if model.chain == .xmr {
SyncCard()
}
HStack(spacing: 10) {
PrimaryButton(title: "Send") { route = .send }
SecondaryButton(title: "Receive") { route = .receive }
SecondaryButton(title: "Swap") { route = .swap }
}
HStack(spacing: 12) {
IconButton(systemName: "arrow.clockwise") { Task { await model.refresh(force: true) } }
if model.chain == .xmr {
IconButton(systemName: "person.2.fill") { route = .accounts }
}
Spacer()
}
if model.chain == .xrp, model.moneroReady, let status = model.syncStatus, status.syncing {
Card {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Circle()
.fill(status.connected ? Theme.success : Theme.danger)
.frame(width: 7, height: 7)
Text("Monero is syncing in the background")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.text)
Spacer()
Text("\(Int(status.progress * 100))%")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.muted)
}
SyncProgress(status: status, eta: model.syncEta)
}
}
}
if !model.transactions.isEmpty {
Card {
VStack(alignment: .leading, spacing: 10) {
Text("Activity")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
// A Monero transaction can produce several transfer entries with the
// same hash, so index-based identities keep the rows stable.
ForEach(Array(model.transactions.enumerated()), id: \.offset) { index, record in
Button {
drawers.transaction = record
} label: {
TransactionRow(record: record, symbol: model.symbol)
}
.buttonStyle(.plain)
if index < model.transactions.count - 1 {
Divider().overlay(Theme.border)
}
}
}
}
} else if model.chain == .xrp, model.balance.funded == false {
Text("This account is not funded yet. Send XRP to the address above to activate it.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
}
}
private var moneroUnavailableCard: some View {
VStack(spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 10) {
Text("Monero wallet")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Theme.text)
Text("The wallet is on this device but could not be opened. It is safe to retry; nothing was deleted.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
SecondaryButton(title: "Retry") {
Task { await model.select(.xmr) }
}
}
}
}
}
private var emptyState: some View {
VStack(spacing: 14) {
ChainPicker(selection: Binding(get: { model.chain },
set: { newValue in Task { await model.select(newValue) } }))
Card {
VStack(alignment: .leading, spacing: 8) {
Text("\(model.chain.title) wallet")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(Theme.text)
Text("Create a new wallet on this device, or import one with its recovery seed.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
}
VStack(spacing: 10) {
PrimaryButton(title: "Create wallet") { route = .create }
SecondaryButton(title: "Import wallet") { route = .importWallet }
}
}
}
}
struct SyncBadge: View {
var status: SyncStatus
private var label: String {
if status.syncing {
let percent = status.progress * 100
return percent < 10 ? String(format: "Syncing %.1f%%", percent) : "Syncing \(Int(percent))%"
}
return status.connected ? "Synced" : "Offline"
}
var body: some View {
HStack(spacing: 6) {
Circle()
.fill(status.connected ? Theme.success : Theme.danger)
.frame(width: 7, height: 7)
Text(label)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Theme.muted)
}
}
}
/// Live scan progress: how far the wallet has reached, and roughly how long the
/// remaining blocks will take at the observed rate.
struct SyncProgress: View {
var status: SyncStatus
var eta: String?
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ProgressView(value: status.progress)
.tint(Theme.accent)
HStack(spacing: 8) {
Text("Block \(status.height.formatted()) of \(status.daemonHeight.formatted())")
Spacer()
if status.syncing {
Text(eta.map { "about \($0) left" } ?? "estimating…")
} else if status.connected {
Text("up to date")
}
}
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
}
struct AddressRow: View {
var address: String
@State private var copied = false
var body: some View {
HStack(spacing: 8) {
Text(address.isEmpty ? "—" : address)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Theme.muted)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 6)
Button {
UIPasteboard.general.string = address
copied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false }
} label: {
Image(systemName: copied ? "checkmark" : "doc.on.doc")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.accent)
}
.disabled(address.isEmpty)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Theme.surfaceAlt)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
struct SeedRevealView: View {
var seed: String
var symbol: String
var onDone: () -> Void
@State private var acknowledged = false
var body: some View {
ZStack {
Theme.background.ignoresSafeArea()
VStack(alignment: .leading, spacing: 16) {
Text("Your recovery phrase")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(Theme.text)
Text("Write these words down in order and keep them offline. Anyone with this phrase can spend your \(symbol).")
.font(.system(size: 13))
.foregroundStyle(Theme.muted)
Text(seed)
.font(.system(size: 15, design: .monospaced))
.foregroundStyle(Theme.text)
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Theme.surface)
.overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
Button {
UIPasteboard.general.string = seed
} label: {
Label("Copy phrase", systemImage: "doc.on.doc")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Theme.accent)
}
Toggle(isOn: $acknowledged) {
Text("I have written down my phrase")
.font(.system(size: 13))
.foregroundStyle(Theme.muted)
}
.tint(Theme.accent)
Spacer()
PrimaryButton(title: "Continue", disabled: !acknowledged, action: onDone)
}
.padding(22)
}
}
}
+121
View File
@@ -0,0 +1,121 @@
import SwiftUI
struct SettingsView: View {
@EnvironmentObject private var model: AppModel
@EnvironmentObject private var drawers: DrawerCoordinator
@State private var showDeleteConfirm = false
private let currencies = ["USD", "MYR", "JPY"]
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: "Settings",
subtitle: "Security and device options",
onBack: { drawers.settings = false })
Card {
VStack(alignment: .leading, spacing: 10) {
Text("Wallet")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
ChainPicker(selection: Binding(get: { model.chain },
set: { newValue in Task { await model.select(newValue) } }))
}
}
Card {
VStack(alignment: .leading, spacing: 10) {
Text("Currency")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 64), spacing: 8)], spacing: 8) {
ForEach(currencies, id: \.self) { currency in
let selected = model.fiatCurrency == currency
Button {
model.fiatCurrency = currency
} label: {
Text(currency)
.font(.system(size: 13, weight: .medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(selected ? Theme.accent : Theme.surfaceAlt)
.foregroundStyle(selected ? Color.black : Theme.text)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
}
Text("Prices come from CoinGecko (with a Kraken fallback for USD and EUR) and refresh about once a minute.")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
.padding(.top, 4)
}
}
Card {
Toggle(isOn: Binding(get: { model.faceIDEnabled },
set: { model.faceIDEnabled = $0 })) {
VStack(alignment: .leading, spacing: 3) {
Text("Unlock with \(Biometrics.label)")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.text)
Text("Require \(Biometrics.label) to open the app, reveal the recovery phrase, and send payments.")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
.tint(Theme.accent)
.disabled(!Biometrics.isAvailable)
if !Biometrics.isAvailable {
Text("This device has no biometric sensor configured.")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
Card {
VStack(alignment: .leading, spacing: 10) {
Text("Danger zone")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
Button {
showDeleteConfirm = true
} label: {
Text("Delete this wallet")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.danger)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Theme.danger.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
Text("Removes the wallet from this device. Your recovery phrase is the only way to get it back.")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
Spacer(minLength: 0)
}
.padding(.horizontal, 20)
.padding(.top, 12)
}
.preferredColorScheme(.dark)
.confirmationDialog("Delete this wallet?",
isPresented: $showDeleteConfirm,
titleVisibility: .visible) {
Button("Delete wallet", role: .destructive) {
Task {
await model.deleteWallet()
drawers.settings = false
}
}
Button("Cancel", role: .cancel) {}
} message: {
Text("The keys stored on this device are removed. Make sure your recovery phrase is backed up.")
}
}
}
+765
View File
@@ -0,0 +1,765 @@
import SwiftUI
import UIKit
/// Swap between the two wallets via kyc.rip, the public no-KYC aggregator.
struct SwapView: View {
@EnvironmentObject private var model: AppModel
@EnvironmentObject private var drawers: DrawerCoordinator
@Binding var route: Route
@State private var from: Chain = .xrp
@State private var amount = ""
@State private var estimate: SwapEstimate?
@State private var selectedRouteID: String?
@State private var estimating = false
@State private var creating = false
@State private var sendingDeposit = false
@State private var createdOrder: SwapOrder?
@State private var localError: String?
@State private var xmrAccountIndex = 0
@State private var xmrAccountPicked = false
@State private var confirmRemoveOrder = false
private var to: Chain { from == .xrp ? .xmr : .xrp }
private var selectedXmrAccount: MoneroAccount? {
model.moneroAccounts.first(where: { $0.index == xmrAccountIndex }) ?? model.moneroAccounts.first
}
private var fromBalance: Balance {
if from == .xmr, let account = selectedXmrAccount {
return Balance(total: account.balance,
unlocked: account.unlocked,
funded: (Decimal(string: account.balance) ?? 0) > 0)
}
if from == model.chain { return model.balance }
return WalletStore.cachedState(from)?.balance ?? Balance(total: "0", unlocked: "", funded: false)
}
private var fromBalanceText: String {
let value = Decimal(string: fromBalance.total) ?? 0
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.usesGroupingSeparator = false
// Monero has 12 decimals; rounding a balance up here would make "Max"
// exceed what the account can spend.
formatter.maximumFractionDigits = from == .xmr ? 12 : 6
return formatter.string(from: value as NSDecimalNumber) ?? "0"
}
/// Quoted routes worth considering, best payout first.
private var routes: [SwapRoute] {
(estimate?.routes ?? []).filter { $0.amountTo > 0 }.sorted { $0.amountTo > $1.amountTo }
}
private var selectedRoute: SwapRoute? {
routes.first { $0.id == selectedRouteID } ?? routes.first
}
/// One-line summary of everything kyc.rip knows about a route.
private func routeFacts(_ route: SwapRoute) -> String {
var facts: [String] = []
if let eta = route.eta { facts.append("~\(eta) min") }
facts.append(route.fixedRate ? "fixed rate" : "floating rate")
if let spread = route.spread { facts.append(String(format: "spread %+.2f%%", spread)) }
if let kyc = route.kycRating { facts.append("KYC \(kyc)") }
if let log = route.logPolicy { facts.append("logs \(log)") }
if let insurance = route.insurance, insurance > 0 { facts.append("insured \(insurance)%") }
if let min = route.minDeposit { facts.append("min \(trim(min)) \(from.symbol)") }
if let max = route.maxDeposit { facts.append("max \(trim(max)) \(from.symbol)") }
return facts.joined(separator: " · ")
}
private func rowFacts(_ route: SwapRoute) -> String {
var facts: [String] = []
if let spread = route.spread { facts.append(String(format: "%+.2f%%", spread)) }
if let kyc = route.kycRating { facts.append("KYC \(kyc)") }
facts.append(route.fixedRate ? "fixed" : "float")
return facts.joined(separator: " · ")
}
private func trim(_ value: Double) -> String {
String(format: "%.6g", value)
}
// MARK: - Swap status
private func statusTitle(_ status: String) -> String {
switch status.uppercased() {
case "WAITING", "NEW": return "Waiting for your deposit"
case "CONFIRMING": return "Deposit detected"
case "EXCHANGING": return "Exchanging"
case "SENDING": return "Sending to your wallet"
case "FINISHED", "SUCCESS": return "Swap complete"
case "EXPIRED": return "Order expired"
case "FAILED": return "Swap failed"
case "REFUNDED": return "Refunded"
case "OVERDUE": return "Deposit overdue"
default: return "Swap status"
}
}
private func statusDetail(_ status: String, order: SwapOrder) -> String {
switch status.uppercased() {
case "WAITING", "NEW":
return "Send your \(order.fromTicker) deposit to the address below."
case "CONFIRMING":
return "Your \(order.fromTicker) arrived. Waiting for network confirmations."
case "EXCHANGING":
return "The provider is converting \(order.fromTicker) to \(order.toTicker)."
case "SENDING":
return "The provider is sending \(order.toTicker) to your wallet."
case "FINISHED", "SUCCESS":
return "\(order.toTicker) was sent to your wallet."
case "EXPIRED":
return "The deposit window closed. If you already sent funds, contact \(order.provider) with the order ID."
case "FAILED":
return "The provider could not complete this swap. Check the order with \(order.provider)."
case "REFUNDED":
return "The provider returned your funds to the refund address."
case "OVERDUE":
return "The deposit arrived after the window closed. Check the order with \(order.provider)."
default:
return "Last known status from \(order.provider)."
}
}
/// Position in the five-step lifecycle, or nil for failure states.
private func statusStep(_ status: String) -> Int? {
switch status.uppercased() {
case "WAITING", "NEW": return 0
case "CONFIRMING": return 1
case "EXCHANGING": return 2
case "SENDING": return 3
case "FINISHED", "SUCCESS": return 4
default: return nil
}
}
private func isTerminal(_ status: String?) -> Bool {
guard let status else { return false }
return ["FINISHED", "SUCCESS", "FAILED", "EXPIRED", "REFUNDED", "OVERDUE"]
.contains(status.uppercased())
}
private func needsDeposit(_ status: String) -> Bool {
["WAITING", "NEW"].contains(status.uppercased())
}
/// Refreshes an open order immediately, then every 20 seconds until it
/// reaches a final state. Cancelled automatically when the view goes away.
private func monitorOrder() async {
guard let order = createdOrder else { return }
if !isTerminal(order.status) {
createdOrder = await model.refreshSwapStatus(order)
}
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 20 * 1_000_000_000)
guard !Task.isCancelled, let current = createdOrder, !isTerminal(current.status) else { return }
createdOrder = await model.refreshSwapStatus(current)
}
}
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: "Swap",
subtitle: "XMR ↔ XRP, via kyc.rip — no account, no KYC",
onBack: { route = .home })
if let order = createdOrder {
orderCard(order)
} else {
swapForm
}
if !model.swapOrders.isEmpty {
recentSwaps
}
}
.task {
from = model.chain
if !xmrAccountPicked { xmrAccountIndex = model.defaultMoneroAccountIndex }
model.loadSwapOrders()
}
.task(id: createdOrder?.id) {
await monitorOrder()
}
.onChange(of: model.moneroAccounts.count) { _ in
if !xmrAccountPicked { xmrAccountIndex = model.defaultMoneroAccountIndex }
}
}
// MARK: - The form
private var swapForm: some View {
VStack(alignment: .leading, spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("From")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text("Balance: \(fromBalanceText) \(from.symbol)")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
HStack(spacing: 10) {
KoinsTextField(placeholder: "Amount", text: $amount, keyboard: .decimalPad)
Button("Max") { amount = fromBalanceText }
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.accent)
}
HStack(spacing: 10) {
Text(from.title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Theme.text)
Spacer()
Button {
from = to
amount = ""
estimate = nil
} label: {
Image(systemName: "arrow.up.arrow.down")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Theme.accent)
.frame(width: 34, height: 34)
.background(Theme.surfaceAlt)
.clipShape(Circle())
}
.accessibilityLabel("Swap direction")
}
if from == .xmr, model.moneroAccounts.count > 1 {
MoneroAccountPicker(accounts: model.moneroAccounts,
selection: $xmrAccountIndex,
onSelect: { xmrAccountPicked = true })
}
Divider().overlay(Theme.border)
HStack {
Text("To")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text(to.title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Theme.text)
}
if let estimate {
VStack(alignment: .leading, spacing: 4) {
Text("≈ \(String(format: "%.6f", selectedRoute?.amountTo ?? estimate.amountTo)) \(to.symbol)")
.font(.system(size: 22, weight: .bold, design: .rounded))
.foregroundStyle(Theme.text)
if let route = selectedRoute {
Text(routeFacts(route))
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
if !routes.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Pick a provider")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.muted)
ForEach(routes.prefix(4)) { route in
routeRow(route)
}
}
.padding(.top, 6)
}
} else {
Text("Enter an amount and get a quote")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
}
}
if let localError {
MessageBanner(message: localError, isError: true)
}
if let status = model.statusMessage {
MessageBanner(message: status, isError: false)
}
if estimate == nil {
PrimaryButton(title: estimating ? "Getting quote…" : "Get quote", disabled: estimating) {
Task { await loadQuote() }
}
} else {
PrimaryButton(title: creating ? "Creating…" : "Swap", disabled: creating) {
Task { await create() }
}
SecondaryButton(title: "Change amount") { estimate = nil }
}
Spacer(minLength: 0)
}
}
private func routeRow(_ route: SwapRoute) -> some View {
let selected = route.id == selectedRoute?.id
return Button {
selectedRouteID = route.id
} label: {
HStack(spacing: 10) {
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(selected ? Theme.accent : Theme.muted)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(route.provider)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.text)
if route.recommended {
Text("Recommended")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(Theme.accent)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Theme.accentSoft)
.clipShape(Capsule())
}
}
Text(rowFacts(route))
.font(.system(size: 10))
.foregroundStyle(Theme.muted)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(String(format: "%.6f", route.amountTo))
.font(.system(size: 13, weight: .semibold, design: .rounded))
.foregroundStyle(Theme.text)
Text(route.eta.map { "~\($0) min" } ?? "")
.font(.system(size: 10))
.foregroundStyle(Theme.muted)
}
}
.padding(10)
.background(selected ? Theme.accentSoft : Theme.surface)
.overlay(RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(selected ? Theme.accent.opacity(0.6) : Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
.buttonStyle(.plain)
}
// MARK: - The order
private func orderCard(_ order: SwapOrder) -> some View {
let status = order.status ?? "WAITING"
let alreadySent = order.depositSent == true
let canSend = needsDeposit(status) && !alreadySent
return VStack(alignment: .leading, spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text(statusTitle(status))
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.text)
Spacer()
SwapStatusBadge(status: status)
}
Text(alreadySent && needsDeposit(status)
? "Deposit sent — waiting for \(order.provider) to see it."
: statusDetail(status, order: order))
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
if let step = statusStep(status) {
HStack(spacing: 4) {
ForEach(0..<5, id: \.self) { index in
Capsule()
.fill(index <= step ? Theme.accent : Theme.surfaceAlt)
.frame(height: 4)
}
}
.padding(.vertical, 2)
}
if !isTerminal(status) {
Text("Updates automatically every 20 seconds.")
.font(.system(size: 10))
.foregroundStyle(Theme.muted)
}
if needsDeposit(status) {
Text(verbatim: alreadySent
? "Your \(order.depositAmount) \(order.fromTicker) deposit was sent to this address:"
: "Send exactly \(order.depositAmount) \(order.fromTicker) to this address:")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
.padding(.top, 4)
HStack(spacing: 8) {
Text(order.depositAddress)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Theme.text)
.lineLimit(1)
.truncationMode(.middle)
Button {
UIPasteboard.general.string = order.depositAddress
} label: {
Image(systemName: "doc.on.doc")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.accent)
.frame(width: 34, height: 34)
.background(Theme.surfaceAlt)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
.disabled(order.depositAddress.isEmpty)
Button {
drawers.qr = AddressQR(title: "Deposit \(order.fromTicker)", address: order.depositAddress)
} label: {
Image(systemName: "qrcode")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.accent)
.frame(width: 34, height: 34)
.background(Theme.surfaceAlt)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
.disabled(order.depositAddress.isEmpty)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Theme.surfaceAlt)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
if let memo = order.depositMemo, !memo.isEmpty {
HStack {
Text("Tag")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text(memo)
.font(.system(size: 13, design: .monospaced))
.foregroundStyle(Theme.text)
}
}
}
HStack {
Text("You get")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text("≈ \(order.toAmount, specifier: "%.6g") \(order.toTicker)")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.success)
}
if let eta = order.eta {
HStack {
Text("Usually takes")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text("~\(eta) min")
.font(.system(size: 13))
.foregroundStyle(Theme.text)
}
}
HStack {
Text("Provider")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text(order.provider)
.font(.system(size: 13))
.foregroundStyle(Theme.text)
}
if order.fromAmount > 0 {
HStack {
Text("Rate")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text("1 \(order.fromTicker) ≈ \(order.toAmount / order.fromAmount, specifier: "%.4f") \(order.toTicker)")
.font(.system(size: 13))
.foregroundStyle(Theme.text)
}
}
if let txIn = order.txIn, !txIn.isEmpty {
txHashRow("Deposit tx", hash: txIn, ticker: order.fromTicker)
}
if let txOut = order.txOut, !txOut.isEmpty {
txHashRow("Payout tx", hash: txOut, ticker: order.toTicker)
}
HStack {
Text("Opened")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text(order.createdAt, format: Date.FormatStyle(date: .abbreviated, time: .shortened))
.font(.system(size: 12))
.foregroundStyle(Theme.text)
}
HStack {
Text("Order ID")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text(order.id)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Theme.text)
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: 160, alignment: .trailing)
Button {
UIPasteboard.general.string = order.id
} label: {
Image(systemName: "doc.on.doc")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.accent)
}
}
if let supportUrl = order.supportUrl, let url = URL(string: supportUrl) {
HStack {
Text("Support")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Link("Contact \(order.provider)", destination: url)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.accent)
}
}
}
}
if canSend, order.fromTicker.uppercased() == "XMR", model.moneroAccounts.count > 1 {
MoneroAccountPicker(accounts: model.moneroAccounts,
selection: $xmrAccountIndex,
onSelect: { xmrAccountPicked = true })
}
if needsDeposit(status) {
if alreadySent {
// The deposit was already broadcast; re-sending would double-pay.
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Theme.success)
Text("Deposit sent")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Theme.text)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 15)
.background(Theme.surfaceAlt)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
} else {
PrimaryButton(title: sendingDeposit ? "Sending…" : "Send deposit", disabled: sendingDeposit) {
Task { await sendDeposit(order) }
}
}
}
SecondaryButton(title: "Check status now") {
Task { createdOrder = await model.refreshSwapStatus(order) }
}
if let orderPage = URL(string: "https://kyc.rip/swap?id=\(order.id)") {
LinkButton(title: "View on kyc.rip", systemImage: "globe", url: orderPage)
}
Button {
confirmRemoveOrder = true
} label: {
Text("Remove this order")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
.frame(maxWidth: .infinity)
}
if let localError {
MessageBanner(message: localError, isError: true)
}
Spacer(minLength: 0)
}
.alert("Remove this swap from history?", isPresented: $confirmRemoveOrder) {
Button("Remove", role: .destructive) {
model.removeSwapOrder(id: order.id)
createdOrder = nil
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Only the local record is removed. If you already sent the deposit, \(order.provider) still has the order.")
}
}
/// A truncated, copyable transaction hash row for the order card, with a
/// link to the right block explorer when the ticker is known.
private func txHashRow(_ label: String, hash: String, ticker: String) -> some View {
HStack {
Text(label)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text(hash)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Theme.text)
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: 160, alignment: .trailing)
Button {
UIPasteboard.general.string = hash
} label: {
Image(systemName: "doc.on.doc")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.accent)
}
if let explorer = BlockExplorer.transactionURL(ticker: ticker, hash: hash) {
Link(destination: explorer) {
Image(systemName: "safari")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.accent)
}
}
}
}
private var recentSwaps: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Recent swaps")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
ForEach(model.swapOrders.prefix(6)) { order in
Button {
createdOrder = order
} label: {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text("\(order.fromAmount, specifier: "%.6g") \(order.fromTicker) → \(order.toAmount, specifier: "%.6g") \(order.toTicker)")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Theme.text)
Text(order.createdAt, style: .date)
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
Spacer()
SwapStatusBadge(status: order.status ?? "WAITING")
}
.padding(12)
.background(Theme.surface)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.buttonStyle(.plain)
.contentShape(Rectangle())
.contextMenu {
Button(role: .destructive) {
if createdOrder?.id == order.id { createdOrder = nil }
model.removeSwapOrder(id: order.id)
} label: {
Label("Remove from history", systemImage: "trash")
}
}
}
}
}
// MARK: - Actions
private func loadQuote() async {
localError = nil
estimating = true
defer { estimating = false }
do {
estimate = try await model.estimateSwap(amount: amount, from: from, to: to)
if routes.isEmpty {
localError = "No provider offers this swap right now"
estimate = nil
} else {
// Default to the best payout; kyc.rip's "recommended" flag is shown
// but not preselected because it can be rev-share driven.
selectedRouteID = routes.first?.id
}
} catch {
localError = error.localizedDescription
}
}
private func create() async {
guard let route = selectedRoute, let estimate else { return }
localError = nil
// Respect the provider's deposit window so the order can't be created
// with an amount it would refuse.
let value = Double(amount) ?? 0
if let min = route.minDeposit, value < min {
localError = "\(route.provider) needs at least \(trim(min)) \(from.symbol)"
return
}
if let max = route.maxDeposit, value > max {
localError = "\(route.provider) takes at most \(trim(max)) \(from.symbol)"
return
}
creating = true
defer { creating = false }
do {
createdOrder = try await model.createSwap(route: route,
estimateID: estimate.id,
amountFrom: amount,
from: from,
to: to)
} catch {
localError = error.localizedDescription
}
}
private func sendDeposit(_ order: SwapOrder) async {
localError = nil
sendingDeposit = true
defer { sendingDeposit = false }
do {
try await model.sendSwapDeposit(order, accountIndex: xmrAccountIndex)
// Record that the deposit left this device so the button can never be
// pressed twice for the same order.
var sent = order
sent.depositSent = true
model.saveSwapOrder(sent)
createdOrder = sent
await model.refresh(force: true)
createdOrder = await model.refreshSwapStatus(sent)
} catch {
localError = error.localizedDescription
}
}
}
struct SwapStatusBadge: View {
var status: String
private var colour: Color {
switch status.uppercased() {
case "FINISHED", "SUCCESS": return Theme.success
case "WAITING", "NEW", "CONFIRMING", "EXCHANGING", "SENDING": return Theme.accent
case "REFUNDED": return Theme.muted
case "FAILED", "EXPIRED", "OVERDUE": return Theme.danger
default: return Theme.muted
}
}
var body: some View {
Text(status.lowercased().capitalized)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(colour)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(colour.opacity(0.15))
.clipShape(Capsule())
}
}
@@ -0,0 +1,166 @@
import SwiftUI
/// Full detail for one transaction. Monero and XRP fill different fields, so
/// rows are included only when the wallet knows the value.
struct TransactionDetailView: View {
var record: TransactionRecord
var symbol: String
var chain: Chain
var onDone: () -> Void
@State private var resolvedDate: Date?
@State private var isResolvingDate = false
private var displayedDate: Date? { record.timestamp ?? resolvedDate }
private var dateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .medium
return formatter
}
/// A Monero incoming transfer only reveals the wallet's own receiving
/// address (the sender is hidden), so it is not labelled "From".
private var counterpartyLabel: String {
if chain == .xmr, record.direction == .incoming { return "Received at" }
return record.direction == .incoming ? "From" : "To"
}
private var statusText: String {
if record.direction == .incoming {
if let confirmations = record.confirmations, confirmations > 0 {
return "Confirmed · \(confirmations) confirmation\(confirmations == 1 ? "" : "s")"
}
return record.confirmed ? "Confirmed" : "Waiting for confirmation"
}
if let confirmations = record.confirmations, confirmations > 0 {
return "Confirmed · \(confirmations) confirmation\(confirmations == 1 ? "" : "s")"
}
return record.confirmed ? "Confirmed" : "Pending"
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: record.direction == .incoming ? "arrow.down.left" : "arrow.up.right")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(record.direction == .incoming ? Theme.success : Theme.accent)
Text(record.direction == .incoming ? "Received" : "Sent")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Theme.text)
}
Text("\(record.direction == .incoming ? "+" : "-")\(record.amount) \(symbol)")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(Theme.text)
Text(statusText)
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
Card {
VStack(alignment: .leading, spacing: 12) {
if let date = displayedDate {
DetailRow(label: "Date", value: dateFormatter.string(from: date))
} else if isResolvingDate {
DetailRow(label: "Date", value: "Looking up the block time…")
} else if let height = record.height, height > 0 {
DetailRow(label: "Block height", value: String(height))
}
if let fee = record.fee, !fee.isEmpty, record.direction == .outgoing {
DetailRow(label: "Network fee", value: "\(fee) \(symbol)")
}
if let counterparty = record.counterparty, !counterparty.isEmpty {
DetailRow(label: counterpartyLabel, value: counterparty, monospaced: true, copyable: true)
}
if let tag = record.tag {
DetailRow(label: "Destination tag", value: String(tag))
}
if let height = record.height ?? record.ledgerIndex {
DetailRow(label: chain == .xmr ? "Block height" : "Ledger index", value: String(height))
}
if let confirmations = record.confirmations {
DetailRow(label: "Confirmations", value: String(confirmations))
}
if let accountIndex = record.accountIndex {
DetailRow(label: "Account", value: accountIndex == 0 ? "Primary (0)" : "Account \(accountIndex + 1) (\(accountIndex))")
}
if let subaddressIndex = record.subaddressIndex {
DetailRow(label: "Address index", value: "\(subaddressIndex)")
}
if let sequence = record.sequence {
DetailRow(label: "Sequence", value: String(sequence))
}
if let paymentId = record.paymentId {
DetailRow(label: "Payment ID", value: paymentId, monospaced: true, copyable: true)
}
if let weight = record.weight {
DetailRow(label: "Weight", value: "\(weight) bytes")
}
if let note = record.note {
DetailRow(label: "Note", value: note)
}
DetailRow(label: chain == .xmr ? "Transaction ID" : "Ledger hash",
value: record.id,
monospaced: true,
copyable: true)
}
}
if let explorer = BlockExplorer.transactionURL(chain: chain, hash: record.id) {
LinkButton(title: "View on explorer", url: explorer)
}
PrimaryButton(title: "Done", action: onDone)
}
.padding(20)
}
.preferredColorScheme(.dark)
.task {
guard record.timestamp == nil,
chain == .xmr,
let height = record.height, height > 0,
resolvedDate == nil else { return }
isResolvingDate = true
resolvedDate = try? await MoneroService.shared.blockTimestamp(height: height)
isResolvingDate = false
}
}
}
struct DetailRow: View {
var label: String
var value: String
var monospaced = false
var copyable = false
@State private var copied = false
var body: some View {
HStack(alignment: .top, spacing: 12) {
Text(label)
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
.frame(width: 108, alignment: .leading)
Text(value)
.font(.system(size: 12, design: monospaced ? .monospaced : .default))
.foregroundStyle(Theme.text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
if copyable {
Button {
UIPasteboard.general.string = value
copied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false }
} label: {
Image(systemName: copied ? "checkmark" : "doc.on.doc")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.accent)
}
}
}
}
}
+142
View File
@@ -0,0 +1,142 @@
import SwiftUI
struct CreateWalletView: View {
@EnvironmentObject private var model: AppModel
@Binding var route: Route
@Binding var seedBox: SeedBox?
@State private var name = ""
@State private var localError: String?
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: "Create wallet",
subtitle: "A new \(model.chain.title) wallet on this device",
onBack: { route = .home })
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: "Wallet name")
KoinsTextField(placeholder: "My \(model.symbol) wallet", text: $name)
}
if model.chain == .xmr {
Card {
Text("The wallet file is created locally and encrypted on this device. Syncing starts right away and can take a while for a new Monero wallet.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
}
if let message = localError ?? model.errorMessage {
MessageBanner(message: message, isError: true)
}
PrimaryButton(title: model.isBusy ? "Generating…" : "Generate wallet",
disabled: model.isBusy) {
Task {
localError = nil
model.clearMessages()
do {
let seed = try await model.createWallet(name: name)
seedBox = SeedBox(seed: seed, symbol: model.symbol)
route = .dashboard
} catch {
localError = error.localizedDescription
}
}
}
Spacer(minLength: 0)
}
}
}
struct ImportWalletView: View {
@EnvironmentObject private var model: AppModel
@Binding var route: Route
@State private var name = ""
@State private var secret = ""
@State private var restoreHeight = ""
@State private var localError: String?
@State private var tipError: String?
@State private var isLoadingTip = false
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: "Import wallet",
subtitle: "Restore a \(model.chain.title) wallet from its seed",
onBack: { route = .home })
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: "Wallet name")
KoinsTextField(placeholder: "My \(model.symbol) wallet", text: $name)
}
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: model.chain == .xrp ? "Recovery phrase" : "Mnemonic seed")
KoinsTextField(placeholder: model.chain == .xrp ? "12 words, or a family seed (sEd…)" : "25 word seed phrase",
text: $secret,
multiline: model.chain == .xmr)
}
if model.chain == .xmr {
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: "Restore height (optional)")
KoinsTextField(placeholder: "e.g. 3200000", text: $restoreHeight, keyboard: .numberPad)
}
Card {
VStack(alignment: .leading, spacing: 10) {
Text("Without a height the wallet scans the whole chain — millions of blocks, which takes hours on a phone. Set the block your wallet was first used, or start from the current tip if you only care about new funds.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
Button {
Task {
tipError = nil
isLoadingTip = true
defer { isLoadingTip = false }
do {
let tip = try await MoneroService.shared.daemonHeight()
restoreHeight = String(tip)
} catch {
tipError = error.localizedDescription
}
}
} label: {
Text(isLoadingTip ? "Fetching chain tip…" : "Use the current chain tip")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Theme.accent)
}
.disabled(isLoadingTip)
if let tipError {
Text(tipError)
.font(.system(size: 11))
.foregroundStyle(Theme.danger)
}
}
}
}
if let message = localError ?? model.errorMessage {
MessageBanner(message: message, isError: true)
}
PrimaryButton(title: model.isBusy ? "Importing…" : "Import wallet",
disabled: model.isBusy) {
Task {
localError = nil
model.clearMessages()
do {
let height = UInt64(restoreHeight.trimmingCharacters(in: .whitespaces))
try await model.importWallet(name: name, secret: secret, restoreHeight: height)
route = .dashboard
} catch {
localError = error.localizedDescription
}
}
}
Spacer(minLength: 0)
}
}
}
+327
View File
@@ -0,0 +1,327 @@
import Foundation
/// Monero amounts are atomic units (1 XMR = 1e12 piconero).
enum MoneroAmount {
static let atomicPerXMR: UInt64 = 1_000_000_000_000
static func xmr(fromAtomic value: String) -> String {
guard let atomic = UInt64(value) else { return "0" }
return xmr(fromAtomic: atomic)
}
static func xmr(fromAtomic value: UInt64) -> String {
let whole = value / atomicPerXMR
let fraction = value % atomicPerXMR
if fraction == 0 { return String(whole) }
var text = String(format: "%012llu", fraction)
while text.hasSuffix("0") { text.removeLast() }
return "\(whole).\(text)"
}
static func atomic(fromXMR value: String) throws -> UInt64 {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, !text.hasPrefix("-") else { throw XRPError.rpc("Enter a valid amount") }
let parts = text.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count <= 2 else { throw XRPError.rpc("Enter a valid amount") }
guard let whole = UInt64(parts[0].isEmpty ? "0" : String(parts[0])) else {
throw XRPError.rpc("Enter a valid amount")
}
var fraction: UInt64 = 0
if parts.count == 2 {
let fractionText = String(parts[1])
guard fractionText.count <= 12, fractionText.allSatisfy({ $0.isNumber }) else {
throw XRPError.rpc("XMR supports at most 12 decimal places")
}
let padded = fractionText.padding(toLength: 12, withPad: "0", startingAt: 0)
guard let parsed = UInt64(padded) else { throw XRPError.rpc("Enter a valid amount") }
fraction = parsed
}
let (wholeAtomic, overflow) = whole.multipliedReportingOverflow(by: atomicPerXMR)
guard !overflow else { throw XRPError.rpc("Amount is too large") }
let (total, carry) = wholeAtomic.addingReportingOverflow(fraction)
guard !carry else { throw XRPError.rpc("Amount is too large") }
return total
}
}
/// Swift wrapper around the Objective-C++ monero-cpp bridge.
final class MoneroService {
static let shared = MoneroService()
/// Wallet files that exist on disk without a stored record, so a lost
/// preference file does not strand a wallet.
static func orphanedWalletNames() -> [String] {
let manager = FileManager.default
guard let documents = manager.urls(for: .documentDirectory, in: .userDomainMask).first,
let items = try? manager.contentsOfDirectory(at: documents,
includingPropertiesForKeys: nil) else {
return []
}
let keys = Set(items.filter { $0.pathExtension == "keys" }
.map { $0.deletingPathExtension().lastPathComponent })
return items
.filter { $0.pathExtension.isEmpty && !$0.lastPathComponent.hasPrefix(".") }
.map { $0.lastPathComponent }
.filter { keys.contains($0) }
.sorted()
}
/// The wallet file is encrypted with this password; the recovery seed lives
/// in the Keychain.
static let walletPassword = "koins"
/// Default daemon, kept in sync with the bridge. Used for the cheap
/// daemon-only queries that do not need an open wallet.
static let daemonHost = "xmr-node.cakewallet.com"
/// Current chain tip, so an import can start scanning from now instead of
/// from the genesis block.
func daemonHeight() async throws -> UInt64 {
guard let url = URL(string: "http://\(Self.daemonHost):18081/json_rpc") else {
throw XRPError.network("Invalid Monero daemon address")
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 15
request.httpBody = try JSONSerialization.data(withJSONObject: [
"jsonrpc": "2.0",
"id": "koins",
"method": "get_info",
])
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw XRPError.network("Monero daemon is unreachable")
}
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let result = json["result"] as? [String: Any],
let height = (result["height"] as? NSNumber)?.uint64Value else {
throw XRPError.network("Could not read the Monero chain tip")
}
return height
}
private func invoke(_ request: [String: Any]) async throws -> [String: Any] {
let json = try JSONSerialization.data(withJSONObject: request)
let requestString = String(data: json, encoding: .utf8) ?? "{}"
let responseString: String = await withCheckedContinuation { continuation in
KoinsMoneroWallet.invoke(requestString) { response in
continuation.resume(returning: response)
}
}
guard let data = responseString.data(using: .utf8),
let response = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw XRPError.rpc("Unexpected response from the Monero wallet")
}
if let error = response["error"] as? String, !error.isEmpty {
throw XRPError.rpc(error)
}
return response
}
// MARK: - Wallet lifecycle
func createWallet(name: String) async throws -> (address: String, mnemonic: String) {
let response = try await invoke(["op": "createWallet",
"name": name,
"password": Self.walletPassword,
"language": "English"])
return (response["address"] as? String ?? "", response["mnemonic"] as? String ?? "")
}
func openWallet(name: String) async throws -> String {
let response = try await invoke(["op": "openWallet",
"name": name,
"password": Self.walletPassword])
return response["address"] as? String ?? ""
}
func restoreWallet(name: String,
seed: String,
restoreHeight: UInt64) async throws -> String {
let response = try await invoke(["op": "restoreWallet",
"name": name,
"password": Self.walletPassword,
"seed": seed,
"restoreHeight": restoreHeight])
return response["address"] as? String ?? ""
}
func removeWallet(name: String) async {
_ = try? await invoke(["op": "removeWallet", "name": name])
}
func closeWallet() async {
_ = try? await invoke(["op": "closeWallet"])
}
/// Writes the wallet file, which is how scan progress survives a restart.
func save() async {
_ = try? await invoke(["op": "saveWallet"])
}
func seed() async throws -> String {
let response = try await invoke(["op": "getSeed"])
return response["mnemonic"] as? String ?? ""
}
/// Block time for a height. wallet2 keeps no timestamp for transfers, so the
/// only way to date a Monero transaction is to ask the daemon for the block
/// it landed in.
func blockTimestamp(height: UInt64) async throws -> Date? {
guard let url = URL(string: "http://\(Self.daemonHost):18081/json_rpc") else { return nil }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 15
request.httpBody = try JSONSerialization.data(withJSONObject: [
"jsonrpc": "2.0",
"id": "koins",
"method": "get_block_header_by_height",
"params": ["height": height],
])
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode),
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let result = json["result"] as? [String: Any],
let header = result["block_header"] as? [String: Any],
let timestamp = (header["timestamp"] as? NSNumber)?.doubleValue, timestamp > 0 else {
return nil
}
return Date(timeIntervalSince1970: timestamp)
}
// MARK: - Reads
func balance() async throws -> Balance {
let response = try await invoke(["op": "getBalance"])
let total = response["balance"] as? String ?? "0"
let unlocked = response["unlocked"] as? String ?? total
return Balance(total: MoneroAmount.xmr(fromAtomic: total),
unlocked: MoneroAmount.xmr(fromAtomic: unlocked),
funded: (UInt64(total) ?? 0) > 0)
}
func transactions() async throws -> [TransactionRecord] {
let response = try await invoke(["op": "getTransactions"])
let entries = response["transactions"] as? [[String: Any]] ?? []
return entries.map { entry in
let timestamp = (entry["timestamp"] as? NSNumber)?.doubleValue ?? 0
return TransactionRecord(
id: entry["hash"] as? String ?? UUID().uuidString,
amount: MoneroAmount.xmr(fromAtomic: entry["amount"] as? String ?? "0"),
timestamp: timestamp > 0 ? Date(timeIntervalSince1970: timestamp) : nil,
direction: (entry["direction"] as? String) == "in" ? .incoming : .outgoing,
confirmed: (entry["confirmed"] as? Bool) ?? false,
fee: MoneroAmount.xmr(fromAtomic: entry["fee"] as? String ?? "0"),
tag: nil,
counterparty: entry["counterparty"] as? String,
confirmations: (entry["confirmations"] as? NSNumber)?.uint64Value,
height: (entry["height"] as? NSNumber)?.uint64Value,
paymentId: (entry["paymentId"] as? String).flatMap { $0.isEmpty ? nil : $0 },
accountIndex: (entry["accountIndex"] as? NSNumber)?.intValue,
subaddressIndex: (entry["subaddressIndex"] as? NSNumber)?.intValue,
note: (entry["note"] as? String).flatMap { $0.isEmpty ? nil : $0 },
weight: (entry["weight"] as? NSNumber)?.uint64Value)
}
.sorted { newestFirst($0, $1) }
}
/// Newest first. wallet2 gives transfers no timestamp, but the block height is
/// chronological and always present, so that is the ordering key; anything
/// still in the pool (height 0) belongs at the top.
private func newestFirst(_ left: TransactionRecord, _ right: TransactionRecord) -> Bool {
func key(_ record: TransactionRecord) -> UInt64 {
guard let height = record.height, height > 0 else { return .max }
return height
}
let leftKey = key(left)
let rightKey = key(right)
if leftKey != rightKey { return leftKey > rightKey }
return (left.timestamp ?? .distantFuture) > (right.timestamp ?? .distantFuture)
}
// MARK: - Accounts
func accounts() async throws -> [MoneroAccount] {
let response = try await invoke(["op": "getAccounts"])
let entries = response["accounts"] as? [[String: Any]] ?? []
return entries.map { entry in
let subEntries = entry["subaddresses"] as? [[String: Any]] ?? []
return MoneroAccount(
index: (entry["index"] as? NSNumber)?.intValue ?? 0,
address: entry["address"] as? String ?? "",
balance: MoneroAmount.xmr(fromAtomic: entry["balance"] as? String ?? "0"),
unlocked: MoneroAmount.xmr(fromAtomic: entry["unlocked"] as? String ?? "0"),
subaddresses: subEntries.map { sub in
MoneroSubaddress(
accountIndex: (sub["accountIndex"] as? NSNumber)?.intValue ?? 0,
index: (sub["index"] as? NSNumber)?.intValue ?? 0,
address: sub["address"] as? String ?? "",
label: sub["label"] as? String ?? "",
balance: MoneroAmount.xmr(fromAtomic: sub["balance"] as? String ?? "0"),
unlocked: MoneroAmount.xmr(fromAtomic: sub["unlocked"] as? String ?? "0"),
isUsed: (sub["isUsed"] as? Bool) ?? false)
})
}
}
func createAccount(label: String) async throws -> MoneroAccount {
let response = try await invoke(["op": "createAccount", "label": label])
return MoneroAccount(index: (response["index"] as? NSNumber)?.intValue ?? 0,
address: response["address"] as? String ?? "",
balance: MoneroAmount.xmr(fromAtomic: response["balance"] as? String ?? "0"),
unlocked: MoneroAmount.xmr(fromAtomic: response["unlocked"] as? String ?? "0"),
subaddresses: [])
}
func createSubaddress(accountIndex: Int, label: String) async throws -> MoneroSubaddress {
let response = try await invoke(["op": "createSubaddress",
"accountIndex": accountIndex,
"label": label])
return MoneroSubaddress(accountIndex: (response["accountIndex"] as? NSNumber)?.intValue ?? accountIndex,
index: (response["index"] as? NSNumber)?.intValue ?? 0,
address: response["address"] as? String ?? "",
label: response["label"] as? String ?? label,
balance: "0",
unlocked: "0",
isUsed: false)
}
// MARK: - Sync
func startSync() async throws -> Bool {
let response = try await invoke(["op": "startSync"])
return (response["syncing"] as? Bool) ?? false
}
func stopSync() async {
_ = try? await invoke(["op": "stopSync"])
}
func syncStatus() async throws -> SyncStatus {
let response = try await invoke(["op": "getSyncStatus"])
return SyncStatus(height: (response["height"] as? NSNumber)?.uint64Value ?? 0,
daemonHeight: (response["daemonHeight"] as? NSNumber)?.uint64Value ?? 0,
syncing: (response["syncing"] as? Bool) ?? false,
connected: (response["connected"] as? Bool) ?? false)
}
// MARK: - Sends
func send(address: String, amountXMR: String, priority: TransactionPriority,
accountIndex: Int = 0) async throws -> (hash: String, fee: String) {
let atomic = try MoneroAmount.atomic(fromXMR: amountXMR)
let response = try await invoke(["op": "transferTo",
"address": address.trimmingCharacters(in: .whitespacesAndNewlines),
"amount": String(atomic),
"priority": priority.rawValue,
"accountIndex": accountIndex])
return (response["txHash"] as? String ?? "",
MoneroAmount.xmr(fromAtomic: response["fee"] as? String ?? "0"))
}
}
+232
View File
@@ -0,0 +1,232 @@
import Foundation
/// A route offered by one of kyc.rip's underlying exchange engines.
struct SwapRoute: Identifiable {
var id: String { "\(provider).\(engine).\(amountTo)" }
var provider: String
var engine: String
var amountFrom: Double
var amountTo: Double
var eta: Int? // minutes
var fixedRate: Bool
var spread: Double? // percent above/below the market rate
var kycRating: String? // A/B/C privacy grade from the aggregator
var logPolicy: String? // the provider's logging policy grade
var insurance: Int? // percent of the swap insured by the provider
var minDeposit: Double?
var maxDeposit: Double?
var recommended: Bool
}
struct SwapEstimate {
var id: String
var amountTo: Double
var routes: [SwapRoute]
}
/// A created swap order, persisted so it can be tracked after the app restarts.
struct SwapOrder: Codable, Identifiable {
var id: String
var engine: String
var provider: String
var depositAddress: String
var depositAmount: Double
var depositMemo: String?
var fromTicker: String
var fromAmount: Double
var toTicker: String
var toAmount: Double
var addressTo: String
var createdAt: Date
var eta: Int?
/// Latest-known lifecycle state (refreshed from the API).
var status: String?
/// Transaction hashes reported by the provider once the swap progresses.
var txIn: String?
var txOut: String?
/// Provider support link, reported by the status endpoint when available.
var supportUrl: String?
/// Set once the deposit has been broadcast from this device, so the
/// "Send deposit" button can never be pressed twice for the same order.
var depositSent: Bool?
}
/// Latest-known state of an order, as reported by the status endpoint.
struct SwapOrderUpdate {
var status: String
var txIn: String?
var txOut: String?
var supportUrl: String?
}
enum SwapError: LocalizedError {
case http(Int)
case api(String)
case noRoute
var errorDescription: String? {
switch self {
case .http(let code): return "Swap service unavailable (\(code))"
case .api(let message): return message
case .noRoute: return "No provider offers this swap right now"
}
}
}
/// kyc.rip (https://api.kyc.rip), a public no-KYC swap aggregator. No account,
/// no API key; the response is used directly.
final class SwapService {
static let shared = SwapService()
private let base = "https://api.kyc.rip"
private let session: URLSession
private init() {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 25
session = URLSession(configuration: configuration)
}
// MARK: - Estimates
func estimate(from: String, to: String, amount: Double) async throws -> SwapEstimate {
var components = URLComponents(string: "\(base)/v2/exchange/estimate")
components?.queryItems = [
URLQueryItem(name: "from", value: from),
URLQueryItem(name: "from_net", value: "Mainnet"),
URLQueryItem(name: "to", value: to),
URLQueryItem(name: "to_net", value: "Mainnet"),
URLQueryItem(name: "amount", value: String(amount)),
]
guard let url = components?.url else { throw SwapError.noRoute }
let (data, response) = try await session.data(from: url)
try check(response)
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw SwapError.noRoute
}
if let error = json["error"] as? String { throw SwapError.api(error) }
let amountTo = (json["amount_to"] as? NSNumber)?.doubleValue ?? 0
let routes = (json["routes"] as? [[String: Any]] ?? []).map { entry in
SwapRoute(
provider: entry["provider"] as? String ?? "",
engine: entry["engine"] as? String ?? "",
amountFrom: (entry["amount_from"] as? NSNumber)?.doubleValue ?? 0,
amountTo: (entry["amount_to"] as? NSNumber)?.doubleValue ?? 0,
eta: (entry["eta"] as? NSNumber)?.intValue,
fixedRate: (entry["fixed"] as? Bool) ?? false,
spread: (entry["spread"] as? NSNumber)?.doubleValue,
kycRating: entry["kyc"] as? String,
logPolicy: entry["log_policy"] as? String,
insurance: (entry["insurance"] as? NSNumber)?.intValue,
minDeposit: (entry["min_deposit"] as? NSNumber)?.doubleValue,
maxDeposit: (entry["max_deposit"] as? NSNumber)?.doubleValue,
recommended: (entry["recommended"] as? Bool) ?? false)
}
return SwapEstimate(id: json["id"] as? String ?? "", amountTo: amountTo, routes: routes)
}
// MARK: - Orders
func createOrder(route: SwapRoute,
estimateID: String,
from: String,
to: String,
addressTo: String,
amountFrom: Double) async throws -> SwapOrder {
guard let url = URL(string: "\(base)/v2/exchange/create") else { throw SwapError.noRoute }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
var body: [String: Any] = [
"provider": route.provider,
"engine": route.engine,
"from_currency": from,
"from_network": "Mainnet",
"to_currency": to,
"to_network": "Mainnet",
"address_to": addressTo,
"amount_from": amountFrom,
"amount_to": route.amountTo,
"fixed_rate": route.fixedRate,
"source": "swap",
"isPayment": false,
]
// The create endpoint expects the quote it was estimated against.
if !estimateID.isEmpty { body["trade_id"] = estimateID }
if let kyc = route.kycRating { body["kyc_rating"] = kyc }
if let log = route.logPolicy { body["log_policy"] = log }
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
try check(response)
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw SwapError.noRoute
}
if let error = json["error"] as? String { throw SwapError.api(error) }
if let code = json["code"] as? String { throw SwapError.api(code) }
guard let id = json["id"] as? String, !id.isEmpty else { throw SwapError.noRoute }
return SwapOrder(
id: id,
engine: json["engine"] as? String ?? route.engine,
provider: json["provider"] as? String ?? route.provider,
depositAddress: json["depositAddress"] as? String ?? "",
depositAmount: (json["depositAmount"] as? NSNumber)?.doubleValue ?? amountFrom,
depositMemo: json["depositMemo"] as? String,
fromTicker: (json["fromTicker"] as? String ?? from).uppercased(),
fromAmount: (json["fromAmount"] as? NSNumber)?.doubleValue ?? amountFrom,
toTicker: (json["toTicker"] as? String ?? to).uppercased(),
toAmount: (json["toAmount"] as? NSNumber)?.doubleValue ?? 0,
addressTo: json["address_user"] as? String ?? addressTo,
createdAt: Date(),
eta: (json["eta"] as? NSNumber)?.intValue,
status: json["status"] as? String,
supportUrl: nonEmpty(json["supportUrl"] as? String))
}
func orderStatus(_ order: SwapOrder) async throws -> SwapOrderUpdate {
var components = URLComponents(string: "\(base)/v2/exchange/status/\(order.id)")
components?.queryItems = [URLQueryItem(name: "engine", value: order.engine)]
guard let url = components?.url else { throw SwapError.noRoute }
let (data, response) = try await session.data(from: url)
try check(response)
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw SwapError.noRoute
}
if let error = json["error"] as? String { throw SwapError.api(error) }
let txIn = nonEmpty(json["txIn"] as? String)
?? (json["txIn"] as? [String: Any]).flatMap { nonEmpty($0["hash"] as? String) }
let txOut = nonEmpty(json["txOut"] as? String)
?? (json["txOut"] as? [String: Any]).flatMap { nonEmpty($0["hash"] as? String) }
return SwapOrderUpdate(status: json["status"] as? String ?? "UNKNOWN",
txIn: txIn,
txOut: txOut,
supportUrl: nonEmpty(json["supportUrl"] as? String))
}
/// The API sends blank strings while a hash is still unknown; treat those as
/// absent so the UI does not render empty rows.
private func nonEmpty(_ value: String?) -> String? {
guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
return value
}
private func check(_ response: URLResponse) throws {
guard let http = response as? HTTPURLResponse else { return }
if http.statusCode == 429 { throw SwapError.api("Rate limited — try again in a minute") }
guard (200..<300).contains(http.statusCode) else { throw SwapError.http(http.statusCode) }
}
}
+53
View File
@@ -0,0 +1,53 @@
import Foundation
/// Exact decimal <-> drops conversion (1 XRP = 1_000_000 drops) without
/// floating point rounding errors.
enum XRPAmount {
static let dropsPerXRP: UInt64 = 1_000_000
static let maxDrops: UInt64 = 100_000_000_000 * dropsPerXRP // 1e11 XRP
static func drops(fromXRP value: String) throws -> UInt64 {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, !text.hasPrefix("-"), !text.hasPrefix("+") else {
throw XRPError.rpc("Enter a valid amount")
}
let parts = text.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count <= 2 else { throw XRPError.rpc("Enter a valid amount") }
let wholeText = parts[0].isEmpty ? "0" : String(parts[0])
guard let whole = UInt64(wholeText) else { throw XRPError.rpc("Enter a valid amount") }
var fraction: UInt64 = 0
if parts.count == 2 {
let fractionText = String(parts[1])
guard fractionText.count <= 6, fractionText.allSatisfy({ $0.isNumber }) else {
throw XRPError.rpc("XRP supports at most 6 decimal places")
}
let padded = fractionText.padding(toLength: 6, withPad: "0", startingAt: 0)
guard let parsed = UInt64(padded) else { throw XRPError.rpc("Enter a valid amount") }
fraction = parsed
}
let (wholeDrops, overflow) = whole.multipliedReportingOverflow(by: dropsPerXRP)
guard !overflow else { throw XRPError.rpc("Amount is too large") }
let (total, carry) = wholeDrops.addingReportingOverflow(fraction)
guard !carry, total <= maxDrops else { throw XRPError.rpc("Amount is too large") }
return total
}
static func xrp(fromDrops drops: String) -> String {
guard let value = UInt64(drops.trimmingCharacters(in: .whitespacesAndNewlines)) else {
return "0"
}
return xrp(fromDrops: value)
}
static func xrp(fromDrops value: UInt64) -> String {
let whole = value / dropsPerXRP
let fraction = value % dropsPerXRP
if fraction == 0 { return String(whole) }
var fractionText = String(format: "%06llu", fraction)
while fractionText.hasSuffix("0") { fractionText.removeLast() }
return "\(whole).\(fractionText)"
}
}
+136
View File
@@ -0,0 +1,136 @@
import Foundation
/// Canonical XRP Ledger binary serialization for the Payment transactions this
/// wallet sends, plus the signing / transaction-id hashing around it.
struct XRPPayment {
var account: String
var destination: String
var amountDrops: UInt64
var feeDrops: UInt64
var sequence: UInt32
var lastLedgerSequence: UInt32?
var destinationTag: UInt32?
var signingPublicKey: [UInt8]
var signature: [UInt8]?
private static let typeUInt16: UInt8 = 1
private static let typeUInt32: UInt8 = 2
private static let typeAmount: UInt8 = 6
private static let typeBlob: UInt8 = 7
private static let typeAccountID: UInt8 = 8
func serialized(includingSignature: Bool) throws -> [UInt8] {
var output: [UInt8] = []
// Fields must appear in (type, field) order.
output += header(type: Self.typeUInt16, field: 2) // TransactionType
output += uint16(0) // ttPAYMENT
output += header(type: Self.typeUInt32, field: 2) // Flags
output += uint32(0)
output += header(type: Self.typeUInt32, field: 4) // Sequence
output += uint32(sequence)
if let tag = destinationTag {
output += header(type: Self.typeUInt32, field: 14) // DestinationTag
output += uint32(tag)
}
if let lastLedger = lastLedgerSequence {
output += header(type: Self.typeUInt32, field: 27) // LastLedgerSequence
output += uint32(lastLedger)
}
output += header(type: Self.typeAmount, field: 1) // Amount
output += try amount(amountDrops)
output += header(type: Self.typeAmount, field: 8) // Fee
output += try amount(feeDrops)
output += header(type: Self.typeBlob, field: 3) // SigningPubKey
output += variableLength(signingPublicKey)
if includingSignature, let signature {
output += header(type: Self.typeBlob, field: 4) // TxnSignature
output += variableLength(signature)
}
output += header(type: Self.typeAccountID, field: 1) // Account
output += variableLength(try XRPKey.accountId(fromAddress: account))
output += header(type: Self.typeAccountID, field: 3) // Destination
output += variableLength(try XRPKey.accountId(fromAddress: destination))
return output
}
/// Bytes the private key signs (HashPrefix.transactionSig + tx).
func signingPayload() throws -> [UInt8] {
[0x53, 0x54, 0x58, 0x00] + (try serialized(includingSignature: false))
}
/// The signed transaction blob submitted to the network (no hash prefix).
func txBlob() throws -> [UInt8] {
guard signature != nil else { throw XRPError.rpc("Transaction is not signed") }
return try serialized(includingSignature: true)
}
func transactionHash() throws -> [UInt8] {
guard signature != nil else { throw XRPError.rpc("Transaction is not signed") }
let blob = try serialized(includingSignature: true)
return XRPHash.sha512Half([0x54, 0x58, 0x4e, 0x00] + blob)
}
// MARK: - Encoding helpers
private func header(type: UInt8, field: UInt8) -> [UInt8] {
if field < 16 { return [(type << 4) | field] }
return [type << 4, field]
}
private func uint16(_ value: UInt16) -> [UInt8] {
[UInt8(value >> 8), UInt8(value & 0xff)]
}
private func uint32(_ value: UInt32) -> [UInt8] {
[
UInt8((value >> 24) & 0xff),
UInt8((value >> 16) & 0xff),
UInt8((value >> 8) & 0xff),
UInt8(value & 0xff),
]
}
/// Native (XRP) amount: 8 bytes big-endian drops with the XRP flag bit set.
private func amount(_ drops: UInt64) throws -> [UInt8] {
guard drops <= XRPAmount.maxDrops else { throw XRPError.rpc("Amount is too large") }
var bytes = uint64(drops)
bytes[0] |= 0x40
return bytes
}
private func uint64(_ value: UInt64) -> [UInt8] {
var bytes: [UInt8] = []
for shift in stride(from: 56, through: 0, by: -8) {
bytes.append(UInt8((value >> UInt64(shift)) & 0xff))
}
return bytes
}
private func variableLength(_ bytes: [UInt8]) -> [UInt8] {
var output: [UInt8] = []
let count = bytes.count
if count < 192 {
output.append(UInt8(count))
} else if count <= 12_480 {
output.append(UInt8((count >> 8) + 192))
output.append(UInt8(count & 0xff))
} else {
output.append(UInt8((count >> 16) + 240))
output.append(UInt8((count >> 8) & 0xff))
output.append(UInt8(count & 0xff))
}
return output + bytes
}
}
+72
View File
@@ -0,0 +1,72 @@
import Foundation
/// Minimal XRP Ledger JSON-RPC client. A couple of public endpoints are tried
/// in order so a single outage does not break the wallet.
final class XRPClient {
static let shared = XRPClient()
private let endpoints = [
"https://s1.ripple.com:51234/",
"https://s2.ripple.com:51234/",
"https://xrplcluster.com/",
]
private var preferredEndpoint = 0
private let session: URLSession
private init() {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 20
configuration.waitsForConnectivity = false
session = URLSession(configuration: configuration)
}
func rpc(_ method: String, _ params: [String: Any] = [:]) async throws -> [String: Any] {
var lastError: Error = XRPError.network("XRP network is unavailable")
for attempt in 0..<endpoints.count {
let index = (preferredEndpoint + attempt) % endpoints.count
guard let url = URL(string: endpoints[index]) else { continue }
do {
let result = try await send(method, params, to: url)
preferredEndpoint = index
return result
} catch {
lastError = error
}
}
throw lastError
}
private func send(_ method: String, _ params: [String: Any], to url: URL) async throws -> [String: Any] {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: Any] = ["method": method, "params": [params], "id": 1]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw XRPError.network("Ledger request failed (\(method))")
}
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw XRPError.network("Malformed ledger response")
}
let result = json["result"] as? [String: Any] ?? [:]
if let error = result["error"] as? String {
let message = result["error_message"] as? String ?? error
throw XRPError.rpc(message)
}
if let error = json["error"] as? String {
throw XRPError.rpc(json["error_message"] as? String ?? error)
}
return result
}
static func isAccountNotFound(_ error: Error) -> Bool {
if case XRPError.rpc(let message) = error {
return message == "actNotFound" || message.contains("Account not found")
}
return false
}
}
+148
View File
@@ -0,0 +1,148 @@
import CryptoKit
import Foundation
enum XRPAlgorithm: String {
case ed25519
case secp256k1
}
/// Set by the app target so this file stays compilable outside iOS.
enum Secp256k1Backend {
static var derivePrivateKey: ((Data) -> Data?)?
static var publicKey: ((Data) -> Data?)?
static var sign: ((Data, Data) -> Data?)?
static var addScalar: ((Data, Data) -> Data?)?
}
struct XRPKeypair {
let algorithm: XRPAlgorithm
/// ed25519: 32-byte seed. secp256k1: 32-byte scalar.
let privateKey: [UInt8]
/// ed25519: 0xED + 32-byte key. secp256k1: 33-byte compressed point.
let publicKey: [UInt8]
var address: String { XRPKey.address(fromPublicKey: publicKey) }
var signingPublicKeyHex: String { Hex.encode(publicKey) }
}
enum XRPKey {
static let ed25519SeedVersion: [UInt8] = [0x01, 0xe1, 0x4b]
static let familySeedVersion: [UInt8] = [0x21]
static let accountIdVersion: [UInt8] = [0x00]
static let ed25519Prefix: UInt8 = 0xED
// MARK: - Seeds
static func generateSeed(algorithm: XRPAlgorithm = .ed25519) -> String {
var entropy = [UInt8](repeating: 0, count: 16)
for index in entropy.indices { entropy[index] = UInt8.random(in: 0...255) }
let version = algorithm == .ed25519 ? ed25519SeedVersion : familySeedVersion
return XRPBase58.checkEncode(version: version, payload: entropy)
}
static func decodeSeed(_ seed: String) throws -> (entropy: [UInt8], algorithm: XRPAlgorithm) {
let decoded = try XRPBase58.decode(seed.trimmingCharacters(in: .whitespacesAndNewlines))
guard decoded.count > 4 else { throw XRPError.invalidSeed }
let body = Array(decoded[0..<(decoded.count - 4)])
let checksum = Array(decoded[(decoded.count - 4)...])
guard XRPHash.checksum(body) == checksum else { throw XRPError.invalidSeed }
if body.count == ed25519SeedVersion.count + 16, Array(body.prefix(3)) == ed25519SeedVersion {
return (Array(body.dropFirst(3)), .ed25519)
}
if body.count == familySeedVersion.count + 16, body[0] == familySeedVersion[0] {
return (Array(body.dropFirst(1)), .secp256k1)
}
throw XRPError.unsupportedSeed
}
// MARK: - Keys
/// Recovery phrase for a new wallet: 12 BIP-39 words.
static func generateMnemonic() -> String {
BIP39.generateMnemonic()
}
/// Accepts either a 12 word BIP-39 recovery phrase or a legacy XRP family
/// seed (`sEd…` / `s…`).
static func keypair(fromSecret secret: String) throws -> XRPKeypair {
let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines)
if BIP39.isMnemonic(trimmed) {
return try keypair(fromMnemonic: trimmed)
}
return try keypair(fromSeed: trimmed)
}
/// Derives the account at `m/44'/144'/0'/0/0`, the path hardware and mobile
/// wallets use for XRP.
static func keypair(fromMnemonic mnemonic: String) throws -> XRPKeypair {
guard BIP39.entropy(from: mnemonic) != nil else {
throw XRPError.invalidSeed
}
let seed = BIP39.seed(from: mnemonic)
guard let master = BIP32.masterKey(fromSeed: seed),
let derived = BIP32.derive(BIP32.xrpPath, from: master) else {
throw XRPError.unsupportedSeed
}
return XRPKeypair(algorithm: .secp256k1,
privateKey: derived.privateKey,
publicKey: derived.publicKey)
}
static func keypair(fromSeed seed: String) throws -> XRPKeypair {
let (entropy, algorithm) = try decodeSeed(seed)
switch algorithm {
case .ed25519:
let raw = XRPHash.sha512Half(entropy)
let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: Data(raw))
let publicKey = [ed25519Prefix] + Array(privateKey.publicKey.rawRepresentation)
return XRPKeypair(algorithm: .ed25519, privateKey: raw, publicKey: publicKey)
case .secp256k1:
guard let derive = Secp256k1Backend.derivePrivateKey,
let toPublic = Secp256k1Backend.publicKey,
let privateKey = derive(Data(entropy)),
let publicKey = toPublic(privateKey) else {
throw XRPError.unsupportedSeed
}
return XRPKeypair(algorithm: .secp256k1,
privateKey: Array(privateKey),
publicKey: Array(publicKey))
}
}
static func address(fromPublicKey publicKey: [UInt8]) -> String {
let accountId = XRPHash.ripemd160(XRPHash.sha256(publicKey))
return XRPBase58.checkEncode(version: accountIdVersion, payload: accountId)
}
static func isValidAddress(_ address: String) -> Bool {
(try? XRPBase58.checkDecode(address, versionLength: 1)) != nil
}
static func accountId(fromAddress address: String) throws -> [UInt8] {
let payload = try XRPBase58.checkDecode(address, versionLength: 1)
guard payload.count == 20 else { throw XRPError.invalidAddress }
return payload
}
// MARK: - Signing
/// ed25519 returns a 64-byte signature; secp256k1 a DER-encoded one, matching
/// what the XRP Ledger expects for each signing scheme.
static func sign(message: [UInt8], keypair: XRPKeypair) throws -> [UInt8] {
switch keypair.algorithm {
case .ed25519:
let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: Data(keypair.privateKey))
let signature = try privateKey.signature(for: Data(message))
return Array(signature)
case .secp256k1:
let digest = XRPHash.sha512Half(message)
guard let signFn = Secp256k1Backend.sign,
let signature = signFn(Data(digest), Data(keypair.privateKey)) else {
throw XRPError.unsupportedSeed
}
return Array(signature)
}
}
}
@@ -0,0 +1,161 @@
import Foundation
/// High level XRP operations: wallet creation/import, balances, history, sends.
final class XRPWalletService {
static let shared = XRPWalletService()
private let rippleEpoch: TimeInterval = 946_684_800
// MARK: - Wallet lifecycle
func createWallet(name: String) throws -> (WalletRecord, String) {
let mnemonic = XRPKey.generateMnemonic()
let keypair = try XRPKey.keypair(fromMnemonic: mnemonic)
let record = WalletRecord(id: "xrp_\(Int(Date().timeIntervalSince1970))",
name: name,
address: keypair.address,
createdAt: Date())
WalletStore.saveWallet(record, chain: .xrp)
WalletStore.saveSecret(mnemonic, chain: .xrp)
return (record, mnemonic)
}
func importWallet(name: String, secret: String) throws -> WalletRecord {
let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines)
let keypair = try XRPKey.keypair(fromSecret: trimmed)
let record = WalletRecord(id: "xrp_\(Int(Date().timeIntervalSince1970))",
name: name,
address: keypair.address,
createdAt: Date())
WalletStore.saveWallet(record, chain: .xrp)
WalletStore.saveSecret(trimmed, chain: .xrp)
return record
}
func address(forSecret secret: String) throws -> String {
try XRPKey.keypair(fromSecret: secret.trimmingCharacters(in: .whitespacesAndNewlines)).address
}
// MARK: - Reads
func balance(address: String) async throws -> Balance {
do {
let result = try await XRPClient.shared.rpc("account_info",
["account": address,
"ledger_index": "current"])
let accountData = result["account_data"] as? [String: Any] ?? [:]
let drops = accountData["Balance"] as? String ?? "0"
return Balance(total: XRPAmount.xrp(fromDrops: drops), unlocked: "", funded: true)
} catch {
if XRPClient.isAccountNotFound(error) {
return Balance(total: "0", unlocked: "", funded: false)
}
throw error
}
}
func transactions(address: String) async throws -> [TransactionRecord] {
let result = try await XRPClient.shared.rpc("account_tx",
["account": address,
"ledger_index_min": -1,
"ledger_index_max": -1,
"limit": 40])
let entries = result["transactions"] as? [[String: Any]] ?? []
var records: [TransactionRecord] = []
for entry in entries {
guard let tx = entry["tx_json"] as? [String: Any] ?? entry["tx"] as? [String: Any] else { continue }
guard (tx["TransactionType"] as? String) == "Payment" else { continue }
let meta = entry["meta"] as? [String: Any] ?? entry["metaData"] as? [String: Any] ?? [:]
if let txResult = meta["TransactionResult"] as? String, txResult != "tesSUCCESS" { continue }
// IOU / MPT deliveries carry an object; only native XRP is shown.
let delivered = (meta["delivered_amount"] as? String)
?? (meta["DeliveredAmount"] as? String)
?? (tx["Amount"] as? String)
guard let deliveredDrops = delivered else { continue }
let destination = tx["Destination"] as? String ?? ""
let isIncoming = destination == address
let date = (tx["date"] as? NSNumber).map { Date(timeIntervalSince1970: $0.doubleValue + rippleEpoch) }
records.append(TransactionRecord(
id: entry["hash"] as? String ?? tx["hash"] as? String ?? UUID().uuidString,
amount: XRPAmount.xrp(fromDrops: deliveredDrops),
timestamp: date,
direction: isIncoming ? .incoming : .outgoing,
confirmed: (entry["validated"] as? Bool) ?? false,
fee: (tx["Fee"] as? String).map { XRPAmount.xrp(fromDrops: $0) },
tag: (tx["DestinationTag"] as? NSNumber)?.intValue,
counterparty: isIncoming ? (tx["Account"] as? String) : destination,
height: (entry["ledger_index"] as? NSNumber)?.uint64Value,
ledgerIndex: (entry["ledger_index"] as? NSNumber)?.uint64Value,
sequence: (tx["Sequence"] as? NSNumber)?.uint64Value))
}
// Newest first, matching the Monero list.
return records.sorted { ($0.timestamp ?? .distantFuture) > ($1.timestamp ?? .distantFuture) }
}
// MARK: - Sends
func send(secret: String,
to: String,
amountXRP: String,
destinationTag: UInt32?) async throws -> (hash: String, fee: String) {
let destination = to.trimmingCharacters(in: .whitespacesAndNewlines)
guard XRPKey.isValidAddress(destination) else {
throw XRPError.rpc("Enter a valid XRP address")
}
let keypair = try XRPKey.keypair(fromSeed: secret)
let amountDrops = try XRPAmount.drops(fromXRP: amountXRP)
async let infoCall = XRPClient.shared.rpc("account_info",
["account": keypair.address,
"ledger_index": "current"])
async let feeCall = XRPClient.shared.rpc("fee", [:])
async let ledgerCall = XRPClient.shared.rpc("ledger_current", [:])
let info = try await infoCall
let accountData = info["account_data"] as? [String: Any] ?? [:]
guard let sequence = (accountData["Sequence"] as? NSNumber)?.uint32Value else {
throw XRPError.rpc("Could not read the account sequence")
}
let balanceDrops = UInt64((accountData["Balance"] as? String) ?? "0") ?? 0
let feeResult = try await feeCall
let feeDrops = (feeResult["drops"] as? [String: Any])
.flatMap { $0["open_ledger_fee"] as? String }
.flatMap { UInt64($0) } ?? 12
let ledger = try await ledgerCall
let ledgerIndex = (ledger["ledger_current_index"] as? NSNumber)?.uint32Value
guard amountDrops + feeDrops <= balanceDrops else {
throw XRPError.rpc("Insufficient balance to cover the amount and network fee")
}
var payment = XRPPayment(account: keypair.address,
destination: destination,
amountDrops: amountDrops,
feeDrops: feeDrops,
sequence: sequence,
lastLedgerSequence: ledgerIndex.map { $0 + 20 },
destinationTag: destinationTag,
signingPublicKey: keypair.publicKey,
signature: nil)
let signature = try XRPKey.sign(message: payment.signingPayload(), keypair: keypair)
payment.signature = signature
let blob = Hex.encode(try payment.txBlob())
let result = try await XRPClient.shared.rpc("submit", ["tx_blob": blob])
let engineResult = result["engine_result"] as? String ?? ""
guard engineResult.hasPrefix("tes") else {
let message = result["engine_result_message"] as? String ?? "Transaction failed"
throw XRPError.rpc("\(engineResult): \(message)")
}
return (Hex.encode(try payment.transactionHash()), XRPAmount.xrp(fromDrops: feeDrops))
}
}
+120
View File
@@ -0,0 +1,120 @@
# Monero native library for iOS
The wallet runs monero-project `wallet2` (plus monero-cpp as the C++ API) on
device. Everything is compiled into a single static archive that the app links.
## Artifacts
| File | Architecture | Platform |
|---|---|---|
| `libs/libkoins_monero_merged.a` | arm64 | iOS simulator |
| `libs/libkoins_monero_device.a` | arm64 | iOS device |
Both are needed: they are the same CPU architecture but different platforms, so
they cannot be merged into one fat archive. `project.yml` selects the right one
per SDK.
## Prerequisites
- Xcode with the iOS device and simulator SDKs
- CMake 3.2x (monero 0.18 is not tested with CMake 4)
- GNU make (the builds use the "Unix Makefiles" generator)
- Roughly 10 GB of disk and a long coffee break
## Sources
- monero-project v0.18.5.1
- monero-cpp (woodser), which keeps monero-project at `external/monero-project`
## Toolchain customisations
The files under `depends/` are the ones modified from upstream, kept here so the
archive can be regenerated:
| File | Change |
|---|---|
| `depends/hosts/ios.mk` | new host; `IOS_PLATFORM=SIMULATOR` (default) or `DEVICE` selects the SDK and the target triple (`aarch64-apple-ios[-simulator]`) |
| `depends/packages/packages.mk` | `ios_packages := boost openssl zeromq expat unbound sodium` (no libiconv) |
| `depends/packages/boost.mk` | boost for the `ios` toolset: darwin toolset, libtool archiver, c++11, no b2 python |
| `depends/packages/zeromq.mk` | ios config opts (`--host=aarch64-apple-darwin`), no libsodium, no curve |
| `depends/packages/unbound.mk` | drops the autoconf dependency |
| `depends/packages/sodium.mk` | drops the autoconf dependency |
| `depends/funcs.mk` | create the prefix `lib` directory before extracting dependency archives |
One source change is also required, in monero-project:
- `src/cryptonote_basic/miner.cpp` — wrap the IOKit includes and the related
code with `defined(TARGET_OS_OSX) && TARGET_OS_OSX`; IOKit is unavailable on
iOS. Two locations, around lines 51 and 986.
Two more steps are needed when targeting a device:
- Copy monero's patched boost serialisation headers into the dependency prefix,
because monero's build otherwise only finds them for the platform it was
installed for:
```sh
cp external/boost/archive/portable_binary_*.hpp \
contrib/depends/$HOST/include/boost/archive/
```
- randomx's AArch64 JIT references `__clear_cache`, which libSystem exports on
macOS and the simulator but not on iOS. The app supplies it in
`Koins/Support/ClearCache.c`.
## Build
Set `PLATFORM` to `SIMULATOR` or `DEVICE`; the only differences are the depends
host flag and the output archive name.
```sh
MONERO=/path/to/monero-project
PLATFORM=SIMULATOR # or DEVICE
HOST=aarch64-apple-ios
# 1. Cross-compiled dependencies (boost, openssl, zeromq, expat, unbound, sodium)
cd "$MONERO/contrib/depends"
make HOST=$HOST IOS_PLATFORM=$PLATFORM -j"$(sysctl -n hw.ncpu)"
# 2. monero-project, static wallet library
cd "$MONERO"
mkdir -p build/ios-$PLATFORM && cd build/ios-$PLATFORM
cmake -G "Unix Makefiles" \
-DCMAKE_TOOLCHAIN_FILE="$MONERO/contrib/depends/$HOST/share/toolchain.cmake" \
-DCMAKE_BUILD_TYPE=Release \
-DSTATIC=ON -DBUILD_SHARED_LIBS=OFF \
-DBUILD_TESTS=OFF -DBUILD_DOCUMENTATION=OFF -DUSE_DEVICE_TREZOR=OFF \
../..
make -j"$(sysctl -n hw.ncpu)" wallet # produces lib/libwallet.a
# 3. monero-cpp
cd /path/to/monero-cpp
mkdir -p build-ios-$PLATFORM && cd build-ios-$PLATFORM
cmake -G "Unix Makefiles" \
-DCMAKE_TOOLCHAIN_FILE="$MONERO/contrib/depends/$HOST/share/toolchain.cmake" \
../..
make -j"$(sysctl -n hw.ncpu)" # produces libmonero-cpp.a
# 4. Merge into one archive for the app
libtool -static -o libkoins_monero_merged.a \
libmonero-cpp.a \
"$MONERO/build/ios-$PLATFORM/lib/libwallet.a" \
"$MONERO/build/ios-$PLATFORM"/src/*/lib*.a \
"$MONERO/build/ios-$PLATFORM"/external/*/lib*.a \
"$MONERO/build/ios-$PLATFORM"/contrib/epee/src/libepee.a \
"$MONERO/contrib/depends/$HOST"/lib/lib*.a
```
Copy the result to `libs/`, using `libkoins_monero_merged.a` for the simulator
and `libkoins_monero_device.a` for the device.
## Notes
- Only the simulator archive is needed to run in the simulator; the device
archive is needed for a real iPhone, and Xcode requires the matching one at
link time.
- Building the device slice after a simulator build requires moving the
simulator depends prefix aside first, since depends installs into
`contrib/depends/<host>` and both platforms share the host name.
- The app also links the OpenSSL that is embedded in this archive, which is how
the XRP wallet gets secp256k1 without an extra dependency.
+270
View File
@@ -0,0 +1,270 @@
define int_vars
#Set defaults for vars which may be overridden per-package
$(1)_cc=$($($(1)_type)_CC)
$(1)_cxx=$($($(1)_type)_CXX)
$(1)_objc=$($($(1)_type)_OBJC)
$(1)_objcxx=$($($(1)_type)_OBJCXX)
$(1)_ar=$($($(1)_type)_AR)
$(1)_ranlib=$($($(1)_type)_RANLIB)
$(1)_libtool=$($($(1)_type)_LIBTOOL)
$(1)_nm=$($($(1)_type)_NM)
$(1)_cflags=$($($(1)_type)_CFLAGS) $($($(1)_type)_$(release_type)_CFLAGS)
$(1)_cxxflags=$($($(1)_type)_CXXFLAGS) $($($(1)_type)_$(release_type)_CXXFLAGS)
$(1)_arflags=$($($(1)_type)_ARFLAGS) $($($(1)_type)_$(release_type)_ARFLAGS)
$(1)_ldflags=$($($(1)_type)_LDFLAGS) $($($(1)_type)_$(release_type)_LDFLAGS) -L$($($(1)_type)_prefix)/lib
$(1)_cppflags=$($($(1)_type)_CPPFLAGS) $($($(1)_type)_$(release_type)_CPPFLAGS) -I$($($(1)_type)_prefix)/include
$(1)_recipe_hash:=
endef
define int_get_all_dependencies
$(sort $(foreach dep,$(2),$(2) $(call int_get_all_dependencies,$(1),$($(dep)_dependencies))))
endef
define fetch_file_inner
( mkdir -p $$($(1)_download_dir) && echo Fetching $(3) from $(2) && \
$(build_DOWNLOAD) "$$($(1)_download_dir)/$(4).temp" "$(2)/$(3)" && \
echo "$(5) $$($(1)_download_dir)/$(4).temp" > $$($(1)_download_dir)/.$(4).hash && \
$(build_SHA256SUM) -c $$($(1)_download_dir)/.$(4).hash && \
mv $$($(1)_download_dir)/$(4).temp $$($(1)_source_dir)/$(4) && \
rm -rf $$($(1)_download_dir) )
endef
define fetch_file
( test -f $$($(1)_source_dir)/$(4) || \
( $(call fetch_file_inner,$(1),$(2),$(3),$(4),$(5)) || \
$(call fetch_file_inner,$(1),$(FALLBACK_DOWNLOAD_PATH),$(4),$(4),$(5))))
endef
define int_get_build_recipe_hash
$(eval $(1)_all_file_checksums:=$(shell $(build_SHA256SUM) $(meta_depends) packages/$(1).mk $(addprefix $(PATCHES_PATH)/$(1)/,$($(1)_patches)) | cut -d" " -f1))
$(eval $(1)_recipe_hash:=$(shell echo -n "$($(1)_all_file_checksums)" | $(build_SHA256SUM) | cut -d" " -f1))
endef
define int_get_build_id
$(eval $(1)_dependencies += $($(1)_$(host_arch)_$(host_os)_dependencies) $($(1)_$(host_os)_dependencies))
$(eval $(1)_all_dependencies:=$(call int_get_all_dependencies,$(1),$($($(1)_type)_native_toolchain) $($(1)_dependencies)))
$(foreach dep,$($(1)_all_dependencies),$(eval $(1)_build_id_deps+=$(dep)-$($(dep)_version)-$($(dep)_recipe_hash)))
$(eval $(1)_build_id_long:=$(1)-$($(1)_version)-$($(1)_recipe_hash)-$(release_type) $($(1)_build_id_deps) $($($(1)_type)_id_string))
$(eval $(1)_build_id:=$(shell echo -n "$($(1)_build_id_long)" | $(build_SHA256SUM) | cut -c-$(HASH_LENGTH)))
final_build_id_long+=$($(package)_build_id_long)
#compute package-specific paths
$(1)_build_subdir?=.
$(1)_download_file?=$($(1)_file_name)
$(1)_source_dir:=$(SOURCES_PATH)
$(1)_source:=$$($(1)_source_dir)/$($(1)_file_name)
$(1)_staging_dir=$(base_staging_dir)/$(host)/$(1)/$($(1)_version)-$($(1)_build_id)
$(1)_staging_prefix_dir:=$$($(1)_staging_dir)$($($(1)_type)_prefix)
$(1)_extract_dir:=$(base_build_dir)/$(host)/$(1)/$($(1)_version)-$($(1)_build_id)
$(1)_download_dir:=$(base_download_dir)/$(1)-$($(1)_version)
$(1)_build_dir:=$$($(1)_extract_dir)/$$($(1)_build_subdir)
$(1)_cached_checksum:=$(BASE_CACHE)/$(host)/$(1)/$(1)-$($(1)_version)-$($(1)_build_id).tar.gz.hash
$(1)_patch_dir:=$(base_build_dir)/$(host)/$(1)/$($(1)_version)-$($(1)_build_id)/.patches-$($(1)_build_id)
$(1)_prefixbin:=$($($(1)_type)_prefix)/bin/
$(1)_cached:=$(BASE_CACHE)/$(host)/$(1)/$(1)-$($(1)_version)-$($(1)_build_id).tar.gz
$(1)_all_sources=$($(1)_file_name) $($(1)_extra_sources)
#stamps
$(1)_fetched=$(SOURCES_PATH)/download-stamps/.stamp_fetched-$(1)-$($(1)_file_name).hash
$(1)_extracted=$$($(1)_extract_dir)/.stamp_extracted
$(1)_preprocessed=$$($(1)_extract_dir)/.stamp_preprocessed
$(1)_cleaned=$$($(1)_extract_dir)/.stamp_cleaned
$(1)_built=$$($(1)_build_dir)/.stamp_built
$(1)_configured=$$($(1)_build_dir)/.stamp_configured
$(1)_staged=$$($(1)_staging_dir)/.stamp_staged
$(1)_postprocessed=$$($(1)_staging_prefix_dir)/.stamp_postprocessed
$(1)_download_path_fixed=$(subst :,\:,$$($(1)_download_path))
#default commands
$(1)_fetch_cmds ?= $(call fetch_file,$(1),$(subst \:,:,$$($(1)_download_path_fixed)),$$($(1)_download_file),$($(1)_file_name),$($(1)_sha256_hash))
$(1)_extract_cmds ?= mkdir -p $$($(1)_extract_dir) && echo "$$($(1)_sha256_hash) $$($(1)_source)" > $$($(1)_extract_dir)/.$$($(1)_file_name).hash && $(build_SHA256SUM) -c $$($(1)_extract_dir)/.$$($(1)_file_name).hash && tar --strip-components=1 -xf $$($(1)_source)
$(1)_preprocess_cmds ?=
$(1)_build_cmds ?=
$(1)_config_cmds ?=
$(1)_stage_cmds ?=
$(1)_set_vars ?=
all_sources+=$$($(1)_fetched)
endef
#$(foreach dep_target,$($(1)_all_dependencies),$(eval $(1)_dependency_targets=$($(dep_target)_cached)))
define int_config_attach_build_config
$(eval $(call $(1)_set_vars,$(1)))
$(1)_cflags+=$($(1)_cflags_$(release_type))
$(1)_cflags+=$($(1)_cflags_$(host_arch)) $($(1)_cflags_$(host_arch)_$(release_type))
$(1)_cflags+=$($(1)_cflags_$(host_os)) $($(1)_cflags_$(host_os)_$(release_type))
$(1)_cflags+=$($(1)_cflags_$(host_arch)_$(host_os)) $($(1)_cflags_$(host_arch)_$(host_os)_$(release_type))
$(1)_cxxflags+=$($(1)_cxxflags_$(release_type))
$(1)_cxxflags+=$($(1)_cxxflags_$(host_arch)) $($(1)_cxxflags_$(host_arch)_$(release_type))
$(1)_cxxflags+=$($(1)_cxxflags_$(host_os)) $($(1)_cxxflags_$(host_os)_$(release_type))
$(1)_cxxflags+=$($(1)_cxxflags_$(host_arch)_$(host_os)) $($(1)_cxxflags_$(host_arch)_$(host_os)_$(release_type))
$(1)_arflags+=$($(1)_arflags_$(release_type))
$(1)_arflags+=$($(1)_arflags_$(host_arch)) $($(1)_arflags_$(host_arch)_$(release_type))
$(1)_arflags+=$($(1)_arflags_$(host_os)) $($(1)_arflags_$(host_os)_$(release_type))
$(1)_arflags+=$($(1)_arflags_$(host_arch)_$(host_os)) $($(1)_arflags_$(host_arch)_$(host_os)_$(release_type))
$(1)_cppflags+=$($(1)_cppflags_$(release_type))
$(1)_cppflags+=$($(1)_cppflags_$(host_arch)) $($(1)_cppflags_$(host_arch)_$(release_type))
$(1)_cppflags+=$($(1)_cppflags_$(host_os)) $($(1)_cppflags_$(host_os)_$(release_type))
$(1)_cppflags+=$($(1)_cppflags_$(host_arch)_$(host_os)) $($(1)_cppflags_$(host_arch)_$(host_os)_$(release_type))
$(1)_ldflags+=$($(1)_ldflags_$(release_type))
$(1)_ldflags+=$($(1)_ldflags_$(host_arch)) $($(1)_ldflags_$(host_arch)_$(release_type))
$(1)_ldflags+=$($(1)_ldflags_$(host_os)) $($(1)_ldflags_$(host_os)_$(release_type))
$(1)_ldflags+=$($(1)_ldflags_$(host_arch)_$(host_os)) $($(1)_ldflags_$(host_arch)_$(host_os)_$(release_type))
$(1)_build_opts+=$$($(1)_build_opts_$(release_type))
$(1)_build_opts+=$$($(1)_build_opts_$(host_arch)) $$($(1)_build_opts_$(host_arch)_$(release_type))
$(1)_build_opts+=$$($(1)_build_opts_$(host_os)) $$($(1)_build_opts_$(host_os)_$(release_type))
$(1)_build_opts+=$$($(1)_build_opts_$(host_arch)_$(host_os)) $$($(1)_build_opts_$(host_arch)_$(host_os)_$(release_type))
$(1)_config_opts+=$$($(1)_config_opts_$(release_type))
$(1)_config_opts+=$$($(1)_config_opts_$(host_arch)) $$($(1)_config_opts_$(host_arch)_$(release_type))
$(1)_config_opts+=$$($(1)_config_opts_$(host_os)) $$($(1)_config_opts_$(host_os)_$(release_type))
$(1)_config_opts+=$$($(1)_config_opts_$(host_arch)_$(host_os)) $$($(1)_config_opts_$(host_arch)_$(host_os)_$(release_type))
$(1)_config_env+=$$($(1)_config_env_$(release_type))
$(1)_config_env+=$($(1)_config_env_$(host_arch)) $($(1)_config_env_$(host_arch)_$(release_type))
$(1)_config_env+=$($(1)_config_env_$(host_os)) $($(1)_config_env_$(host_os)_$(release_type))
$(1)_config_env+=$($(1)_config_env_$(host_arch)_$(host_os)) $($(1)_config_env_$(host_arch)_$(host_os)_$(release_type))
$(1)_build_env+=$$($(1)_build_env_$(release_type))
$(1)_build_env+=$($(1)_build_env_$(host_arch)) $($(1)_build_env_$(host_arch)_$(release_type))
$(1)_build_env+=$($(1)_build_env_$(host_os)) $($(1)_build_env_$(host_os)_$(release_type))
$(1)_build_env+=$($(1)_build_env_$(host_arch)_$(host_os)) $($(1)_build_env_$(host_arch)_$(host_os)_$(release_type))
$(1)_config_env+=PKG_CONFIG_LIBDIR=$($($(1)_type)_prefix)/lib/pkgconfig
$(1)_config_env+=PKG_CONFIG_PATH=$($($(1)_type)_prefix)/share/pkgconfig
$(1)_config_env+=PATH="$(build_prefix)/bin:$(PATH)"
$(1)_build_env+=PATH="$(build_prefix)/bin:$(PATH)"
$(1)_stage_env+=PATH="$(build_prefix)/bin:$(PATH)"
$(1)_autoconf=./configure --build=$(BUILD) --host=$($($(1)_type)_host) --prefix=$($($(1)_type)_prefix) $$($(1)_config_opts) CC="$$($(1)_cc)" CXX="$$($(1)_cxx)"
ifeq ($(filter $(1),libusb unbound),)
$(1)_autoconf += --disable-dependency-tracking
endif
ifneq ($($(1)_nm),)
$(1)_autoconf += NM="$$($(1)_nm)"
endif
ifneq ($($(1)_ranlib),)
$(1)_autoconf += RANLIB="$$($(1)_ranlib)"
endif
ifneq ($($(1)_ar),)
$(1)_autoconf += AR="$$($(1)_ar)"
endif
ifneq ($($(1)_arflags),)
$(1)_autoconf += ARFLAGS="$$($(1)_arflags)"
endif
ifneq ($($(1)_cflags),)
$(1)_autoconf += CFLAGS="$$($(1)_cflags)"
endif
ifneq ($($(1)_cxxflags),)
$(1)_autoconf += CXXFLAGS="$$($(1)_cxxflags)"
endif
ifneq ($($(1)_cppflags),)
$(1)_autoconf += CPPFLAGS="$$($(1)_cppflags)"
endif
ifneq ($($(1)_ldflags),)
$(1)_autoconf += LDFLAGS="$$($(1)_ldflags)"
endif
endef
define int_add_cmds
$($(1)_fetched):
$(AT)mkdir -p $$(@D) $(SOURCES_PATH)
$(AT)rm -f $$@
$(AT)touch $$@
$(AT)cd $$(@D); $(call $(1)_fetch_cmds,$(1))
$(AT)cd $($(1)_source_dir); $(foreach source,$($(1)_all_sources),$(build_SHA256SUM) $(source) >> $$(@);)
$(AT)touch $$@
$($(1)_extracted): | $($(1)_fetched)
$(AT)echo Extracting $(1)...
$(AT)mkdir -p $$(@D)
$(AT)cd $$(@D); $(call $(1)_extract_cmds,$(1))
$(AT)touch $$@
$($(1)_preprocessed): | $($(1)_dependencies) $($(1)_extracted)
$(AT)echo Preprocessing $(1)...
$(AT)mkdir -p $$(@D) $($(1)_patch_dir)
$(AT)$(foreach patch,$($(1)_patches),cd $(PATCHES_PATH)/$(1); cp $(patch) $($(1)_patch_dir) ;)
$(AT)cd $$(@D); $(call $(1)_preprocess_cmds, $(1))
$(AT)touch $$@
$($(1)_configured): | $($(1)_preprocessed)
$(AT)echo Configuring $(1)...
$(AT)rm -rf $(host_prefix); mkdir -p $(host_prefix)/lib; cd $(host_prefix); $(foreach package,$($(1)_all_dependencies), $(if $(strip $($(package)_cached)), tar xf $($(package)_cached);,))
$(AT)mkdir -p $$(@D)
$(AT)+cd $$(@D); $($(1)_config_env) $(call $(1)_config_cmds, $(1))
$(AT)touch $$@
$($(1)_built): | $($(1)_configured)
$(AT)echo Building $(1)...
$(AT)mkdir -p $$(@D)
$(AT)+cd $$(@D); $($(1)_build_env) $(call $(1)_build_cmds, $(1))
$(AT)touch $$@
$($(1)_staged): | $($(1)_built)
$(AT)echo Staging $(1)...
$(AT)mkdir -p $($(1)_staging_dir)/$(host_prefix)
$(AT)cd $($(1)_build_dir); $($(1)_stage_env) $(call $(1)_stage_cmds, $(1))
$(AT)rm -rf $($(1)_extract_dir)
$(AT)touch $$@
$($(1)_postprocessed): | $($(1)_staged)
$(AT)echo Postprocessing $(1)...
$(AT)cd $($(1)_staging_prefix_dir); $(call $(1)_postprocess_cmds)
$(AT)touch $$@
$($(1)_cached): | $($(1)_dependencies) $($(1)_postprocessed)
$(AT)echo Caching $(1)...
$(AT)cd $$($(1)_staging_dir)/$(host_prefix); find . | sort | tar --no-recursion -czf $$($(1)_staging_dir)/$$(@F) -T -
$(AT)mkdir -p $$(@D)
$(AT)rm -rf $$(@D) && mkdir -p $$(@D)
$(AT)mv $$($(1)_staging_dir)/$$(@F) $$(@)
$(AT)rm -rf $($(1)_staging_dir)
$($(1)_cached_checksum): $($(1)_cached)
$(AT)cd $$(@D); $(build_SHA256SUM) $$(<F) > $$(@)
.PHONY: $(1)
$(1): | $($(1)_cached_checksum)
.SECONDARY: $($(1)_cached) $($(1)_postprocessed) $($(1)_staged) $($(1)_built) $($(1)_configured) $($(1)_preprocessed) $($(1)_extracted) $($(1)_fetched)
endef
stages = fetched extracted preprocessed configured built staged postprocessed cached cached_checksum
define ext_add_stages
$(foreach stage,$(stages),
$(1)_$(stage): $($(1)_$(stage))
.PHONY: $(1)_$(stage))
endef
# These functions create the build targets for each package. They must be
# broken down into small steps so that each part is done for all packages
# before moving on to the next step. Otherwise, a package's info
# (build-id for example) would only be available to another package if it
# happened to be computed already.
#set the type for host/build packages.
$(foreach native_package,$(native_packages),$(eval $(native_package)_type=build))
$(foreach package,$(packages),$(eval $(package)_type=$(host_arch)_$(host_os)))
#set overridable defaults
$(foreach package,$(all_packages),$(eval $(call int_vars,$(package))))
#include package files
$(foreach package,$(all_packages),$(eval include packages/$(package).mk))
#compute a hash of all files that comprise this package's build recipe
$(foreach package,$(all_packages),$(eval $(call int_get_build_recipe_hash,$(package))))
#generate a unique id for this package, incorporating its dependencies as well
$(foreach package,$(all_packages),$(eval $(call int_get_build_id,$(package))))
#compute final vars after reading package vars
$(foreach package,$(all_packages),$(eval $(call int_config_attach_build_config,$(package))))
#create build targets
$(foreach package,$(all_packages),$(eval $(call int_add_cmds,$(package))))
#special exception: if a toolchain package exists, all non-native packages depend on it
$(foreach package,$(packages),$(eval $($(package)_unpacked): |$($($(host_arch)_$(host_os)_native_toolchain)_cached) ))
@@ -0,0 +1,41 @@
# iOS host for monero depends system (arm64 simulator for dev).
# Targets modern iOS (14+) on Apple Silicon simulators / devices.
IOS_MIN_VERSION=13.0
LD64_VERSION=609
ifndef IOS_PLATFORM
IOS_PLATFORM=SIMULATOR
endif
ifeq ($(IOS_PLATFORM),SIMULATOR)
IOS_SDK_ROOT := $(shell xcrun --sdk iphonesimulator --show-sdk-path)
CC_target=aarch64-apple-ios-simulator
else
IOS_SDK_ROOT := $(shell xcrun --sdk iphoneos --show-sdk-path)
CC_target=aarch64-apple-ios
endif
ios_CC=clang -target $(CC_target) -miphoneos-version-min=$(IOS_MIN_VERSION) --sysroot $(IOS_SDK_ROOT) -mlinker-version=$(LD64_VERSION)
ios_CXX=clang++ -target $(CC_target) -miphoneos-version-min=$(IOS_MIN_VERSION) --sysroot $(IOS_SDK_ROOT) -mlinker-version=$(LD64_VERSION) -stdlib=libc++
ios_CFLAGS=-pipe -isysroot $(IOS_SDK_ROOT)
ios_CXXFLAGS=-pipe -isysroot $(IOS_SDK_ROOT) -std=c++14 -stdlib=libc++
ios_ARFLAGS=cr
ios_release_CFLAGS=-O1
ios_release_CXXFLAGS=$(ios_release_CFLAGS)
ios_debug_CFLAGS=-O1
ios_debug_CXXFLAGS=$(ios_debug_CFLAGS)
ios_native_toolchain=native_cctools darwin_sdk
# iOS uses the system (macOS) ar/ranlib/strip/nm for cross-compiled archives.
ios_AR=ar
ios_RANLIB=ranlib
ios_STRIP=strip
ios_NM=nm
ios_LIBTOOL=libtool
ios_OTOOL=otool
ios_INSTALL_NAME_TOOL=install_name_tool
@@ -0,0 +1,46 @@
package=boost
$(package)_version=1.76.0
$(package)_download_path=https://archives.boost.io/release/$($(package)_version)/source/
$(package)_file_name=$(package)_$(subst .,_,$($(package)_version)).tar.gz
$(package)_sha256_hash=7bd7ddceec1a1dfdcbdb3e609b60d01739c38390a5f956385a12f3122049f0ca
$(package)_dependencies=
$(package)_patches=fix_integral_wrapper.patch
define $(package)_set_vars
$(package)_config_opts_release=variant=release
$(package)_config_opts_debug=variant=debug
$(package)_config_opts+=--layout=system --user-config=user-config.jam
$(package)_config_opts+=threading=multi link=static -sNO_BZIP2=1 -sNO_ZLIB=1
$(package)_config_opts_linux=threadapi=pthread runtime-link=shared
$(package)_config_opts_android=threadapi=pthread runtime-link=static target-os=android
$(package)_config_opts_darwin=--toolset=darwin runtime-link=shared
$(package)_config_opts_mingw32=binary-format=pe target-os=windows threadapi=win32 runtime-link=static
$(package)_config_opts_x86_64_mingw32=address-model=64
$(package)_config_opts_i686_mingw32=address-model=32
$(package)_config_opts_i686_linux=address-model=32 architecture=x86
$(package)_toolset_$(host_os)=gcc
$(package)_archiver_$(host_os)=$($(package)_ar)
$(package)_toolset_darwin=darwin
$(package)_archiver_darwin=$($(package)_libtool)
$(package)_config_libraries=chrono,filesystem,program_options,system,thread,test,date_time,regex,serialization,locale
$(package)_toolset_ios=darwin
$(package)_archiver_ios=libtool
$(package)_arflags_ios=
$(package)_cxxflags_ios=-fPIC -std=c++11 -stdlib=libc++
$(package)_cxxflags=-std=c++11
$(package)_cxxflags_linux=-fPIC
$(package)_cxxflags_freebsd=-fPIC
endef
define $(package)_preprocess_cmds
patch -p1 < $($(package)_patch_dir)/fix_integral_wrapper.patch &&\
echo "using $(boost_toolset_$(host_os)) : : $($(package)_cxx) : <root>\"$(IOS_SDK_ROOT)\" <cxxflags>\"$($(package)_cxxflags) $($(package)_cppflags) -isysroot $(IOS_SDK_ROOT)\" <linkflags>\"$($(package)_ldflags) -isysroot $(IOS_SDK_ROOT)\" <archiver>\"$(boost_archiver_$(host_os))\" <arflags>\"$($(package)_arflags)\" <striper>\"$(host_STRIP)\" <ranlib>\"$(host_RANLIB)\" <rc>\"$(host_WINDRES)\" : ;" > user-config.jam
endef
define $(package)_config_cmds
./bootstrap.sh --with-libraries=$(boost_config_libraries)
endef
define $(package)_stage_cmds
./b2 -d0 -j4 --prefix=$($(package)_staging_prefix_dir) $($(package)_config_opts) install
endef
@@ -0,0 +1,43 @@
packages:=boost openssl zeromq expat unbound
# iOS: iconv is provided by the system; libiconv-1.15 fails with modern clang.
ios_packages := boost openssl zeromq expat unbound sodium
# ccache is useless in gitian builds
ifneq ($(GITIAN),1)
native_packages := native_ccache
endif
hardware_packages := hidapi protobuf libusb
hardware_native_packages := native_protobuf
android_native_packages = android_ndk
android_packages = ncurses readline sodium
darwin_native_packages = $(hardware_native_packages)
darwin_packages = ncurses readline sodium $(hardware_packages)
# not really native...
freebsd_native_packages = freebsd_base
freebsd_packages = ncurses readline sodium
linux_packages = eudev ncurses readline sodium $(hardware_packages)
linux_native_packages = $(hardware_native_packages)
ifeq ($(build_tests),ON)
packages += gtest
endif
ifneq ($(host_arch),riscv64)
linux_packages += unwind
endif
mingw32_packages = icu4c sodium $(hardware_packages)
mingw32_native_packages = $(hardware_native_packages)
ifneq ($(build_os),darwin)
darwin_native_packages += darwin_sdk native_clang native_cctools native_libtapi
endif

Some files were not shown because too many files have changed in this diff Show More