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 |
@@ -5,6 +5,16 @@
|
||||
- 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 through the whole pipeline without pausing between steps: build, notarize, verify, upload to the GitHub release, update the Homebrew cask, and push the tap repo. One permission covers the entire publish flow. You must still get a separate permission before starting each new publish cycle.
|
||||
- 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.6',
|
||||
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",
|
||||
|
||||
@@ -41,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
|
||||
@@ -62,7 +89,11 @@ async function waitForRpc(
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const conn = rpcConnection(state)
|
||||
const res = await conn.sendJsonRequest('get_version', {})
|
||||
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
|
||||
@@ -290,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 }
|
||||
}
|
||||
@@ -329,7 +364,7 @@ export async function openWallet(
|
||||
state.wallet = await connectClient(state)
|
||||
}
|
||||
try {
|
||||
await state.wallet.openWallet(name, password)
|
||||
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)
|
||||
@@ -348,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 }
|
||||
}
|
||||
@@ -360,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
|
||||
@@ -398,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) {
|
||||
@@ -543,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
|
||||
}
|
||||
@@ -587,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) {
|
||||
@@ -665,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
|
||||
}
|
||||
}
|
||||
@@ -677,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)
|
||||
|
||||
@@ -108,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(
|
||||
@@ -144,25 +151,30 @@ export function createMoneroHandlers(state: {
|
||||
console.log('[rpc] moneroGetBalance')
|
||||
if (!state.manager)
|
||||
throw new Error('Monero wallet RPC not started')
|
||||
const { balance, unlocked } = await walletGetBalance(
|
||||
state.manager,
|
||||
)
|
||||
const address = await walletGetAddress(state.manager)
|
||||
const height = await walletGetHeight(state.manager)
|
||||
const daemonHeight = await walletGetDaemonHeight(state.manager)
|
||||
console.log('[rpc] balance:', {
|
||||
balance: balance.toString(),
|
||||
unlocked: unlocked.toString(),
|
||||
address,
|
||||
height,
|
||||
daemonHeight,
|
||||
})
|
||||
return {
|
||||
balance: balance.toString(),
|
||||
unlocked: unlocked.toString(),
|
||||
address,
|
||||
height,
|
||||
daemonHeight,
|
||||
try {
|
||||
const { balance, unlocked } = await walletGetBalance(
|
||||
state.manager,
|
||||
)
|
||||
const address = await walletGetAddress(state.manager)
|
||||
const height = await walletGetHeight(state.manager)
|
||||
const daemonHeight = await walletGetDaemonHeight(state.manager)
|
||||
console.log('[rpc] balance:', {
|
||||
balance: balance.toString(),
|
||||
unlocked: unlocked.toString(),
|
||||
address,
|
||||
height,
|
||||
daemonHeight,
|
||||
})
|
||||
return {
|
||||
balance: balance.toString(),
|
||||
unlocked: unlocked.toString(),
|
||||
address,
|
||||
height,
|
||||
daemonHeight,
|
||||
}
|
||||
} catch (e) {
|
||||
logError('[rpc] moneroGetBalance failed', e)
|
||||
throw e
|
||||
}
|
||||
},
|
||||
moneroGetTransactions: async ({
|
||||
@@ -173,12 +185,17 @@ export function createMoneroHandlers(state: {
|
||||
console.log('[rpc] moneroGetTransactions', { accountIndex })
|
||||
if (!state.manager)
|
||||
throw new Error('Monero wallet RPC not started')
|
||||
const txs = await walletGetTransactions(
|
||||
state.manager,
|
||||
accountIndex,
|
||||
)
|
||||
console.log(`[rpc] moneroGetTransactions: ${txs.length} txs`)
|
||||
return txs
|
||||
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,14 +106,19 @@ export const MoneroWallet = () => {
|
||||
return authed === true
|
||||
}
|
||||
|
||||
let initPromise: Promise<void> | null = null
|
||||
const init = async () => {
|
||||
await checkStatus()
|
||||
if (installed && !running && !downloading) {
|
||||
await start()
|
||||
if (initPromise) return initPromise
|
||||
initPromise = (async () => {
|
||||
await checkStatus()
|
||||
}
|
||||
await checkBiometric()
|
||||
ready = true
|
||||
if (installed && !running && !downloading) {
|
||||
await start()
|
||||
await checkStatus()
|
||||
}
|
||||
await checkBiometric()
|
||||
ready = true
|
||||
})()
|
||||
return initPromise
|
||||
}
|
||||
|
||||
const login = async () => {
|
||||
@@ -203,14 +234,22 @@ export const MoneroWallet = () => {
|
||||
const checkStatus = async () => {
|
||||
if (!electrobun.rpc) return
|
||||
const [binStatus] = await tryCatch(
|
||||
electrobun.rpc.request.moneroBinaryStatus({}),
|
||||
rpcTimeout(
|
||||
electrobun.rpc.request.moneroBinaryStatus({}),
|
||||
15000,
|
||||
'moneroBinaryStatus',
|
||||
),
|
||||
)
|
||||
if (binStatus) {
|
||||
installed = binStatus.installed
|
||||
downloading = binStatus.downloading
|
||||
}
|
||||
const [status] = await tryCatch(
|
||||
electrobun.rpc.request.moneroWalletStatus({}),
|
||||
rpcTimeout(
|
||||
electrobun.rpc.request.moneroWalletStatus({}),
|
||||
30000,
|
||||
'moneroWalletStatus',
|
||||
),
|
||||
)
|
||||
if (status) {
|
||||
running = status.running
|
||||
@@ -362,31 +401,125 @@ 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
|
||||
loading = true
|
||||
try {
|
||||
const [bal] = await tryCatch(
|
||||
electrobun.rpc.request.moneroGetBalance({}),
|
||||
)
|
||||
if (bal) {
|
||||
balAtomic = bal.balance
|
||||
unlockedAtomic = bal.unlocked
|
||||
address = bal.address
|
||||
height = bal.height
|
||||
daemonHeight = bal.daemonHeight
|
||||
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, balErr] = await tryCatch(
|
||||
rpcTimeout(
|
||||
rpc.request.moneroGetBalance({}),
|
||||
20000,
|
||||
'moneroGetBalance',
|
||||
),
|
||||
)
|
||||
if (balErr) {
|
||||
failed = true
|
||||
lastError = balErr
|
||||
}
|
||||
if (bal) {
|
||||
balAtomic = bal.balance
|
||||
unlockedAtomic = bal.unlocked
|
||||
address = bal.address
|
||||
height = bal.height
|
||||
daemonHeight = bal.daemonHeight
|
||||
}
|
||||
const [result, txErr] = await tryCatch(
|
||||
rpcTimeout(
|
||||
rpc.request.moneroGetTransactions({
|
||||
accountIndex: selectedAccountIndex,
|
||||
}),
|
||||
20000,
|
||||
'moneroGetTransactions',
|
||||
),
|
||||
)
|
||||
if (txErr) {
|
||||
failed = true
|
||||
lastError = txErr
|
||||
}
|
||||
txs = result ?? []
|
||||
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) {
|
||||
syncing = true
|
||||
scheduleSyncRetry()
|
||||
frontendLogError(
|
||||
`monero refresh failed (height ${height}/${daemonHeight})`,
|
||||
e,
|
||||
)
|
||||
} finally {
|
||||
loading = false
|
||||
refreshInFlight = null
|
||||
}
|
||||
const [result] = await tryCatch(
|
||||
electrobun.rpc.request.moneroGetTransactions({
|
||||
accountIndex: selectedAccountIndex,
|
||||
}),
|
||||
)
|
||||
txs = result ?? []
|
||||
await fetchAccounts()
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Monero refresh failed'
|
||||
})()
|
||||
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 {
|
||||
loading = false
|
||||
priceInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,6 +639,12 @@ export const MoneroWallet = () => {
|
||||
get passwordRequired() {
|
||||
return passwordRequired
|
||||
},
|
||||
get syncing() {
|
||||
return syncing
|
||||
},
|
||||
get price() {
|
||||
return price
|
||||
},
|
||||
get error() {
|
||||
return error
|
||||
},
|
||||
@@ -555,6 +694,7 @@ export const MoneroWallet = () => {
|
||||
autoUnlock,
|
||||
unlockWallet,
|
||||
refresh,
|
||||
fetchPrice,
|
||||
send,
|
||||
sendAll,
|
||||
createAccount,
|
||||
@@ -562,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,19 +76,24 @@ export const XrpWallet = () => {
|
||||
biometricAvailable = ok === true
|
||||
}
|
||||
|
||||
let initPromise: Promise<void> | null = null
|
||||
const init = async () => {
|
||||
if (!electrobun.rpc) return
|
||||
const [walletList] = await tryCatch(
|
||||
electrobun.rpc.request.xrpListWallets({}),
|
||||
)
|
||||
if (walletList) {
|
||||
wallets = walletList
|
||||
if (walletList.length > 0 && !currentWalletId) {
|
||||
currentWalletId = walletList[0].id
|
||||
if (initPromise) return initPromise
|
||||
initPromise = (async () => {
|
||||
if (!electrobun.rpc) return
|
||||
const [walletList] = await tryCatch(
|
||||
electrobun.rpc.request.xrpListWallets({}),
|
||||
)
|
||||
if (walletList) {
|
||||
wallets = walletList
|
||||
if (walletList.length > 0 && !currentWalletId) {
|
||||
currentWalletId = walletList[0].id
|
||||
}
|
||||
}
|
||||
}
|
||||
await checkBiometric()
|
||||
ready = true
|
||||
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,30 +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)
|
||||
let fetchXrpInFlight = false
|
||||
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),
|
||||
)
|
||||
|
||||
@@ -47,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,
|
||||
@@ -70,66 +99,23 @@
|
||||
maximumFractionDigits: dp,
|
||||
})
|
||||
|
||||
async function fetchXrp() {
|
||||
if (fetchXrpInFlight) return
|
||||
fetchXrpInFlight = true
|
||||
loadingXrp = true
|
||||
const rpc = electrobun.rpc
|
||||
if (!rpc) {
|
||||
loadingXrp = false
|
||||
fetchXrpInFlight = 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
|
||||
fetchXrpInFlight = 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().catch((e) => frontendLogError('xrpWallet.init() failed', e))
|
||||
fetchXmr()
|
||||
})
|
||||
|
||||
// Wait for the XRP wallet list to load, then fetch balances.
|
||||
// Balances live in the stores so they survive navigation; the effects
|
||||
// only trigger background refetches.
|
||||
$effect(() => {
|
||||
if (xrpWallet.ready && xrpWallet.wallets.length > 0) {
|
||||
fetchXrp()
|
||||
xrpWallet.fetchBalances()
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (moneroWallet.walletOpen) {
|
||||
fetchXmr()
|
||||
moneroWallet.fetchPrice()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -189,18 +175,55 @@
|
||||
</div>
|
||||
</div>
|
||||
{: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)}
|
||||
<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">
|
||||
${fmtUsd(portfolioUsd)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col items-center pt-4 pb-2">
|
||||
<p class="text-xs text-muted-foreground">Total Portfolio Value</p>
|
||||
{#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">
|
||||
${fmtUsd(portfolioUsd)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<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')}
|
||||
class="text-left transition-transform active:scale-[0.98]">
|
||||
@@ -210,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 !xrpWallet.ready || loadingXrp || (xrpWallet.wallets.length > 0 && Object.keys(xrpBalances).length === 0)}
|
||||
{#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}
|
||||
@@ -241,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}
|
||||
@@ -285,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">
|
||||
<label class="text-xs font-medium">Recipient Address</label>
|
||||
<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">
|
||||
<label class="text-xs font-medium">Recipient Address</label>
|
||||
<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