Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ff5385a9b | ||
|
|
2f70931cba | ||
|
|
e67ed9e063 | ||
|
|
c9679e35c4 | ||
|
|
7e739e1b77 | ||
|
|
4fa1584dfc | ||
|
|
31429e6923 | ||
|
|
782143e87b | ||
|
|
994ba5ed6b | ||
|
|
e3846fe881 | ||
|
|
edfefb6210 | ||
|
|
58d862161e | ||
|
|
0b1b253d30 | ||
|
|
78e1cefad4 | ||
|
|
b989b0f791 | ||
|
|
2728bf161e | ||
|
|
51dc007107 | ||
|
|
27dc122632 | ||
|
|
e9720391b7 | ||
|
|
c0903f4f02 | ||
|
|
399502cf72 | ||
|
|
e53c6aa7a9 | ||
|
|
021672b47c | ||
|
|
caca4321cc | ||
|
|
1a23c730c6 | ||
|
|
807388ea6b | ||
|
|
f4037a188a | ||
|
|
6d50566f37 | ||
|
|
6b52b32e20 | ||
|
|
a3af802166 | ||
|
|
3e1a39d4d7 | ||
|
|
d80b58b704 | ||
|
|
babd85e614 | ||
|
|
d15152a013 | ||
|
|
3913889a22 | ||
|
|
da0c56658b | ||
|
|
743700b82c | ||
|
|
8953596194 | ||
|
|
5df3ae7058 | ||
|
|
7b4ba84fee | ||
|
|
20f29faa80 | ||
|
|
c0f6417925 | ||
|
|
c61fea938a | ||
|
|
bdfee2a553 | ||
|
|
1c5e5b617f | ||
|
|
11e0bcdaf6 | ||
|
|
4270d380fb | ||
|
|
af6251669a | ||
|
|
6eec1f998d | ||
|
|
d9ca8a0047 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Generated by XcodeGen from project.yml
|
||||
Koins.xcodeproj/
|
||||
|
||||
# Build output
|
||||
build/
|
||||
DerivedData/
|
||||
*.xcworkspace
|
||||
xcuserdata/
|
||||
*.xcuserstate
|
||||
@@ -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 |
@@ -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 |
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
@@ -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)]))
|
||||
}
|
||||
}
|
||||
@@ -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))))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
}
|
||||
@@ -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...])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
Reference in New Issue
Block a user