36 Commits
Author SHA1 Message Date
TZGyn 3ff5385a9b Show live swap status, explorer links, and full kyc.rip route details 2026-10-26 21:31:41 +08:00
TZGyn 2f70931cba Move the chain switcher into the settings drawer 2026-10-11 20:18:05 +08:00
TZGyn e67ed9e063 Show Monero accounts as their own section below the wallet card 2026-10-11 19:22:21 +08:00
TZGyn c9679e35c4 Give Monero sync its own section and consolidate the wallet buttons 2026-10-10 20:18:41 +08:00
TZGyn 7e739e1b77 Show Monero accounts as a stack of cards you can swipe through 2026-10-07 21:27:57 +08:00
TZGyn 4fa1584dfc Swap XMR and XRP through kyc.rip without leaving the app 2026-10-07 21:01:34 +08:00
TZGyn 31429e6923 Present sheets as a full bottom drawer 2026-10-03 23:42:46 +08:00
TZGyn 782143e87b Offer only USD, MYR and JPY in the currency settings 2026-10-01 22:08:46 +08:00
TZGyn 994ba5ed6b Give each Monero account a swipeable card with its balance and QR 2026-10-01 22:05:19 +08:00
TZGyn e3846fe881 Write the inverted icon variant to Branding, not the icon set 2026-09-29 21:20:58 +08:00
TZGyn edfefb6210 Give the app icon more padding 2026-09-29 21:16:24 +08:00
TZGyn 58d862161e Show each wallet's balance in USD 2026-09-29 21:02:45 +08:00
TZGyn 0b1b253d30 Make the whole transaction row tappable 2026-09-28 22:23:54 +08:00
TZGyn 78e1cefad4 Require Face ID to open the app and to send 2026-09-27 23:35:26 +08:00
TZGyn b989b0f791 Cache wallet balance and activity so screens show it instantly 2026-09-25 21:58:34 +08:00
TZGyn 2728bf161e List transactions newest first on both chains 2026-09-24 21:14:55 +08:00
TZGyn 51dc007107 Use the official Monero mark for the app icon 2026-09-23 21:08:23 +08:00
TZGyn 27dc122632 Fix the QR scanner showing a black preview 2026-09-22 22:45:25 +08:00
TZGyn e9720391b7 Add a black and white Monero app icon 2026-09-22 22:28:46 +08:00
TZGyn c0903f4f02 Fill in payments by scanning a QR code or picking one from photos 2026-09-20 02:00:52 +08:00
TZGyn 399502cf72 Stop a slow chain refresh from showing the wrong wallet balance 2026-09-20 01:30:42 +08:00
TZGyn e53c6aa7a9 Show each address QR in a bottom drawer 2026-09-20 00:57:51 +08:00
TZGyn 021672b47c Show full transaction details for Monero and XRP 2026-09-20 00:43:09 +08:00
TZGyn caca4321cc Add Monero subaccounts and subaddresses with a QR per address 2026-09-20 00:11:53 +08:00
TZGyn 1a23c730c6 Keep the Monero wallet out of iCloud backups 2026-09-19 22:54:58 +08:00
TZGyn 807388ea6b Keep Monero scan progress across app restarts 2026-09-19 22:17:00 +08:00
TZGyn f4037a188a Sign the iOS app for real devices and stop bundling the static archives 2026-09-19 19:25:01 +08:00
TZGyn 6d50566f37 Note the device build steps in the iOS readme 2026-09-19 13:44:11 +08:00
TZGyn 6b52b32e20 Document how the Monero iOS library is built 2026-09-19 13:43:08 +08:00
TZGyn a3af802166 Support real iOS devices with a device build of the Monero library 2026-09-19 13:42:04 +08:00
TZGyn 3e1a39d4d7 Replace the JavaScript mobile app with a native Swift iOS wallet 2026-09-19 13:33:11 +08:00
TZGyn d80b58b704 Point publish instructions at Gitea 2026-09-11 16:21:39 +08:00
TZGyn babd85e614 add vue lynx mobile wallet 2026-08-09 18:09:21 +08:00
TZGyn d15152a013 bump version to 0.0.12 2026-08-04 22:19:25 +08:00
TZGyn 3913889a22 add portfolio allocation donut chart on dashboard 2026-08-04 22:19:13 +08:00
TZGyn da0c56658b add QR image upload to send pages 2026-08-04 22:19:03 +08:00
98 changed files with 29770 additions and 17 deletions
+11 -1
View File
@@ -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.
+1 -1
View File
@@ -4,7 +4,7 @@ export default {
app: {
name: 'koins',
identifier: 'koins.tzgyn.com',
version: '0.0.11',
version: '0.0.12',
},
build: {
// Vite builds to dist/, we copy from there
+1
View File
@@ -20,6 +20,7 @@
"d3-shape": "^3.2.0",
"electrobun": "^1.18.1",
"env-paths": "^4.0.0",
"jsqr": "^1.4.0",
"mode-watcher": "^1.1.0",
"monero-ts": "^0.11.8",
"qrcode": "^1.5.4",
@@ -0,0 +1,62 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js'
import Loader from '$lib/components/loader.svelte'
import QrCodeIcon from '@lucide/svelte/icons/qr-code'
import { decodeQrFromFile } from '$lib/qr.js'
let {
onDecode,
disabled = false,
}: {
onDecode: (text: string) => void
disabled?: boolean
} = $props()
let fileInput: HTMLInputElement
let busy = $state(false)
let error = $state('')
const handleFile = async (e: Event) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
busy = true
error = ''
try {
const text = await decodeQrFromFile(file)
if (!text) throw new Error('No QR code found in the image')
onDecode(text)
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to read QR code'
} finally {
busy = false
if (fileInput) fileInput.value = ''
}
}
</script>
<div class="flex items-center gap-2">
<input
bind:this={fileInput}
type="file"
accept="image/*"
class="hidden"
onchange={handleFile}
disabled={disabled || busy} />
<Button
type="button"
variant="outline"
size="sm"
class="h-7 gap-1.5"
disabled={disabled || busy}
onclick={() => fileInput?.click()}>
{#if busy}
<Loader class="size-3.5" />
{:else}
<QrCodeIcon size={14} />
{/if}
Scan QR
</Button>
{#if error}
<span class="text-xs text-red-500">{error}</span>
{/if}
</div>
+75
View File
@@ -0,0 +1,75 @@
import jsQR from 'jsqr'
export const decodeQrFromFile = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const url = URL.createObjectURL(file)
const img = new Image()
img.onload = () => {
try {
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Canvas is not supported')
ctx.drawImage(img, 0, 0)
const { data, width, height } = ctx.getImageData(
0,
0,
canvas.width,
canvas.height,
)
resolve(jsQR(data, width, height)?.data ?? '')
} catch (e) {
reject(e)
} finally {
URL.revokeObjectURL(url)
}
}
img.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error('Could not load the image'))
}
img.src = url
})
export type QrParseResult = {
address: string
amount?: string
destinationTag?: string
}
// Handles payment URIs (monero:ADDRESS?tx_amount=..., xrp:rADDR?amount=..&dt=..)
// as well as bare addresses. Returns the last path-like segment before any
// query string so prefixes and /pay paths are skipped.
export const parseQrPayload = (
text: string,
kind: 'xrp' | 'monero',
): QrParseResult => {
let body = text.trim().replace(/\s+/g, '')
body = body.replace(/^(?:[a-z0-9+.-]+):\/?\//i, '')
const [beforeQuery, queryRaw] = body.split('?')
const segments = beforeQuery.split('/')
const address = segments[segments.length - 1]
const params: Record<string, string> = {}
if (queryRaw) {
for (const pair of queryRaw.split('&')) {
const eq = pair.indexOf('=')
const key = (eq >= 0 ? pair.slice(0, eq) : pair).toLowerCase()
const value = eq >= 0 ? decodeURIComponent(pair.slice(eq + 1)) : ''
if (key) params[key] = value
}
}
if (kind === 'xrp') {
return {
address,
amount: params['amount'] || undefined,
destinationTag: params['dt'] || undefined,
}
}
return {
address,
amount: params['tx_amount'] || undefined,
}
}
+80 -11
View File
@@ -15,6 +15,9 @@
import WalletIcon from '@lucide/svelte/icons/wallet'
import Loader from '$lib/components/loader.svelte'
import { frontendLog, frontendLogError } from '$lib/electrobun.js'
import { arc } from 'd3-shape'
import { tweened } from 'svelte/motion'
import { cubicOut } from 'svelte/easing'
let feError = $state<string | null>(null)
@@ -43,6 +46,33 @@
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)
@@ -69,6 +99,8 @@
maximumFractionDigits: dp,
})
const fmtPct = (v: number) => `${Math.round(v * 100)}%`
onMount(() => {
frontendLog('dashboard mounted')
})
@@ -143,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 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="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]">
+11 -1
View File
@@ -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} />
+12 -1
View File
@@ -10,6 +10,8 @@
CardDescription,
} from '$lib/components/ui/card/index.js'
import { navigate } from 'sv-router/generated'
import QrUpload from '$lib/components/qr-upload.svelte'
import { parseQrPayload } from '$lib/qr.js'
const w = xrpWallet
@@ -78,7 +80,16 @@
}}
class="space-y-4">
<div class="space-y-1.5">
<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} />
+9
View File
@@ -0,0 +1,9 @@
# Generated by XcodeGen from project.yml
Koins.xcodeproj/
# Build output
build/
DerivedData/
*.xcworkspace
xcuserdata/
*.xcuserstate
+32
View File
@@ -0,0 +1,32 @@
# Branding
The app icon is the **official Monero mark**, monochrome.
`logo-official.svg` is `meta/safari-pinned-tab.svg` from
[monero-project/monero-site](https://github.com/monero-project/monero-site) —
the Monero Project's own traced, single-colour version of the logo, which is
exactly what a black-and-white icon needs. `logo-official.png` is that file
rasterised to 1024×1024:
```sh
qlmanage -t -s 1024 -o /tmp Branding/logo-official.svg
```
iOS cannot use SVG for app icons, so `make-icons.swift` produces the PNG the
asset catalog needs — the mark as published (black badge, white M) inset to 90%
so the platform's squircle mask does not clip it:
```sh
cd apps/ios
swift Branding/make-icons.swift Koins/Assets.xcassets/AppIcon.appiconset
```
That writes the opaque 1024×1024 app icon (no alpha channel — the App Store
rejects icons with alpha) and `Branding/logo-inverted.png`, the same mark
inverted for dark surfaces (white badge, black M, black ground).
The asset catalog uses the single-size format, so Xcode derives every size the
device needs from the 1024.
Note: the Monero name and mark belong to the Monero Project — check their brand
guidance before shipping this publicly.
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

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

After

Width:  |  Height:  |  Size: 1.0 KiB

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

After

Width:  |  Height:  |  Size: 53 KiB

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,2 @@
#import "KoinsMoneroWallet.h"
#import "KoinsSecp256k1.h"
+16
View File
@@ -0,0 +1,16 @@
#import <Foundation/Foundation.h>
#import "KoinsSecp256k1.h"
NS_ASSUME_NONNULL_BEGIN
/// Objective-C++ bridge to the on-device monero-cpp wallet. A single JSON
/// in/out entry point keeps the C++ surface out of Swift; work is serialized
/// on a private queue because a monero_wallet_full is not thread safe.
@interface KoinsMoneroWallet : NSObject
+ (void)invoke:(NSString *)jsonRequest
completion:(void (^)(NSString *response))completion;
@end
NS_ASSUME_NONNULL_END
+388
View File
@@ -0,0 +1,388 @@
#import "KoinsMoneroWallet.h"
#import <os/log.h>
#include "monero_wallet_full.h"
static std::string walletPath(NSString *name) {
NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
NSString *dir = paths.firstObject ?: NSTemporaryDirectory();
NSFileManager *fm = [NSFileManager defaultManager];
if (![fm fileExistsAtPath:dir]) {
[fm createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:nil error:nil];
}
return std::string([dir stringByAppendingPathComponent:name].UTF8String);
}
// Keep wallet files out of iCloud/iTunes backups: the seed in the Keychain is
// device-only and the file is rebuildable from it, so backing it up would only
// consume the user's quota (it peaks near 120 MB while a wallet restored from
// genesis scans). Must run after every write, since wallet2 rewrites these
// files on each save.
static void excludeWalletFilesFromBackup(const std::string &walletPathString) {
NSString *base = [NSString stringWithUTF8String:walletPathString.c_str()];
if (base.length == 0) return;
for (NSString *suffix in @[ @"", @".keys", @".address.txt", @".mms" ]) {
NSURL *url = [NSURL fileURLWithPath:[base stringByAppendingString:suffix]];
[url setResourceValue:@YES forKey:NSURLIsExcludedFromBackupKey error:nil];
}
}
static monero_wallet_full *g_wallet = nullptr;
static std::string g_walletPath;
static NSString *g_daemonURL = @"xmr-node.cakewallet.com:18081";
static NSArray<NSString *> *g_fallbackDaemons = nil;
static std::atomic<bool> s_cachedConnected(false);
static std::atomic<unsigned long long> s_cachedDaemonHeight(0);
static std::atomic<long long> s_lastDaemonProbe(0);
static void connectDaemon(monero_wallet_full *w) {
if (!w) return;
@autoreleasepool {
if (!g_fallbackDaemons) {
g_fallbackDaemons = @[
@"xmr-node.cakewallet.com:18081",
@"node.monerodevs.org:18089",
@"monero.stackwallet.com:18081",
];
}
NSMutableArray<NSString *> *candidates = [NSMutableArray arrayWithObject:g_daemonURL];
for (NSString *url in g_fallbackDaemons) {
if (![url isEqualToString:g_daemonURL]) [candidates addObject:url];
}
for (NSString *url in candidates) {
try {
w->set_daemon_connection(std::string(url.UTF8String));
if (w->is_connected_to_daemon()) {
g_daemonURL = url;
return;
}
} catch (...) {
// Try the next daemon.
}
}
}
}
@implementation KoinsMoneroWallet
+ (void)invoke:(NSString *)jsonRequest
completion:(void (^)(NSString *response))completion {
static dispatch_queue_t invokeQueue;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
invokeQueue = dispatch_queue_create("com.koins.monero.invoke", DISPATCH_QUEUE_SERIAL);
});
dispatch_async(invokeQueue, ^{
NSMutableDictionary *resp = [NSMutableDictionary new];
try {
NSData *data = [jsonRequest dataUsingEncoding:NSUTF8StringEncoding];
NSDictionary *req = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
NSString *op = req[@"op"] ?: @"";
resp[@"op"] = op;
if ([op isEqualToString:@"setDaemon"]) {
NSString *url = req[@"url"];
if (url.length > 0) g_daemonURL = url;
resp[@"url"] = g_daemonURL;
} else if ([op isEqualToString:@"createWallet"]) {
NSString *name = req[@"name"];
std::string path = walletPath(name);
std::string password = std::string([req[@"password"] UTF8String]);
bool exists = false;
try { exists = monero_wallet_full::wallet_exists(path); } catch (...) {}
if (exists) {
g_wallet = monero_wallet_full::open_wallet(path, password, monero_network_type::MAINNET, false);
connectDaemon(g_wallet);
resp[@"address"] = [NSString stringWithUTF8String:g_wallet->get_address(0, 0).c_str()];
try { resp[@"mnemonic"] = [NSString stringWithUTF8String:g_wallet->get_seed().c_str()]; } catch (...) {}
g_walletPath = path;
excludeWalletFilesFromBackup(g_walletPath);
} else {
monero_wallet_config config;
config.m_path = path;
config.m_password = password;
config.m_network_type = monero_network_type::MAINNET;
config.m_language = std::string([req[@"language"] ?: @"English" UTF8String]);
config.m_save_current = false;
monero_wallet_full *w = monero_wallet_full::create_wallet(config, nullptr);
g_wallet = w;
connectDaemon(w);
resp[@"address"] = [NSString stringWithUTF8String:w->get_address(0, 0).c_str()];
resp[@"mnemonic"] = [NSString stringWithUTF8String:w->get_seed().c_str()];
g_walletPath = path;
excludeWalletFilesFromBackup(g_walletPath);
}
} else if ([op isEqualToString:@"openWallet"] || [op isEqualToString:@"restoreWallet"]) {
if (g_wallet) {
try { g_wallet->close(true); } catch (...) {}
delete g_wallet;
g_wallet = nullptr;
}
if ([op isEqualToString:@"openWallet"]) {
g_wallet = monero_wallet_full::open_wallet(
walletPath(req[@"name"]),
std::string([req[@"password"] UTF8String]),
monero_network_type::MAINNET, false);
} else {
monero_wallet_config config;
config.m_path = walletPath(req[@"name"]);
config.m_password = std::string([req[@"password"] UTF8String]);
config.m_network_type = monero_network_type::MAINNET;
config.m_seed = std::string([req[@"seed"] UTF8String]);
config.m_restore_height = (uint64_t)[req[@"restoreHeight"] longLongValue];
config.m_language = "English";
config.m_save_current = false;
g_wallet = monero_wallet_full::create_wallet(config, nullptr);
}
connectDaemon(g_wallet);
g_walletPath = walletPath(req[@"name"]);
excludeWalletFilesFromBackup(g_walletPath);
resp[@"address"] = [NSString stringWithUTF8String:g_wallet->get_address(0, 0).c_str()];
} else if ([op isEqualToString:@"getSeed"]) {
if (g_wallet) {
resp[@"mnemonic"] = [NSString stringWithUTF8String:g_wallet->get_seed().c_str()];
} else {
resp[@"error"] = @"No wallet open";
}
} else if ([op isEqualToString:@"removeWallet"]) {
NSString *name = req[@"name"];
if (g_wallet) {
try { g_wallet->close(false); } catch (...) {}
delete g_wallet;
g_wallet = nullptr;
}
NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
NSString *dir = paths.firstObject;
NSFileManager *fm = [NSFileManager defaultManager];
for (NSString *ext in @[ @"", @".keys", @".address.txt", @".mms" ]) {
NSString *file = [dir stringByAppendingPathComponent:[name stringByAppendingString:ext]];
[fm removeItemAtPath:file error:nil];
}
} else if ([op isEqualToString:@"closeWallet"]) {
if (g_wallet) {
try { g_wallet->close(true); } catch (...) {}
delete g_wallet;
g_wallet = nullptr;
}
g_walletPath.clear();
} else if ([op isEqualToString:@"saveWallet"]) {
// monero-cpp never persists while syncing, so scan progress survives a
// restart only if this is called explicitly. save() pauses the sync
// thread while it writes.
if (g_wallet) {
g_wallet->save();
excludeWalletFilesFromBackup(g_walletPath);
resp[@"saved"] = @YES;
} else {
resp[@"error"] = @"No wallet open";
}
} else if ([op isEqualToString:@"getBalance"]) {
if (g_wallet) {
uint64_t balance = 0, unlocked = 0;
try {
balance = g_wallet->get_balance();
unlocked = g_wallet->get_unlocked_balance();
} catch (const std::exception &e) {
os_log_error(OS_LOG_DEFAULT, "[koins] getBalance: %{public}s", e.what());
}
resp[@"balance"] = @(balance).stringValue;
resp[@"unlocked"] = @(unlocked).stringValue;
} else {
resp[@"error"] = @"No wallet open";
}
} else if ([op isEqualToString:@"getAddress"]) {
if (g_wallet) {
resp[@"address"] = [NSString stringWithUTF8String:g_wallet->get_address(0, 0).c_str()];
} else {
resp[@"error"] = @"No wallet open";
}
} else if ([op isEqualToString:@"getTransactions"]) {
NSMutableArray *entries = [NSMutableArray new];
if (!g_wallet) {
resp[@"transactions"] = entries;
} else {
try {
monero_transfer_query query;
for (const auto &transfer : g_wallet->get_transfers(query)) {
const auto &tx = transfer->m_tx;
const auto isIn = transfer->is_incoming();
boost::optional<uint64_t> txHeight = tx ? tx->get_height() : boost::none;
NSMutableDictionary *entry = [NSMutableDictionary dictionary];
entry[@"hash"] = [NSString stringWithUTF8String:((tx && tx->m_hash) ? *tx->m_hash : "").c_str()];
entry[@"amount"] = @((transfer->m_amount ? *transfer->m_amount : 0)).stringValue;
entry[@"timestamp"] = @((tx && tx->m_received_timestamp) ? *tx->m_received_timestamp : 0);
entry[@"direction"] = (isIn && *isIn) ? @"in" : @"out";
entry[@"confirmed"] = @((tx && tx->m_is_confirmed) ? *tx->m_is_confirmed : false);
entry[@"height"] = @(txHeight ? *txHeight : 0);
entry[@"fee"] = @((tx && tx->m_fee) ? *tx->m_fee : 0).stringValue;
// Extra detail for the transaction view.
if (tx) {
if (tx->m_payment_id) entry[@"paymentId"] = [NSString stringWithUTF8String:tx->m_payment_id->c_str()];
if (tx->m_num_confirmations) entry[@"confirmations"] = @(*tx->m_num_confirmations);
if (tx->m_weight) entry[@"weight"] = @(*tx->m_weight);
if (tx->m_note) entry[@"note"] = [NSString stringWithUTF8String:tx->m_note->c_str()];
if (tx->m_unlock_time) entry[@"unlockTime"] = @(*tx->m_unlock_time);
}
if (transfer->m_account_index) entry[@"accountIndex"] = @(*transfer->m_account_index);
if (isIn && *isIn) {
auto incoming = std::dynamic_pointer_cast<monero_incoming_transfer>(transfer);
if (incoming) {
if (incoming->m_subaddress_index) entry[@"subaddressIndex"] = @(*incoming->m_subaddress_index);
if (incoming->m_address) entry[@"counterparty"] = [NSString stringWithUTF8String:incoming->m_address->c_str()];
}
} else {
auto outgoing = std::dynamic_pointer_cast<monero_outgoing_transfer>(transfer);
if (outgoing) {
if (!outgoing->m_addresses.empty()) entry[@"counterparty"] = [NSString stringWithUTF8String:outgoing->m_addresses.front().c_str()];
if (!outgoing->m_subaddress_indices.empty()) entry[@"subaddressIndex"] = @(outgoing->m_subaddress_indices.front());
}
}
[entries addObject:entry];
}
} catch (const std::exception &e) {
os_log_error(OS_LOG_DEFAULT, "[koins] getTransactions: %{public}s", e.what());
}
resp[@"transactions"] = entries;
}
} else if ([op isEqualToString:@"getAccounts"]) {
NSMutableArray *accounts = [NSMutableArray new];
if (!g_wallet) {
resp[@"accounts"] = accounts;
} else {
for (const auto &account : g_wallet->get_accounts(true, "")) {
NSMutableDictionary *accountDict = [NSMutableDictionary new];
accountDict[@"index"] = @(account.m_index ? *account.m_index : 0);
accountDict[@"address"] = [NSString stringWithUTF8String:(account.m_primary_address ? *account.m_primary_address : "").c_str()];
accountDict[@"balance"] = @((account.m_balance ? *account.m_balance : 0)).stringValue;
accountDict[@"unlocked"] = @((account.m_unlocked_balance ? *account.m_unlocked_balance : 0)).stringValue;
NSMutableArray *subaddresses = [NSMutableArray new];
for (const auto &sub : account.m_subaddresses) {
[subaddresses addObject:@{
@"accountIndex" : @(sub.m_account_index ? *sub.m_account_index : 0),
@"index" : @(sub.m_index ? *sub.m_index : 0),
@"address" : [NSString stringWithUTF8String:(sub.m_address ? *sub.m_address : "").c_str()],
@"label" : [NSString stringWithUTF8String:(sub.m_label ? *sub.m_label : "").c_str()],
@"balance" : @((sub.m_balance ? *sub.m_balance : 0)).stringValue,
@"unlocked" : @((sub.m_unlocked_balance ? *sub.m_unlocked_balance : 0)).stringValue,
@"isUsed" : @((sub.m_is_used ? *sub.m_is_used : false)),
}];
}
accountDict[@"subaddresses"] = subaddresses;
[accounts addObject:accountDict];
}
resp[@"accounts"] = accounts;
}
} else if ([op isEqualToString:@"createAccount"]) {
if (!g_wallet) {
resp[@"error"] = @"No wallet open";
} else {
monero_account account = g_wallet->create_account(std::string([req[@"label"] UTF8String]));
g_wallet->save();
excludeWalletFilesFromBackup(g_walletPath);
resp[@"index"] = @(account.m_index ? *account.m_index : 0);
resp[@"address"] = [NSString stringWithUTF8String:(account.m_primary_address ? *account.m_primary_address : "").c_str()];
resp[@"balance"] = @((account.m_balance ? *account.m_balance : 0)).stringValue;
resp[@"unlocked"] = @((account.m_unlocked_balance ? *account.m_unlocked_balance : 0)).stringValue;
}
} else if ([op isEqualToString:@"createSubaddress"]) {
if (!g_wallet) {
resp[@"error"] = @"No wallet open";
} else {
uint32_t accountIndex = (uint32_t)[req[@"accountIndex"] integerValue];
monero_subaddress sub = g_wallet->create_subaddress(accountIndex, std::string([req[@"label"] UTF8String]));
g_wallet->save();
excludeWalletFilesFromBackup(g_walletPath);
resp[@"accountIndex"] = @(sub.m_account_index ? *sub.m_account_index : 0);
resp[@"index"] = @(sub.m_index ? *sub.m_index : 0);
resp[@"address"] = [NSString stringWithUTF8String:(sub.m_address ? *sub.m_address : "").c_str()];
resp[@"label"] = [NSString stringWithUTF8String:(sub.m_label ? *sub.m_label : "").c_str()];
}
} else if ([op isEqualToString:@"startSync"]) {
if (g_wallet) {
connectDaemon(g_wallet);
try {
if (g_wallet->is_connected_to_daemon()) {
g_wallet->start_syncing(5000);
resp[@"syncing"] = @YES;
} else {
resp[@"syncing"] = @NO;
}
} catch (const std::exception &e) {
resp[@"syncing"] = @NO;
}
} else {
resp[@"error"] = @"No wallet open";
}
} else if ([op isEqualToString:@"stopSync"]) {
try { if (g_wallet) g_wallet->stop_syncing(); } catch (...) {}
} else if ([op isEqualToString:@"getSyncStatus"]) {
uint64_t height = 0, daemonHeight = 0;
bool connected = false;
if (g_wallet) {
try {
height = g_wallet->get_height();
// is_connected_to_daemon performs a live round trip; public
// daemons rate limit aggressive polling, so cache for 10s.
long long now = (long long)([[NSDate date] timeIntervalSince1970] * 1000);
if (now - s_lastDaemonProbe.load() > 10000) {
s_lastDaemonProbe.store(now);
connected = g_wallet->is_connected_to_daemon();
s_cachedConnected = connected;
} else {
connected = s_cachedConnected;
}
daemonHeight = s_cachedDaemonHeight;
if (connected) {
daemonHeight = g_wallet->get_daemon_height();
s_cachedDaemonHeight = daemonHeight;
}
} catch (const std::exception &e) {
os_log_error(OS_LOG_DEFAULT, "[koins] getSyncStatus: %{public}s", e.what());
}
}
resp[@"height"] = @(height);
resp[@"daemonHeight"] = @(daemonHeight);
resp[@"syncing"] = @(connected && height < daemonHeight);
resp[@"connected"] = @(connected);
} else if ([op isEqualToString:@"transferTo"]) {
if (!g_wallet) {
resp[@"error"] = @"No wallet open";
} else {
monero_tx_config config;
auto destination = std::make_shared<monero_destination>();
destination->m_address = std::string([req[@"address"] UTF8String]);
destination->m_amount = (uint64_t)[req[@"amount"] longLongValue];
config.m_destinations = { destination };
config.m_priority = (monero_tx_priority)[req[@"priority"] integerValue];
config.m_account_index = (uint32_t)[req[@"accountIndex"] integerValue];
// create_txs relays the transaction when m_relay is set, so it must
// not be submitted a second time.
config.m_relay = true;
auto txs = g_wallet->create_txs(config);
resp[@"txHash"] = [NSString stringWithUTF8String:(txs[0]->m_hash ? *txs[0]->m_hash : "").c_str()];
resp[@"fee"] = @((txs[0]->m_fee ? *txs[0]->m_fee : 0)).stringValue;
}
} else {
resp[@"error"] = [NSString stringWithFormat:@"Unknown op: %@", op];
}
} catch (const std::exception &e) {
os_log_error(OS_LOG_DEFAULT, "[koins] monero %{public}@ failed: %{public}s",
resp[@"op"], e.what());
resp[@"error"] = [NSString stringWithUTF8String:e.what()];
} catch (...) {
resp[@"error"] = @"Unknown native exception";
}
NSData *out = [NSJSONSerialization dataWithJSONObject:resp options:0 error:nil];
NSString *outStr = [[NSString alloc] initWithData:out encoding:NSUTF8StringEncoding];
completion(outStr ?: @"{\"error\":\"encode failed\"}");
});
}
@end
+25
View File
@@ -0,0 +1,25 @@
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
/// secp256k1 helpers used by the XRP wallet. Backed by the OpenSSL build that
/// already ships inside the on-device monero static library, so no extra
/// dependency is linked.
@interface KoinsSecp256k1 : NSObject
/// XRP account private scalar derived from a 16-byte family seed entropy.
+ (nullable NSData *)privateKeyFromEntropy:(NSData *)entropy;
/// 33-byte compressed public key for a 32-byte private scalar.
+ (nullable NSData *)publicKeyFromPrivateKey:(NSData *)privateKey;
/// DER-encoded, low-S ECDSA signature over a 32-byte digest.
+ (nullable NSData *)signDigest:(NSData *)digest
withPrivateKey:(NSData *)privateKey;
/// (left + right) mod n, used by BIP-32 hardened child derivation.
+ (nullable NSData *)addScalar:(NSData *)left toScalar:(NSData *)right;
@end
NS_ASSUME_NONNULL_END
+187
View File
@@ -0,0 +1,187 @@
#import "KoinsSecp256k1.h"
#import <openssl/bn.h>
#import <openssl/ec.h>
#import <openssl/ecdsa.h>
#import <openssl/obj_mac.h>
#import <openssl/sha.h>
// secp256k1 group order.
static const char *kCurveOrderHex =
"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141";
static BIGNUM *KoinsCurveOrder(void) {
BIGNUM *order = NULL;
BN_hex2bn(&order, kCurveOrderHex);
return order;
}
static NSData *KoinsSHA512Half(NSData *input) {
unsigned char digest[SHA512_DIGEST_LENGTH];
SHA512((const unsigned char *)input.bytes, (size_t)input.length, digest);
return [NSData dataWithBytes:digest length:32];
}
// Mirror of ripple-keypairs deriveScalar: hash the bytes (optionally with a
// 32-bit discriminator) until the result is a valid non-zero curve scalar.
static BIGNUM *KoinsDeriveScalar(NSData *bytes, BOOL hasDiscriminator,
uint32_t discriminator, const BIGNUM *order) {
for (uint32_t i = 0; i <= 4294967295u; i++) {
NSMutableData *input = [NSMutableData dataWithData:bytes];
if (hasDiscriminator) {
uint32_t d = htonl(discriminator);
[input appendBytes:&d length:4];
}
uint32_t idx = htonl(i);
[input appendBytes:&idx length:4];
NSData *half = KoinsSHA512Half(input);
BIGNUM *scalar = BN_bin2bn((const unsigned char *)half.bytes, (int)half.length, NULL);
if (scalar && !BN_is_zero(scalar) && BN_cmp(scalar, order) < 0) {
return scalar;
}
if (scalar) BN_free(scalar);
}
return NULL;
}
@implementation KoinsSecp256k1
+ (nullable NSData *)privateKeyFromEntropy:(NSData *)entropy {
if (entropy.length != 16) return nil;
BN_CTX *ctx = BN_CTX_new();
BIGNUM *order = KoinsCurveOrder();
EC_GROUP *group = EC_GROUP_new_by_curve_name(NID_secp256k1);
EC_POINT *publicGen = EC_POINT_new(group);
NSData *result = nil;
BIGNUM *privateGen = KoinsDeriveScalar(entropy, NO, 0, order);
if (privateGen && EC_POINT_mul(group, publicGen, privateGen, NULL, NULL, ctx)) {
unsigned char compressed[33];
size_t written = EC_POINT_point2oct(group, publicGen,
POINT_CONVERSION_COMPRESSED,
compressed, sizeof(compressed), ctx);
if (written == sizeof(compressed)) {
NSData *publicGenBytes = [NSData dataWithBytes:compressed length:written];
BIGNUM *offset = KoinsDeriveScalar(publicGenBytes, YES, 0, order);
if (offset) {
BIGNUM *derived = BN_new();
if (BN_mod_add(derived, offset, privateGen, order, ctx)) {
unsigned char out[32] = {0};
BN_bn2binpad(derived, out, sizeof(out));
result = [NSData dataWithBytes:out length:sizeof(out)];
}
BN_free(derived);
BN_free(offset);
}
}
}
if (privateGen) BN_free(privateGen);
if (publicGen) EC_POINT_free(publicGen);
if (group) EC_GROUP_free(group);
if (order) BN_free(order);
if (ctx) BN_CTX_free(ctx);
return result;
}
+ (nullable NSData *)publicKeyFromPrivateKey:(NSData *)privateKey {
if (privateKey.length != 32) return nil;
BN_CTX *ctx = BN_CTX_new();
EC_GROUP *group = EC_GROUP_new_by_curve_name(NID_secp256k1);
EC_POINT *point = EC_POINT_new(group);
BIGNUM *scalar = BN_bin2bn((const unsigned char *)privateKey.bytes, (int)privateKey.length, NULL);
NSData *result = nil;
if (scalar && EC_POINT_mul(group, point, scalar, NULL, NULL, ctx)) {
unsigned char compressed[33];
size_t written = EC_POINT_point2oct(group, point,
POINT_CONVERSION_COMPRESSED,
compressed, sizeof(compressed), ctx);
if (written == sizeof(compressed)) {
result = [NSData dataWithBytes:compressed length:written];
}
}
if (scalar) BN_free(scalar);
if (point) EC_POINT_free(point);
if (group) EC_GROUP_free(group);
if (ctx) BN_CTX_free(ctx);
return result;
}
+ (nullable NSData *)addScalar:(NSData *)left toScalar:(NSData *)right {
if (left.length != 32 || right.length != 32) return nil;
BN_CTX *ctx = BN_CTX_new();
BIGNUM *order = KoinsCurveOrder();
BIGNUM *a = BN_bin2bn((const unsigned char *)left.bytes, (int)left.length, NULL);
BIGNUM *b = BN_bin2bn((const unsigned char *)right.bytes, (int)right.length, NULL);
BIGNUM *sum = BN_new();
NSData *result = nil;
if (a && b && sum && BN_mod_add(sum, a, b, order, ctx)) {
unsigned char out[32] = {0};
BN_bn2binpad(sum, out, sizeof(out));
result = [NSData dataWithBytes:out length:sizeof(out)];
}
if (sum) BN_free(sum);
if (b) BN_free(b);
if (a) BN_free(a);
if (order) BN_free(order);
if (ctx) BN_CTX_free(ctx);
return result;
}
+ (nullable NSData *)signDigest:(NSData *)digest
withPrivateKey:(NSData *)privateKey {
if (digest.length != 32 || privateKey.length != 32) return nil;
BN_CTX *ctx = BN_CTX_new();
EC_KEY *key = EC_KEY_new_by_curve_name(NID_secp256k1);
BIGNUM *scalar = BN_bin2bn((const unsigned char *)privateKey.bytes, (int)privateKey.length, NULL);
NSData *result = nil;
if (key && scalar && EC_KEY_set_private_key(key, scalar)) {
const EC_GROUP *group = EC_KEY_get0_group(key);
EC_POINT *pub = EC_POINT_new(group);
if (pub && EC_POINT_mul(group, pub, scalar, NULL, NULL, ctx) &&
EC_KEY_set_public_key(key, pub)) {
ECDSA_SIG *sig = ECDSA_do_sign((const unsigned char *)digest.bytes, (int)digest.length, key);
if (sig) {
// Ripple only accepts canonical (low-S) signatures.
BIGNUM *order = KoinsCurveOrder();
BIGNUM *halfOrder = BN_dup(order);
BN_rshift1(halfOrder, halfOrder);
const BIGNUM *r = NULL;
const BIGNUM *s = NULL;
ECDSA_SIG_get0(sig, &r, &s);
if (BN_cmp(s, halfOrder) > 0) {
BIGNUM *lowS = BN_new();
BN_sub(lowS, order, s);
ECDSA_SIG_set0(sig, BN_dup(r), lowS);
}
unsigned char *der = NULL;
int derLen = i2d_ECDSA_SIG(sig, &der);
if (derLen > 0) {
result = [NSData dataWithBytes:der length:(NSUInteger)derLen];
OPENSSL_free(der);
}
BN_free(halfOrder);
BN_free(order);
ECDSA_SIG_free(sig);
}
}
if (pub) EC_POINT_free(pub);
}
if (scalar) BN_free(scalar);
if (key) EC_KEY_free(key);
if (ctx) BN_CTX_free(ctx);
return result;
}
@end
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>Scan a QR code to fill in a payment address.</string>
<key>NSFaceIDUsageDescription</key>
<string>Unlock Koins and approve payments.</string>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Dark</string>
</dict>
</plist>
+30
View File
@@ -0,0 +1,30 @@
import SwiftUI
@main
struct KoinsApp: App {
@StateObject private var model: AppModel
init() {
Secp256k1Backend.derivePrivateKey = { entropy in
KoinsSecp256k1.privateKey(fromEntropy: entropy)
}
Secp256k1Backend.publicKey = { privateKey in
KoinsSecp256k1.publicKey(fromPrivateKey: privateKey)
}
Secp256k1Backend.sign = { digest, privateKey in
KoinsSecp256k1.signDigest(digest, withPrivateKey: privateKey)
}
Secp256k1Backend.addScalar = { left, right in
KoinsSecp256k1.addScalar(left, toScalar: right)
}
_model = StateObject(wrappedValue: AppModel())
SelfTest.runIfRequested()
}
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(model)
}
}
}
+134
View File
@@ -0,0 +1,134 @@
import Foundation
enum Chain: String, Codable, CaseIterable {
case xrp
case xmr
var symbol: String { self == .xrp ? "XRP" : "XMR" }
var title: String { self == .xrp ? "XRP Ledger" : "Monero" }
var subtitle: String {
self == .xrp ? "Fast, low-fee payments" : "Private, untraceable payments"
}
}
/// Web block explorers used for "view on the web" links.
enum BlockExplorer {
static func transactionURL(chain: Chain, hash: String) -> URL? {
guard !hash.isEmpty else { return nil }
switch chain {
case .xmr: return URL(string: "https://xmrchain.net/tx/\(hash)")
case .xrp: return URL(string: "https://livenet.xrpl.org/transactions/\(hash)")
}
}
/// Same lookup for a raw ticker ("XMR"/"XRP"), as used by the swap API.
static func transactionURL(ticker: String, hash: String) -> URL? {
switch ticker.uppercased() {
case "XMR": return transactionURL(chain: .xmr, hash: hash)
case "XRP": return transactionURL(chain: .xrp, hash: hash)
default: return nil
}
}
}
struct WalletRecord: Codable, Identifiable, Equatable {
let id: String
var name: String
var address: String
var createdAt: Date
}
enum Direction: String, Codable {
case incoming
case outgoing
}
struct TransactionRecord: Identifiable, Codable {
let id: String
let amount: String
let timestamp: Date?
let direction: Direction
let confirmed: Bool
var fee: String?
var tag: Int?
var counterparty: String?
var confirmations: UInt64?
var height: UInt64?
var paymentId: String?
var accountIndex: Int?
var subaddressIndex: Int?
var note: String?
var weight: UInt64?
var ledgerIndex: UInt64?
var sequence: UInt64?
}
struct Balance: Codable {
var total: String
var unlocked: String
var funded: Bool
}
/// Persisted per chain so a switch or relaunch shows the last-known state
/// instantly while the network catches up.
struct ChainCache: Codable {
var balance: Balance
var transactions: [TransactionRecord]
var accounts: [MoneroAccount]?
var savedAt: Date
}
struct SyncStatus {
var height: UInt64
var daemonHeight: UInt64
var syncing: Bool
var connected: Bool
var progress: Double {
guard daemonHeight > 0 else { return 0 }
return min(1, Double(height) / Double(daemonHeight))
}
}
enum TransactionPriority: Int {
case defaultPriority = 0
case low = 1
case medium = 2
case high = 3
var label: String {
switch self {
case .defaultPriority: return "Default"
case .low: return "Low"
case .medium: return "Medium"
case .high: return "High"
}
}
}
struct MoneroSubaddress: Identifiable, Codable {
let accountIndex: Int
let index: Int
let address: String
let label: String
let balance: String
let unlocked: String
let isUsed: Bool
var id: String { "\(accountIndex)/\(index)" }
}
struct MoneroAccount: Identifiable, Codable {
let index: Int
let address: String
let balance: String
let unlocked: String
var subaddresses: [MoneroSubaddress]
var id: Int { index }
var title: String {
index == 0 ? "Primary account" : "Account \(index + 1)"
}
}
@@ -0,0 +1,107 @@
/**
* Copyright (c) woodser
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#pragma once
#include <exception>
#include <string>
/**
* Public interface for libmonero-cpp library.
*/
namespace monero {
/**
* Exception when interacting with a Monero wallet or daemon.
*/
class monero_error : public std::exception {
public:
std::string message;
monero_error() {}
/**
* Construct the exception.
*
* @param msg is a human-readable description of the error
*/
monero_error(const std::string& msg) : message(msg) {}
const char* what() const noexcept override {
return message.c_str();
}
};
/**
* Exception when interacting with the Monero daemon or wallet RPC API.
*/
class monero_rpc_error : public monero_error {
public:
int code;
/**
* Construct the exception.
*
* @param error_code is the error code (optional)
* @param msg is a human-readable description of the error
*/
monero_rpc_error(int error_code, const std::string& msg) : code(error_code) { message = msg; }
/**
* Construct the exception.
*
* @param msg is a human-readable description of the error
*/
monero_rpc_error(const std::string& msg) : code(-1) { message = msg; }
};
}
@@ -0,0 +1,439 @@
#include "monero_rpc_connection.h"
#include "utils/gen_utils.h"
#include "utils/monero_utils.h"
#include "daemon/monero_daemon_rpc_model.h"
#include "monero_error.h"
static const uint32_t DEFAULT_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes
namespace monero {
// --------------------------- MONERO REQUEST PARAMS ---------------------------
rapidjson::Value monero_request_params::to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const {
rapidjson::Value root(rapidjson::kObjectType);
return root;
}
// --------------------------- MONERO RPC REQUEST ---------------------------
monero_rpc_request::monero_rpc_request(const std::string& method, const std::shared_ptr<serializable_struct>& params, bool json_rpc): m_method(method), m_params(params) {
if (params == nullptr) m_params = std::make_shared<monero_request_params>();
if (json_rpc) {
m_id = "0";
m_version = "2.0";
}
}
rapidjson::Value monero_rpc_request::to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const {
if (!is_json_rpc()) {
if (m_params == nullptr) throw monero_error("No params provided");
return m_params->to_rapidjson_val(allocator);
}
// create root
rapidjson::Value root(rapidjson::kObjectType);
// set string values
rapidjson::Value value_str(rapidjson::kStringType);
if (m_version != boost::none) monero_utils::add_json_member("jsonrpc", m_version.get(), allocator, root, value_str);
if (m_id != boost::none) monero_utils::add_json_member("id", m_id.get(), allocator, root, value_str);
if (m_method != boost::none) monero_utils::add_json_member("method", m_method.get(), allocator, root, value_str);
if (m_params != nullptr) root.AddMember("params", m_params->to_rapidjson_val(allocator), allocator);
// return root
return root;
}
// --------------------------- MONERO RPC RESPONSE ---------------------------
void raise_rpc_error(const boost::property_tree::ptree& error_node, const boost::string_ref uri, const std::string& method) {
std::string err_message = "Unknown error";
int err_code = -1;
for (auto it = error_node.begin(); it != error_node.end(); ++it) {
std::string key_err = it->first;
if (key_err == std::string("message")) {
if (it->second.data().empty()) {
err_message = "Received error response from RPC request with method '" + method + "' to " + uri.to_string();
}
else err_message = it->second.data();
} else if (key_err == std::string("code")) {
try {
err_code = it->second.get_value<int>();
} catch (...) {
MERROR("non-numeric error code from a malformed/unusual RPC response");
err_code = -1;
}
}
}
throw monero_rpc_error(err_code, err_message);
}
// --------------------------- MONERO RPC CONNECTION ---------------------------
bool monero_rpc_connection::compare(int p1, int p2) {
if (p1 == p2) return false;
// 0 alway first
if (p1 == 0) return true;
if (p2 == 0) return false;
return p1 > p2;
}
monero_rpc_connection::monero_rpc_connection(const std::string& uri, const std::string& username, const std::string& password, const std::string& proxy_uri, const std::string& zmq_uri, int priority, const boost::optional<uint32_t>& timeout_ms) {
if (!uri.empty()) m_uri = uri;
else m_uri = boost::none;
if (!proxy_uri.empty()) m_proxy_uri = proxy_uri;
else m_proxy_uri = boost::none;
if (!zmq_uri.empty()) m_zmq_uri = zmq_uri;
else m_zmq_uri = boost::none;
m_priority = priority;
m_timeout_ms = timeout_ms;
set_credentials(username, password);
}
monero_rpc_connection::monero_rpc_connection(const monero_rpc_connection& rpc) {
boost::lock_guard<boost::recursive_mutex> src_lock(rpc.m_mutex);
m_uri = rpc.m_uri;
m_proxy_uri = rpc.m_proxy_uri;
m_zmq_uri = rpc.m_zmq_uri;
m_priority = rpc.m_priority;
m_timeout_ms = rpc.m_timeout_ms;
set_credentials(rpc.m_username.value_or(""), rpc.m_password.value_or(""));
m_is_online = rpc.m_is_online;
m_is_authenticated = rpc.m_is_authenticated;
m_response_time = rpc.m_response_time;
m_attributes = rpc.m_attributes;
}
rapidjson::Value monero_rpc_connection::to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const {
boost::lock_guard<boost::recursive_mutex> lock(m_mutex);
// create root
rapidjson::Value root(rapidjson::kObjectType);
// set string values
rapidjson::Value value_str(rapidjson::kStringType);
if (m_uri != boost::none) monero_utils::add_json_member("uri", m_uri.get(), allocator, root, value_str);
if (m_username != boost::none) monero_utils::add_json_member("username", m_username.get(), allocator, root, value_str);
if (m_password != boost::none) monero_utils::add_json_member("password", m_password.get(), allocator, root, value_str);
if (m_proxy_uri != boost::none) monero_utils::add_json_member("proxyUri", m_proxy_uri.get(), allocator, root, value_str);
if (m_zmq_uri != boost::none) monero_utils::add_json_member("zmqUri", m_zmq_uri.get(), allocator, root, value_str);
// set num values
rapidjson::Value value_num(rapidjson::kNumberType);
monero_utils::add_json_member("priority", m_priority, allocator, root, value_num);
if (m_timeout_ms != boost::none) monero_utils::add_json_member("timeoutMs", m_timeout_ms.get(), allocator, root, value_num);
if (m_response_time != boost::none) monero_utils::add_json_member("responseTime", m_response_time.get(), allocator, root, value_num);
// set bool values
if (m_is_online != boost::none) monero_utils::add_json_member("isOnline", m_is_online.get(), allocator, root);
if (m_is_authenticated != boost::none) monero_utils::add_json_member("isAuthenticated", m_is_authenticated.get(), allocator, root);
return root;
}
bool monero_rpc_connection::is_onion() const {
boost::lock_guard<boost::recursive_mutex> lock(m_mutex);
epee::net_utils::http::url_content parsed{};
if (!epee::net_utils::parse_url(m_uri.value_or(""), parsed)) return false;
return parsed.host.size() >= 6 && parsed.host.compare(parsed.host.size() - 6, 6, ".onion") == 0;
}
bool monero_rpc_connection::is_i2p() const {
boost::lock_guard<boost::recursive_mutex> lock(m_mutex);
epee::net_utils::http::url_content parsed{};
if (!epee::net_utils::parse_url(m_uri.value_or(""), parsed)) return false;
return parsed.host.size() >= 8 && parsed.host.compare(parsed.host.size() - 8, 8, ".b32.i2p") == 0;
}
void monero_rpc_connection::set_credentials(const std::string& username, const std::string& password) {
boost::lock_guard<boost::recursive_mutex> lock(m_mutex);
// disconnect http client if connected
if (m_http_client != nullptr && m_http_client->is_connected()) m_http_client->disconnect();
bool username_empty = username.empty();
bool password_empty = password.empty();
// check username and password consistency
if (!username_empty || !password_empty) {
if (password_empty) {
throw monero_error("password cannot be empty because username is not empty");
}
if (username_empty) {
throw monero_error("username cannot be empty because password is not empty");
}
}
// check username and password changes
bool username_equals = (m_username == boost::none && username_empty) || (m_username != boost::none && *m_username == username);
bool password_equals = (m_password == boost::none && password_empty) || (m_password != boost::none && *m_password == password);
// connection reset values
if (!username_equals || !password_equals) {
m_is_online = boost::none;
m_is_authenticated = boost::none;
}
// setup username and password
if (!username_empty && !password_empty) {
m_username = username;
m_password = password;
} else {
m_username = boost::none;
m_password = boost::none;
}
}
void monero_rpc_connection::set_attribute(const std::string& key, const std::string& val) {
boost::lock_guard<boost::recursive_mutex> lock(m_mutex);
m_attributes[key] = val;
}
std::string monero_rpc_connection::get_attribute(const std::string& key) const {
boost::lock_guard<boost::recursive_mutex> lock(m_mutex);
std::unordered_map<std::string, std::string>::const_iterator i = m_attributes.find(key);
if (i == m_attributes.end()) {
return std::string(""); // attribute not found
}
return i->second;
}
boost::optional<bool> monero_rpc_connection::is_online() const {
boost::lock_guard<boost::recursive_mutex> lock(m_mutex);
return m_is_online;
}
boost::optional<bool> monero_rpc_connection::is_authenticated() const {
boost::lock_guard<boost::recursive_mutex> lock(m_mutex);
return m_is_authenticated;
}
boost::optional<bool> monero_rpc_connection::is_connected() const {
boost::lock_guard<boost::recursive_mutex> lock(m_mutex);
if (m_is_online == boost::none) return boost::none;
return m_is_online.get() && (m_is_authenticated == boost::none || m_is_authenticated.get());
}
bool monero_rpc_connection::check_connection(const boost::optional<uint32_t>& timeout_ms) {
boost::lock_guard<boost::recursive_mutex> lock(m_mutex);
boost::optional<bool> is_online_before = m_is_online;
boost::optional<bool> is_authenticated_before = m_is_authenticated;
boost::optional<std::chrono::high_resolution_clock::time_point> start_time = boost::none;
try {
// assume daemon connection
monero_get_blocks_by_height_request request(100);
start_time = std::chrono::high_resolution_clock::now();
send_binary_request(request, timeout_ms);
// set response time
m_response_time = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::high_resolution_clock::now() - start_time.get()).count();
m_is_online = true;
m_is_authenticated = true;
}
catch (const monero_rpc_error& ex) {
m_is_online = false;
m_is_authenticated = boost::none;
m_response_time = boost::none;
if (ex.code == 401 || ex.code == 404) {
// fallback to latency check
m_is_online = true;
m_is_authenticated = ex.code == 404;
}
}
catch (...) {
m_is_online = false;
m_is_authenticated = boost::none;
m_response_time = boost::none;
}
if (gen_utils::bool_equals(true, m_is_online) && start_time != boost::none) {
m_response_time = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::high_resolution_clock::now() - start_time.get()).count();
}
return is_online_before != m_is_online || is_authenticated_before != m_is_authenticated;
}
boost::property_tree::ptree monero_rpc_connection::send_json_request(const std::string& path, const std::shared_ptr<serializable_struct>& params, const boost::optional<uint32_t>& timeout_ms) const {
// send JSON-RPC request
monero_rpc_request request(path, params);
auto response = send_json_request(request, timeout_ms);
// assert JSON-RPC response is defined
if (response.m_result == boost::none) throw monero_error("Invalid Monero JSONRPC response");
return response.m_result.get();
}
monero_rpc_response monero_rpc_connection::send_json_request(const monero_rpc_request &request, const boost::optional<uint32_t>& timeout_ms) const {
// invoke JSON-RPC method
monero_rpc_response response;
send_rpc_request("/json_rpc", request, response, timeout_ms);
// return JSON-RPC response
return response;
}
boost::property_tree::ptree monero_rpc_connection::send_path_request(const std::string& path, const std::shared_ptr<serializable_struct>& params, const boost::optional<uint32_t>& timeout_ms) const {
// send RPC request
monero_rpc_request request(path, params, false);
auto response = send_path_request(request, timeout_ms);
// assert RPC response is defined
if (response.m_response == boost::none) throw monero_error("Invalid Monero RPC response");
return response.m_response.get();
}
monero_rpc_response monero_rpc_connection::send_path_request(const monero_rpc_request &request, const boost::optional<uint32_t>& timeout_ms) const {
// validate parameters
if (request.m_method == boost::none || request.m_method->empty()) throw monero_error("No RPC method set in path request");
// invoke RPC method
monero_rpc_response response;
send_rpc_request(std::string("/") + request.m_method.get(), request, response, timeout_ms);
// return RPC response
return response;
}
monero_rpc_response monero_rpc_connection::send_binary_request(const monero_rpc_request &request, const boost::optional<uint32_t>& timeout_ms) const {
// validate parameters
if (request.m_method == boost::none || request.m_method->empty()) throw monero_error("No RPC method set in binary request");
// invoke binary RPC method
monero_rpc_response response;
send_rpc_request(std::string("/") + request.m_method.get(), request, response, timeout_ms, true);
// return binary response
return response;
}
void monero_rpc_connection::ensure_configured() const {
// create http client on first use, so constructing a connection does not require network support
if (m_http_client == nullptr) {
auto factory = std::make_unique<net::http::client_factory>();
m_http_client = factory->create();
m_http_client->set_auto_connect(true);
}
// only reconfigure when uri/creds/proxy changed since last apply
std::string uri = m_uri.value_or("");
std::string username = m_username.value_or("");
std::string password = m_password.value_or("");
std::string proxy_uri = m_proxy_uri.value_or("");
auto key = std::make_tuple(uri, username, password, proxy_uri);
if (m_applied == key) return; // unchanged, so reuse live connection
// setup connection credentials
boost::optional<epee::net_utils::http::login> login;
if(!username.empty() && !password.empty()) {
login = epee::net_utils::http::login();
login->username = username;
login->password = password;
}
// detect ssl
epee::net_utils::ssl_support_t ssl = uri.rfind("https", 0) == 0 ? epee::net_utils::ssl_support_t::e_ssl_support_enabled : epee::net_utils::ssl_support_t::e_ssl_support_disabled;
if (!m_http_client->set_proxy(proxy_uri)) throw monero_error("Could not set proxy uri: " + proxy_uri);
if (!m_http_client->set_server(uri, login, std::move(ssl))) throw monero_error("Could not set uri: " + uri);
if (!m_http_client->connect(std::chrono::milliseconds(DEFAULT_TIMEOUT_MS))) MWARNING("Could not connect to server: " + uri);
m_applied = key;
}
std::string monero_rpc_connection::invoke_post(const boost::string_ref uri, const std::string& body, const boost::optional<uint32_t>& timeout_ms) const {
boost::lock_guard<boost::recursive_mutex> lock(m_mutex);
// request/response timeout: user-configurable, defaults to 3 minutes when not set.
uint32_t timeout = DEFAULT_TIMEOUT_MS;
if (timeout_ms != boost::none) timeout = timeout_ms.get();
else if (m_timeout_ms != boost::none) timeout = m_timeout_ms.get();
if (timeout == 0) timeout = std::numeric_limits<uint32_t>::max(); // timeout of 0 is treated as indefinite
// ensure_configured() bounds only the first connect; epee reconnects with this request timeout
ensure_configured();
const epee::net_utils::http::http_response_info* pri = NULL;
// invoke http json
const uint64_t received_before = m_http_client->get_bytes_received();
if (!m_http_client->invoke_post(uri, body, std::chrono::milliseconds(timeout), std::addressof(pri))) {
// epee never surfaces the 401 status, but a rejected login means the server answered;
// a dead or idle-closed socket yields no response bytes at all
bool received = m_http_client->get_bytes_received() > received_before;
if (received && m_http_client->is_connected()) {
throw monero_rpc_error(401, "Unauthorized");
}
// drop the socket: epee leaves EOF'd sockets marked connected, so without this the next
// call reuses the poisoned socket and hits the same misdiagnosis again
m_http_client->disconnect();
if (m_uri == boost::none || m_uri->empty()) throw monero_error("Cannot send RPC request: uri not set.");
throw monero_error("Network error: " + std::string(received ? "connection dropped mid-response" : "no response") + " from " + m_uri.get() + uri.to_string() + " (timeout: " + std::to_string(timeout) + "ms)");
}
if (!pri) throw monero_error("Could not get response info");
if (pri->m_response_code < 200 || pri->m_response_code > 299) {
std::string err_msg = std::to_string(pri->m_response_code) + " " + pri->m_response_comment + (pri->m_body.empty() ? "" : ": " + pri->m_body);
throw monero_rpc_error(pri->m_response_code, err_msg);
}
// return response info body
return pri->m_body;
}
void deserialize_rpc_response(const std::string& json, monero_rpc_response& response, const boost::string_ref uri, const std::string& method) {
// parse json to property node
boost::property_tree::ptree node;
gen_utils::deserialize(json, node);
// parse JSON-RPC response
for (boost::property_tree::ptree::const_iterator it = node.begin(); it != node.end(); ++it) {
std::string key = it->first;
if (key == std::string("error")) raise_rpc_error(it->second, uri, method);
else if (key == std::string("jsonrpc")) response.m_jsonrpc = it->second.data();
else if (key == std::string("result")) response.m_result = it->second;
}
// if not JSON-RPC, set RPC response
if (response.m_jsonrpc == boost::none) response.m_response = node;
}
void monero_rpc_connection::send_rpc_request(const boost::string_ref uri, const monero_rpc_request& request, monero_rpc_response& response, const boost::optional<uint32_t>& timeout_ms, bool binary) const {
std::string body;
if (request.m_method == boost::none) throw monero_error("Must provide a valid request method");
if (binary) monero_utils::json_to_binary(request.serialize(), body);
else body = request.serialize();
std::string result = invoke_post(uri, body, timeout_ms);
if (binary) response.m_binary = result;
else deserialize_rpc_response(result, response, uri, request.m_method.get());
}
std::shared_ptr<monero_rpc_connection> monero_rpc_connection::from_property_tree(const boost::property_tree::ptree& node) {
std::shared_ptr<monero_rpc_connection> connection = std::make_shared<monero_rpc_connection>();
for (boost::property_tree::ptree::const_iterator it = node.begin(); it != node.end(); ++it) {
std::string key = it->first;
if (key == std::string("uri")) connection->m_uri = it->second.data();
else if (key == std::string("username")) connection->m_username = it->second.data();
else if (key == std::string("password")) connection->m_password = it->second.data();
else if (key == std::string("proxyUri") || key == std::string("proxy_uri")) connection->m_proxy_uri = it->second.data();
else if (key == std::string("zmqUri")) connection->m_zmq_uri = it->second.data();
}
return connection;
}
}
@@ -0,0 +1,281 @@
/**
* Copyright (c) woodser
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#pragma once
#include <boost/thread/mutex.hpp>
#include <boost/thread/thread.hpp>
#include <boost/thread/condition_variable.hpp>
#include <boost/property_tree/ptree.hpp>
#include <boost/property_tree/json_parser.hpp>
#include "rapidjson/document.h"
#include "rapidjson/writer.h"
#include "rapidjson/stringbuffer.h"
#include "net/http.h"
#include "daemon/monero_daemon_model.h"
/**
* Public interface for libmonero-cpp library.
*/
namespace monero {
/**
* Models parameters for a request to a RPC server.
*/
struct monero_request_params : public serializable_struct {
monero_request_params() { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Models a request to a RPC server.
*/
struct monero_rpc_request : public serializable_struct {
boost::optional<std::string> m_id;
boost::optional<std::string> m_version;
boost::optional<std::string> m_method;
std::shared_ptr<serializable_struct> m_params;
monero_rpc_request() { }
monero_rpc_request(const std::string& method, const std::shared_ptr<serializable_struct>& params, bool json_rpc = true);
bool is_json_rpc() const { return m_id != boost::none && m_version != boost::none; }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Models a response from a RPC server.
*/
struct monero_rpc_response {
boost::optional<std::string> m_jsonrpc;
boost::optional<boost::property_tree::ptree> m_result;
boost::optional<boost::property_tree::ptree> m_response;
boost::optional<std::string> m_binary;
};
/**
* Maintains a connection and sends requests to a Monero RPC API.
*/
class monero_rpc_connection : public serializable_struct {
public:
boost::optional<std::string> m_uri;
boost::optional<std::string> m_username;
boost::optional<std::string> m_password;
boost::optional<std::string> m_proxy_uri;
boost::optional<std::string> m_zmq_uri; // TODO: implement zmq listener
boost::optional<uint32_t> m_timeout_ms; // RPC request timeout in milliseconds
boost::optional<uint64_t> m_response_time; // automatically set by calling check_connection()
int m_priority; // priority relative to other connections where 1 is highest, then 2, etc, and 0 is lowest (default)
static std::shared_ptr<monero_rpc_connection> from_property_tree(const boost::property_tree::ptree& node);
/**
* Checks RPC connection priority order.
*
* @param c1 first priority to compare
* @param c2 second priority to compare
*/
static bool compare(int p1, int p2);
/**
* Initialize a new RPC connection.
*
* @param uri RPC connection uri
* @param username RPC connection authentication username
* @param password RPC connection authentication password
* @param proxy_uri RPC connection proxy uri
* @param zmq_uri RPC connection zmq uri
* @param priority RPC connection priority
* @param timeout_ms RPC connection timeout in milliseconds
*/
monero_rpc_connection(const std::string& uri = "", const std::string& username = "", const std::string& password = "", const std::string& proxy_uri = "", const std::string& zmq_uri = "", int priority = 0, const boost::optional<uint32_t>& timeout_ms = boost::none);
/**
* Copy a RPC connection.
*
* @param rpc RPC connection to copy
*/
monero_rpc_connection(const monero_rpc_connection& rpc);
/**
* Indicates if the connection uri is a TOR server.
*
* @return true or false to indicate if connection uri is a TOR server
*/
bool is_onion() const;
/**
* Indicates if the connection uri is a I2P server.
*
* @return true or false to indicate if connection uri is a I2P server
*/
bool is_i2p() const;
/**
* Set connection credentials.
*
* @param username username to use in RPC authentication
* @param password password to use in RPC authentication
*/
void set_credentials(const std::string& username, const std::string& password);
/**
* Set connection attribute.
*
* @param key is the attribute key
* @param val is the attribute value
*/
void set_attribute(const std::string& key, const std::string& val);
/**
* Get connection attribute.
*
* @param key is the attribute to get the value of
* @return key's value if set
*/
std::string get_attribute(const std::string& key) const;
/**
* Indicates if the connection is online, which is set automatically by calling check_connection().
*
* @return true or false to indicate if online, or null if check_connection() has not been called
*/
boost::optional<bool> is_online() const;
/**
* Indicates if the connection is authenticated, which is set automatically by calling check_connection().
*
* @return true if authenticated or no authentication, false if not authenticated, or null if not set
*/
boost::optional<bool> is_authenticated() const;
/**
* Indicates if the connection is connected, which is set automatically by calling check_connection().
*
* @return true or false to indicate if connected, or null if check_connection() has not been called
*/
boost::optional<bool> is_connected() const;
/**
* Check the connection and update online, authentication, and response time status.
*
* @param timeout_ms the maximum response time before considered offline
* @return
*/
bool check_connection(const boost::optional<uint32_t>& timeout_ms = boost::none);
/**
* Send a request to the RPC API.
*
* @param path specifies the method to request
* @param params are the request's input parameters
* @return the RPC API response as a map
*/
boost::property_tree::ptree send_json_request(const std::string& path, const std::shared_ptr<serializable_struct>& params = nullptr, const boost::optional<uint32_t>& timeout_ms = boost::none) const;
/**
* Send a request to the RPC API.
*
* @param request specifies the method to request with parameters
* @param timeout request timeout in milliseconds
* @return the RPC API response as a map
*/
monero_rpc_response send_json_request(const monero_rpc_request &request, const boost::optional<uint32_t>& timeout_ms = boost::none) const;
/**
* Send a RPC request to the given path and with the given paramters.
*
* E.g. "/get_transactions" with params
*
* @param path is the url path of the request to invoke
* @param params are request parameters sent in the body
* @return the RPC API response as a map
*/
boost::property_tree::ptree send_path_request(const std::string& path, const std::shared_ptr<serializable_struct>& params = nullptr, const boost::optional<uint32_t>& timeout_ms = boost::none) const;
/**
* Send a RPC request to the given path and with the given paramters.
*
* @param request specifies the method to request with parameters
* @param timeout request timeout in milliseconds
* @return the request's deserialized response
*/
monero_rpc_response send_path_request(const monero_rpc_request &request, const boost::optional<uint32_t>& timeout_ms = boost::none) const;
/**
* Send a binary RPC request.
*
* @param request specifies the method to request with paramesters
* @param timeout request timeout in milliseconds
* @return the request's deserialized response
*/
monero_rpc_response send_binary_request(const monero_rpc_request &request, const boost::optional<uint32_t>& timeout_ms = boost::none) const;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
protected:
// instance variables
mutable boost::recursive_mutex m_mutex;
boost::optional<bool> m_is_online;
boost::optional<bool> m_is_authenticated;
mutable std::unique_ptr<epee::net_utils::http::abstract_http_client> m_http_client;
std::unordered_map<std::string, std::string> m_attributes;
mutable std::tuple<std::string, std::string, std::string, std::string> m_applied;
void ensure_configured() const;
std::string invoke_post(const boost::string_ref uri, const std::string& body, const boost::optional<uint32_t>& timeout_ms = boost::none) const;
void send_rpc_request(const boost::string_ref uri, const monero_rpc_request& request, monero_rpc_response& response, const boost::optional<uint32_t>& timeout_ms = boost::none, bool binary = false) const;
};
}
@@ -0,0 +1,60 @@
/**
* Copyright (c) woodser
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#include "monero_daemon.h"
/**
* Public library intereface.
*/
namespace monero {
}
@@ -0,0 +1,805 @@
/**
* Copyright (c) woodser
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#pragma once
#include "common/monero_rpc_connection.h"
#include "monero_daemon_model.h"
/**
* Public library interface.
*/
namespace monero {
/**
* Receives notifications as a daemon is updated.
*/
class monero_daemon_listener {
public:
std::shared_ptr<monero_block_header> m_last_header;
/**
* Called when a new block is added to the chain.
*
* @param header is the header of the block added to the chain
*/
virtual void on_block_header(const std::shared_ptr<monero_block_header>& header) {
m_last_header = header;
}
};
/**
* Monero daemon interface.
*/
class monero_daemon {
public:
/**
* Virtual destructor.
*/
virtual ~monero_daemon() {}
/**
* Register a listener receive daemon notifications.
*
* @param listener is the listener to receive daemon notifications
*/
virtual void add_listener(monero_daemon_listener &listener) {
throw std::runtime_error("monero_daemon::add_listener(): not supported");
}
/**
* Unregister a listener to receive daemon notifications.
*
* @param listener is the listener to unregister
*/
virtual void remove_listener(monero_daemon_listener &listener) {
throw std::runtime_error("monero_daemon::remove_listener(): not supported");
}
/**
* Get the listeners registered with the daemon.
*/
virtual std::set<monero_daemon_listener*> get_listeners() {
throw std::runtime_error("monero_daemon::get_listeners(): not supported");
}
/**
* Remove all listeners registered with the daemon.
*/
virtual void remove_listeners() {
throw std::runtime_error("monero_daemon::remove_listeners(): not supported");
}
/**
* Get the daemon's version.
*
* @return the daemon's version
*/
virtual monero_version get_version() {
throw std::runtime_error("monero_daemon::get_version(): not supported");
}
/**
* Indicates if the daemon is trusted or untrusted.
*
* @return true if the daemon is trusted, false otherwise
*/
virtual bool is_trusted() {
throw std::runtime_error("monero_daemon::is_trusted(): not supported");
}
/**
* Get the number of blocks in the longest chain known to the node.
*
* @return the number of blocks
*/
virtual uint64_t get_height() {
throw std::runtime_error("monero_daemon::get_height(): not supported");
}
/**
* Get a block's hash by its height.
*
* @param height is the height of the block hash to get
* @return tthe block's hash at the given height
*/
virtual std::string get_block_hash(uint64_t height) {
throw std::runtime_error("monero_daemon::get_block_hash(): not supported");
}
/**
* Get a block template for mining a new block.
*
* @param wallet_address is the address of the wallet to receive miner transactions if block is successfully mined
* @param reserve_size is the reserve size (optional)
* @return a block template for mining a new block
*/
virtual std::shared_ptr<monero_block_template> get_block_template(const std::string& wallet_address, const boost::optional<int>& reserve_size = boost::none) {
throw std::runtime_error("monero_daemon::get_block_template(): not supported");
}
/**
* Get the last block's header.
*
* @return the last block's header
*/
virtual std::shared_ptr<monero_block_header> get_last_block_header() {
throw std::runtime_error("monero_daemon::get_last_block_header(): not supported");
}
/**
* Get a block header by its hash.
*
* @param block_hash is the hash of the block to get the header of
* @return the block's header
*/
virtual std::shared_ptr<monero_block_header> get_block_header_by_hash(const std::string& block_hash) {
throw std::runtime_error("monero_daemon::get_block_header_by_hash(): not supported");
}
/**
* Get a block header by its height.
*
* @param height is the height of the block to get the header of
* @return the block's header
*/
virtual std::shared_ptr<monero_block_header> get_block_header_by_height(uint64_t height) {
throw std::runtime_error("monero_daemon::get_block_header_by_height(): not supported");
}
/**
* Get block headers for the given range.
*
* @param start_height is the start height lower bound inclusive (optional)
* @param end_height is the end height upper bound inclusive (optional)
* @return block headers in the given range
*/
virtual std::vector<std::shared_ptr<monero_block_header>> get_block_headers_by_range(uint64_t start_height, uint64_t end_height) {
throw std::runtime_error("monero_daemon::get_block_headers_by_range(): not supported");
}
/**
* Get a block by hash.
*
* @param block_hash is the hash of the block to get
* @return the block with the given hash
*/
virtual std::shared_ptr<monero_block> get_block_by_hash(const std::string& block_hash) {
throw std::runtime_error("monero_daemon::get_block_by_hash(): not supported");
}
/**
* Get blocks by hash.
*
* @param block_hashes are array of hashes; first 10 blocks hash goes sequential,
* next goes in pow(2,n) offset, like 2, 4, 8, 16, 32, 64 and so on,
* and the last one is always genesis block
* @param start_height is the start height to get blocks by hash
* @param prune specifies if returned blocks should be pruned (defaults to false) // TODO: test default
* @return the retrieved blocks
*/
virtual std::vector<std::shared_ptr<monero_block>> get_blocks_by_hash(const std::vector<std::string>& block_hashes, uint64_t start_height, bool prune) {
throw std::runtime_error("monero_daemon::get_blocks_by_hash(): not supported");
}
/**
* Get a block by height.
*
* @param height is the height of the block to get
* @return the block at the given height
*/
virtual std::shared_ptr<monero_block> get_block_by_height(uint64_t height) {
throw std::runtime_error("monero_daemon::get_block_by_height(): not supported");
}
/**
* Get blocks at the given heights.
*
* @param heights are the heights of the blocks to get
* @return blocks at the given heights
*/
virtual std::vector<std::shared_ptr<monero_block>> get_blocks_by_height(const std::vector<uint64_t>& heights) {
throw std::runtime_error("monero_daemon::get_blocks_by_height(): not supported");
}
/**
* Get blocks in the given height range.
*
* @param start_height is the start height lower bound inclusive (optional)
* @param end_height is the end height upper bound inclusive (optional)
* @return blocks in the given height range
*/
virtual std::vector<std::shared_ptr<monero_block>> get_blocks_by_range(boost::optional<uint64_t> start_height, boost::optional<uint64_t> end_height) {
throw std::runtime_error("monero_daemon::get_blocks_by_range(): not supported");
}
/**
* Get blocks in the given height range as chunked requests so that each request is
* not too big.
*
* @param start_height is the start height lower bound inclusive (optional)
* @param end_height is the end height upper bound inclusive (optional)
* @return blocks in the given height range
*/
virtual std::vector<std::shared_ptr<monero_block>> get_blocks_by_range_chunked(boost::optional<uint64_t> start_height, boost::optional<uint64_t> end_height, boost::optional<uint64_t> max_chunk_size) {
throw std::runtime_error("monero_daemon::get_blocks_by_range_chunked(): not supported");
}
/**
* Get block hashes as a binary request to the daemon.
*
* @param block_hashes specify block hashes to fetch; first 10 blocks hash goes
* sequential, next goes in pow(2,n) offset, like 2, 4, 8, 16, 32, 64
* and so on, and the last one is always genesis block
* @param start_height is the starting height of block hashes to return
* @return the requested block hashes
*/
virtual std::vector<std::string> get_block_hashes(const std::vector<std::string>& block_hashes, uint64_t start_height) {
throw std::runtime_error("monero_daemon::get_block_hashes(): not supported");
}
/**
* Get a transaction by hash.
*
* @param tx_hash is the hash of the transaction to get
* @param prune specifies if the returned tx should be pruned (defaults to false)
* @return the transaction with the given hash or null if not found
*/
virtual std::shared_ptr<monero_tx> get_tx(const std::string& tx_hash, bool prune = false) {
std::vector<std::string> hashes;
hashes.push_back(tx_hash);
auto txs = get_txs(hashes, prune);
std::shared_ptr<monero_tx> tx;
if (txs.size() > 0) {
tx = txs[0];
}
return tx;
}
/**
* Get transactions by hashes.
*
* @param tx_hashes are hashes of transactions to get
* @param prune specifies if the returned txs should be pruned (defaults to false)
* @return found transactions with the given hashes
*/
virtual std::vector<std::shared_ptr<monero_tx>> get_txs(const std::vector<std::string>& tx_hashes, bool prune = false) {
throw std::runtime_error("monero_daemon::get_txs(): not supported");
}
/**
* Get a transaction hex by hash.
*
* @param tx_hash is the hash of the transaction to get hex from
* @param prune specifies if the returned tx hex should be pruned (defaults to false)
* @return the tx hex with the given hash
*/
virtual boost::optional<std::string> get_tx_hex(const std::string& tx_hash, bool prune = false) {
std::vector<std::string> hashes;
hashes.push_back(tx_hash);
auto hexes = get_tx_hexes(hashes, prune);
boost::optional<std::string> hex;
if (hexes.size() > 0) {
hex = hexes[0];
}
return hex;
}
/**
* Get transaction hexes by hashes.
*
* @param tx_hashes are hashes of transactions to get hexes from
* @param prune specifies if the returned tx hexes should be pruned (defaults to false)
* @return are the tx hexes
*/
virtual std::vector<std::string> get_tx_hexes(const std::vector<std::string>& tx_hashes, bool prune = false) {
throw std::runtime_error("monero_daemon::get_tx_hexes(): not supported");
}
/**
* Gets the total emissions and fees from the genesis block to the current height.
*
* @param height is the height to start computing the miner sum
* @param num_blocks are the number of blocks to include in the sum
* @return the sum emission and fees since the geneis block
*/
virtual std::shared_ptr<monero_miner_tx_sum> get_miner_tx_sum(uint64_t height, uint64_t num_blocks) {
throw std::runtime_error("monero_daemon::get_miner_tx_sum(): not supported");
}
/**
* Get mining fee estimates per kB.
*
* @param grace_blocks TODO
* @return mining fee estimates per kB
*/
virtual std::shared_ptr<monero_fee_estimate> get_fee_estimate(uint64_t grace_blocks = 0) {
throw std::runtime_error("monero_daemon::get_fee_estimate(): not supported");
}
/**
* Submits a transaction to the daemon's pool.
*
* @param tx_hex is the raw transaction hex to submit
* @param do_not_relay specifies if the tx should be relayed (optional)
* @return the submission results
*/
virtual std::shared_ptr<monero_submit_tx_result> submit_tx_hex(const std::string& tx_hex, bool do_not_relay = false) {
throw std::runtime_error("monero_daemon::submit_tx_hex(): not supported");
}
/**
* Relays a transaction by hash.
*
* @param tx_hash identifies the transaction to relay
*/
virtual void relay_tx_by_hash(const std::string& tx_hash) {
std::vector<std::string> tx_hashes;
tx_hashes.push_back(tx_hash);
relay_txs_by_hash(tx_hashes);
}
/**
* Relays transactions by hash.
*
* @param tx_hashes identify the transactions to relay
*/
virtual void relay_txs_by_hash(const std::vector<std::string>& tx_hashes) {
throw std::runtime_error("monero_daemon::relay_txs_by_hash(): not supported");
}
/**
* Get valid transactions seen by the node but not yet mined into a block, as well
* as spent key image information for the tx pool.
*
* @return transactions in the transaction pool
*/
virtual std::vector<std::shared_ptr<monero_tx>> get_tx_pool() {
throw std::runtime_error("monero_daemon::get_tx_pool(): not supported");
}
/**
* Get hashes of transactions in the transaction pool.
*
* @return hashes of transactions in the transaction pool
*/
virtual std::vector<std::string> get_tx_pool_hashes() {
throw std::runtime_error("monero_daemon::get_tx_pool_hashes(): not supported");
}
/**
* Get all transaction pool backlog.
*
* @return transaction pool backlog entries
*/
virtual std::vector<monero_tx_backlog_entry> get_tx_pool_backlog() {
throw std::runtime_error("monero_daemon::get_tx_pool_backlog(): not supported");
}
/**
* Get transaction pool statistics.
*
* @return statistics about the transaction pool
*/
virtual std::shared_ptr<monero_tx_pool_stats> get_tx_pool_stats() {
throw std::runtime_error("monero_daemon::get_tx_pool_stats(): not supported");
}
/**
* Flushes all transactions from the tx pool.
*/
virtual void flush_tx_pool() {
throw std::runtime_error("monero_daemon::flush_tx_pool(): not supported");
}
/**
* Flush transactions from the tx pool.
*
* @param hashes are hashes of transactions to flush
*/
virtual void flush_tx_pool(const std::vector<std::string> &hashes) {
throw std::runtime_error("monero_daemon::flush_tx_pool(): not supported");
}
/**
* Flush a single transaction from the tx pool.
*
* @param hash is the hash of transaction to flush
*/
virtual void flush_tx_pool(const std::string &hash) {
throw std::runtime_error("monero_daemon::flush_tx_pool(): not supported");
}
/**
* Get the spent status of the given key image.
*
* @param key_image is key image hex to get the status of
* @return the status of the key image
*/
virtual monero_key_image_spent_status get_key_image_spent_status(const std::string& key_image) {
std::vector<std::string> key_images;
key_images.push_back(key_image);
auto statuses = get_key_image_spent_statuses(key_images);
if (statuses.empty()) throw std::runtime_error("Could not get key image spent status");
return statuses[0];
}
/**
* Get the spent status of each given key image.
*
* @param key_image are hex key images to get the statuses of
* @return the spent status for each key image
*/
virtual std::vector<monero_key_image_spent_status> get_key_image_spent_statuses(const std::vector<std::string>& key_images) {
throw std::runtime_error("monero_daemon::get_key_image_spent_statuses(): not supported");
}
/**
* Get outputs identified by a list of output amounts and indices as a binary
* request.
*
* @param outputs identify each output by amount and index
* @return the identified outputs
*/
virtual std::vector<std::shared_ptr<monero_output>> get_outputs(const std::vector<monero_output>& outputs) {
throw std::runtime_error("monero_daemon::get_outputs(): not supported");
}
/**
* Get a histogram of output amounts. For all amounts (possibly filtered by
* parameters), gives the number of outputs on the chain for that amount.
* RingCT outputs counts as 0 amount.
*
* @param amounts are amounts of outputs to make the histogram with
* @param min_count TODO
* @param max_count TODO
* @param is_unlocked makes a histogram with outputs with the specified lock state
* @param recent_cutoff TODO
* @return output histogram entries meeting the parameters
*/
virtual std::vector<std::shared_ptr<monero_output_histogram_entry>> get_output_histogram(const std::vector<uint64_t>& amounts, const boost::optional<int>& min_count, const boost::optional<int>& max_count, const boost::optional<bool>& is_unlocked, const boost::optional<int>& recent_cutoff) {
throw std::runtime_error("monero_daemon::get_output_histogram(): not supported");
}
/**
* Creates an output distribution.
*
* @param amounts are amounts of outputs to make the distribution with
* @param is_cumulative specifies if the results should be cumulative (defaults to TODO)
* @param start_height is the start height lower bound inclusive (optional)
* @param end_height is the end height upper bound inclusive (optional)
* @return output distribution entries meeting the parameters
*/
virtual std::vector<std::shared_ptr<monero_output_distribution_entry>> get_output_distribution(const std::vector<uint64_t>& amounts, const boost::optional<bool>& is_cumulative = boost::none, const boost::optional<uint64_t>& start_height = boost::none, const boost::optional<uint64_t>& end_height = boost::none) {
throw std::runtime_error("monero_daemon::get_output_distribution(): not supported");
}
/**
* Get general information about the state of the node and the network.
*
* @return general information about the node and network
*/
virtual std::shared_ptr<monero_daemon_info> get_info() {
throw std::runtime_error("monero_daemon::get_info(): not supported");
}
/**
* Get synchronization information.
*
* @return contains sync information
*/
virtual std::shared_ptr<monero_daemon_sync_info> get_sync_info() {
throw std::runtime_error("monero_daemon::get_sync_info(): not supported");
}
/**
* Look up information regarding hard fork voting and readiness.
*
* @return hard fork information
*/
virtual std::shared_ptr<monero_hard_fork_info> get_hard_fork_info() {
throw std::runtime_error("monero_daemon::get_hard_fork_info(): not supported");
}
/**
* Get alternative chains seen by the node.
*
* @return alternative chains seen by the node
*/
virtual std::vector<std::shared_ptr<monero_alt_chain>> get_alt_chains() {
throw std::runtime_error("monero_daemon::get_alt_chains(): not supported");
}
/**
* Get known block hashes which are not on the main chain.
*
* @return known block hashes which are not on the main chain
*/
virtual std::vector<std::string> get_alt_block_hashes() {
throw std::runtime_error("monero_daemon::get_alt_block_hashes(): not supported");
}
/**
* Get the download bandwidth limit.
*
* @return is the download bandwidth limit
*/
virtual int get_download_limit() {
throw std::runtime_error("monero_daemon::get_download_limit(): not supported");
}
/**
* Set the download bandwidth limit.
*
* @param limit is the download limit to set (-1 to reset to default)
* @return int is the new download limit after setting
*/
virtual int set_download_limit(int limit) {
throw std::runtime_error("monero_daemon::set_download_limit(): not supported");
}
/**
* Reset the download bandwidth limit.
*
* @return the download bandwidth limit after resetting
*/
virtual int reset_download_limit() {
throw std::runtime_error("monero_daemon::reset_download_limit(): not supported");
}
/**
* Get the upload bandwidth limit.
*
* @return is the upload bandwidth limit
*/
virtual int get_upload_limit() {
throw std::runtime_error("monero_daemon::get_upload_limit(): not supported");
}
/**
* Set the upload bandwidth limit.
*
* @param limit is the upload limit to set (-1 to reset to default)
* @return int is the new upload limit after setting
*/
virtual int set_upload_limit(int limit) {
throw std::runtime_error("monero_daemon::set_upload_limit(): not supported");
}
/**
* Reset the upload bandwidth limit.
*
* @return the upload bandwidth limit after resetting
*/
virtual int reset_upload_limit() {
throw std::runtime_error("monero_daemon::reset_upload_limit(): not supported");
}
/**
* Get peers with active incoming or outgoing connections to the node.
*
* @return the daemon's peers
*/
virtual std::vector<std::shared_ptr<monero_peer>> get_peers() {
throw std::runtime_error("monero_daemon::get_peers(): not supported");
}
/**
* Get all known peers including their last known online status.
*
* @return the daemon's known peers
*/
virtual std::vector<std::shared_ptr<monero_peer>> get_known_peers() {
throw std::runtime_error("monero_daemon::get_known_peers(): not supported");
}
/**
* Limit number of outgoing peers.
*
* @param limit is the maximum number of outgoing peers
*/
virtual void set_outgoing_peer_limit(int limit) {
throw std::runtime_error("monero_daemon::set_outgoing_peer_limit(): not supported");
}
/**
* Limit number of incoming peers.
*
* @param limit is the maximum number of incoming peers
*/
virtual void set_incoming_peer_limit(int limit) {
throw std::runtime_error("monero_daemon::set_incoming_peer_limit(): not supported");
}
/**
* Get peer bans.
*
* @return entries about banned peers
*/
virtual std::vector<std::shared_ptr<monero_ban>> get_peer_bans() {
throw std::runtime_error("monero_daemon::get_peer_bans(): not supported");
}
/**
* Ban peers nodes.
*
* @param bans are bans to apply against peer nodes
*/
virtual void set_peer_bans(const std::vector<std::shared_ptr<monero_ban>>& bans) {
throw std::runtime_error("monero_daemon::set_peer_bans(): not supported");
}
/**
* Ban a peer node.
*
* @param ban contains information about a node to ban
*/
virtual void set_peer_ban(const std::shared_ptr<monero_ban>& ban) {
if (ban == nullptr) throw std::runtime_error("Ban is none");
std::vector<std::shared_ptr<monero_ban>> bans;
bans.push_back(ban);
set_peer_bans(bans);
}
/**
* Start mining.
*
* @param address is the address given miner rewards if the daemon mines a block
* @param num_threads is the number of mining threads to run
* @param is_background specifies if the miner should run in the background or not
* @param ignore_battery specifies if the battery state (e.g. on laptop) should be ignored or not
*/
virtual void start_mining(const std::string &address, boost::optional<uint64_t> num_threads, boost::optional<bool> is_background, boost::optional<bool> ignore_battery) {
throw std::runtime_error("monero_daemon::start_mining(): not supported");
}
/**
* Stop mining.
*/
virtual void stop_mining() {
throw std::runtime_error("monero_daemon::stop_mining(): not supported");
}
/**
* Get the daemon's mining status.
*
* @return the daemon's mining status
*/
virtual std::shared_ptr<monero_mining_status> get_mining_status() {
throw std::runtime_error("monero_daemon::get_mining_status(): not supported");
}
/**
* Generate blocks to a wallet address (regtest only).
*
* @param wallet_address is the address of the wallet to receive miner transactions if block is successfully mined
* @param num_blocks is the number of blocks to generate
* @param prev_block_hash is the hash of the previous block to build on top of (optional, builds on the current tip if not given)
* @param starting_nonce is the starting nonce to use (optional)
* @return the result of generating blocks; height is the height of the last block generated
*/
virtual std::shared_ptr<monero_generate_blocks_result> generate_blocks(const std::string& wallet_address, uint64_t num_blocks, const boost::optional<std::string>& prev_block_hash = boost::none, const boost::optional<uint32_t>& starting_nonce = boost::none) {
throw std::runtime_error("monero_daemon::generate_blocks(): not supported");
}
/**
* Submit a mined block to the network.
*
* @param block_blob is the mined block to submit
*/
virtual void submit_block(const std::string& block_blob) {
std::vector<std::string> block_blobs;
block_blobs.push_back(block_blob);
return submit_blocks(block_blobs);
}
/**
* Submit mined blocks to the network.
*
* @param block_blobs are the mined blocks to submit
*/
virtual void submit_blocks(const std::vector<std::string>& block_blobs) {
throw std::runtime_error("monero_daemon::submit_blocks(): not supported");
}
/**
* Prune the blockchain.
*
* @param check specifies to check the pruning (default false)
* @return the prune result
*/
virtual std::shared_ptr<monero_prune_result> prune_blockchain(bool check) {
throw std::runtime_error("monero_daemon::prune_blockchain(): not supported");
}
/**
* Check for update.
*
* @return the result of the update check
*/
virtual std::shared_ptr<monero_daemon_update_check_result> check_for_update() {
throw std::runtime_error("monero_daemon::check_for_update(): not supported");
}
/**
* Download an update.
*
* @param path is the path to download the update (optional)
* @return the result of the update download
*/
virtual std::shared_ptr<monero_daemon_update_download_result> download_update(const std::string& path = "") {
throw std::runtime_error("monero_daemon::download_update(): not supported");
}
/**
* Safely disconnect and shut down the daemon.
*/
virtual void stop() {
throw std::runtime_error("monero_daemon::stop(): not supported");
}
/**
* Get the header of the next block added to the chain.
*
* @return the header of the next block added to the chain
*/
virtual std::shared_ptr<monero_block_header> wait_for_next_block_header() {
throw std::runtime_error("monero_daemon::wait_for_next_block_header(): not supported");
}
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,639 @@
/**
* Copyright (c) woodser
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#pragma once
#include <map>
#include <boost/property_tree/ptree.hpp>
#include <boost/property_tree/json_parser.hpp>
#include "rapidjson/document.h"
#include "rapidjson/writer.h"
#include "rapidjson/stringbuffer.h"
/**
* Public interface for libmonero-cpp library.
*/
namespace monero {
/**
* Base struct which can be serialized.
*/
struct serializable_struct {
virtual ~serializable_struct() = default;
/**
* Serializes the struct to a json std::string.
*
* @return the struct serialized to a json std::string
*/
std::string serialize() const;
/**
* Converts the struct to a rapidjson Value.
*
* @param allocator is the rapidjson document allocator
* @return the struct as a rapidjson Value
*/
virtual rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const = 0;
};
/**
* Models connection ssl options.
*/
struct ssl_options : public serializable_struct {
boost::optional<std::string> m_ssl_private_key_path;
boost::optional<std::string> m_ssl_certificate_path;
boost::optional<std::string> m_ssl_ca_file;
std::vector<std::string> m_ssl_allowed_fingerprints;
boost::optional<bool> m_ssl_allow_any_cert;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Enumerates Monero connection types.
*/
enum monero_connection_type : uint8_t {
INVALID = 0,
IPV4,
IPV6,
TOR,
I2P
};
/**
* Enumerates Monero network types.
*/
enum monero_network_type : uint8_t {
MAINNET = 0,
TESTNET,
STAGENET
};
/**
* Models connection bandwidth limits.
*/
struct monero_bandwidth_limits : public serializable_struct {
boost::optional<int> m_up;
boost::optional<int> m_down;
monero_bandwidth_limits() { }
monero_bandwidth_limits(int up, int down): m_up(up), m_down(down) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_bandwidth_limits>& limits);
};
/**
* Models a Monero version.
*/
struct monero_version : public serializable_struct {
boost::optional<uint32_t> m_number;
boost::optional<bool> m_is_release;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_version>& version);
};
// forward declarations
struct monero_tx;
struct monero_output;
/**
* Models a Monero block header which contains information about the block.
*
* TODO: a header that is transmitted may have fewer fields like cryptonote::block_header; separate?
*/
struct monero_block_header : public serializable_struct {
boost::optional<std::string> m_hash;
boost::optional<uint64_t> m_height;
boost::optional<uint64_t> m_timestamp;
boost::optional<uint64_t> m_size;
boost::optional<uint64_t> m_weight;
boost::optional<uint64_t> m_long_term_weight;
boost::optional<uint64_t> m_depth;
boost::optional<uint64_t> m_difficulty_low;
boost::optional<uint64_t> m_difficulty_high;
boost::optional<uint64_t> m_cumulative_difficulty_low;
boost::optional<uint64_t> m_cumulative_difficulty_high;
boost::optional<uint32_t> m_major_version;
boost::optional<uint32_t> m_minor_version;
boost::optional<uint32_t> m_nonce;
boost::optional<std::string> m_miner_tx_hash;
boost::optional<uint32_t> m_num_txs;
boost::optional<bool> m_orphan_status;
boost::optional<std::string> m_prev_hash;
boost::optional<uint64_t> m_reward;
boost::optional<std::string> m_pow_hash;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
std::shared_ptr<monero_block_header> copy(const std::shared_ptr<monero_block_header>& src, const std::shared_ptr<monero_block_header>& tgt) const;
virtual void merge(const std::shared_ptr<monero_block_header>& self, const std::shared_ptr<monero_block_header>& other);
};
/**
* Models a Monero block in the blockchain.
*/
struct monero_block : public monero_block_header {
boost::optional<std::string> m_hex;
std::shared_ptr<monero_tx> m_miner_tx;
std::vector<std::shared_ptr<monero_tx>> m_txs;
std::vector<std::string> m_tx_hashes;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
std::shared_ptr<monero_block> copy(const std::shared_ptr<monero_block>& src, const std::shared_ptr<monero_block>& tgt) const;
void merge(const std::shared_ptr<monero_block_header>& self, const std::shared_ptr<monero_block_header>& other);
void merge(const std::shared_ptr<monero_block>& self, const std::shared_ptr<monero_block>& other);
};
/**
* Models a Monero transaction on the blockchain.
*/
struct monero_tx : public serializable_struct {
static const std::string DEFAULT_PAYMENT_ID; // default payment id "0000000000000000"
static const std::string DEFAULT_ID;
std::shared_ptr<monero_block> m_block;
boost::optional<std::string> m_hash;
boost::optional<uint32_t> m_version;
boost::optional<bool> m_is_miner_tx;
boost::optional<std::string> m_payment_id;
boost::optional<uint64_t> m_fee;
boost::optional<uint32_t> m_ring_size;
boost::optional<bool> m_relay;
boost::optional<bool> m_is_relayed;
boost::optional<bool> m_is_confirmed;
boost::optional<bool> m_in_tx_pool;
boost::optional<uint64_t> m_num_confirmations;
boost::optional<uint64_t> m_unlock_time;
boost::optional<uint64_t> m_last_relayed_timestamp;
boost::optional<uint64_t> m_received_timestamp;
boost::optional<bool> m_is_double_spend_seen;
boost::optional<std::string> m_key;
boost::optional<std::string> m_full_hex;
boost::optional<std::string> m_pruned_hex;
boost::optional<std::string> m_prunable_hex;
boost::optional<std::string> m_prunable_hash;
boost::optional<uint64_t> m_size;
boost::optional<uint64_t> m_weight;
std::vector<std::shared_ptr<monero_output>> m_inputs;
std::vector<std::shared_ptr<monero_output>> m_outputs;
std::vector<uint64_t> m_output_indices;
boost::optional<std::string> m_metadata;
boost::optional<std::string> m_common_tx_sets;
std::vector<uint8_t> m_extra;
boost::optional<std::string> m_rct_signatures; // TODO: implement
boost::optional<std::string> m_rct_sig_prunable; // TODO: implement
boost::optional<bool> m_is_kept_by_block;
boost::optional<bool> m_is_failed;
boost::optional<uint64_t> m_last_failed_height;
boost::optional<std::string> m_last_failed_hash;
boost::optional<uint64_t> m_max_used_block_height;
boost::optional<std::string> m_max_used_block_hash;
std::vector<std::string> m_signatures;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, std::shared_ptr<monero_tx> tx);
std::shared_ptr<monero_tx> copy(const std::shared_ptr<monero_tx>& src, const std::shared_ptr<monero_tx>& tgt) const;
virtual void merge(const std::shared_ptr<monero_tx>& self, const std::shared_ptr<monero_tx>& other);
boost::optional<uint64_t> get_height() const;
};
/**
* Models a Monero key image.
*/
struct monero_key_image : public serializable_struct {
boost::optional<std::string> m_hex;
boost::optional<std::string> m_signature;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_key_image>& key_image);
static std::vector<std::shared_ptr<monero_key_image>> deserialize_key_images(const std::string& key_images_json); // TODO: remove this specialty util used once
std::shared_ptr<monero_key_image> copy(const std::shared_ptr<monero_key_image>& src, const std::shared_ptr<monero_key_image>& tgt) const;
void merge(const std::shared_ptr<monero_key_image>& self, const std::shared_ptr<monero_key_image>& other);
};
/**
* Models a Monero transaction output.
*/
struct monero_output : public serializable_struct {
std::shared_ptr<monero_tx> m_tx;
std::shared_ptr<monero_key_image> m_key_image;
boost::optional<uint64_t> m_amount;
boost::optional<uint64_t> m_index;
std::vector<uint64_t> m_ring_output_indices;
boost::optional<std::string> m_stealth_public_key;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_output>& output);
std::shared_ptr<monero_output> copy(const std::shared_ptr<monero_output>& src, const std::shared_ptr<monero_output>& tgt) const;
virtual void merge(const std::shared_ptr<monero_output>& self, const std::shared_ptr<monero_output>& other);
};
/**
* Models the status of a Monero key image.
*/
enum monero_key_image_spent_status : uint8_t {
NOT_SPENT = 0,
CONFIRMED,
TX_POOL
};
/**
* Models a Monero RPC payment information.
*/
struct monero_rpc_payment_info : public serializable_struct {
boost::optional<uint64_t> m_credits;
boost::optional<std::string> m_top_block_hash;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_rpc_payment_info>& rpc_payment_info);
};
/**
* Models an alternative chain seen by the node.
*/
struct monero_alt_chain : public serializable_struct {
std::vector<std::string> m_block_hashes;
boost::optional<uint64_t> m_difficulty_low;
boost::optional<uint64_t> m_difficulty_high;
boost::optional<uint64_t> m_height;
boost::optional<uint64_t> m_length;
boost::optional<std::string> m_main_chain_parent_block_hash;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_alt_chain>& alt_chain);
};
/**
* Monero banhammer.
*/
struct monero_ban : public serializable_struct {
boost::optional<std::string> m_host;
boost::optional<uint32_t> m_ip;
boost::optional<bool> m_is_banned;
boost::optional<uint64_t> m_seconds;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_ban>& ban);
};
/**
* Result of pruning the blockchain.
*/
struct monero_prune_result : public serializable_struct {
boost::optional<bool> m_is_pruned;
boost::optional<int> m_pruning_seed;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_prune_result>& result);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Monero daemon mining status.
*/
struct monero_mining_status : public serializable_struct {
boost::optional<bool> m_is_active;
boost::optional<bool> m_is_background;
boost::optional<std::string> m_address;
boost::optional<uint64_t> m_speed;
boost::optional<int> m_num_threads;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_mining_status>& status);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Model for the summation of miner emissions and fees.
*/
struct monero_miner_tx_sum : public serializable_struct {
boost::optional<uint64_t> m_emission_sum_low;
boost::optional<uint64_t> m_emission_sum_high;
boost::optional<uint64_t> m_fee_sum_low;
boost::optional<uint64_t> m_fee_sum_high;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_miner_tx_sum>& sum);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Monero block template to mine.
*/
struct monero_block_template : public serializable_struct {
boost::optional<std::string> m_block_template_blob;
boost::optional<std::string> m_block_hashing_blob;
boost::optional<std::string> m_prev_hash;
boost::optional<std::string> m_seed_hash;
boost::optional<std::string> m_next_seed_hash;
boost::optional<uint64_t> m_difficulty_low;
boost::optional<uint64_t> m_difficulty_high;
boost::optional<uint64_t> m_expected_reward;
boost::optional<uint64_t> m_height;
boost::optional<uint64_t> m_reserved_offset;
boost::optional<uint64_t> m_seed_height;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_block_template>& tmplt);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Monero daemon connection span.
*/
struct monero_connection_span : public serializable_struct {
boost::optional<std::string> m_connection_id;
boost::optional<std::string> m_remote_address;
boost::optional<uint64_t> m_num_blocks;
boost::optional<uint64_t> m_rate;
boost::optional<uint64_t> m_speed;
boost::optional<uint64_t> m_size;
boost::optional<uint64_t> m_start_height;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_connection_span>& span);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Models a peer to the daemon.
*/
struct monero_peer : public serializable_struct {
boost::optional<std::string> m_id;
boost::optional<std::string> m_address;
boost::optional<std::string> m_host;
boost::optional<int> m_port;
boost::optional<bool> m_is_online;
boost::optional<uint64_t> m_last_seen_timestamp;
boost::optional<int> m_pruning_seed;
boost::optional<int> m_rpc_port;
boost::optional<uint64_t> m_rpc_credits_per_hash;
boost::optional<std::string> m_hash;
boost::optional<uint64_t> m_avg_download;
boost::optional<uint64_t> m_avg_upload;
boost::optional<uint64_t> m_current_download;
boost::optional<uint64_t> m_current_upload;
boost::optional<uint64_t> m_height;
boost::optional<bool> m_is_incoming;
boost::optional<uint64_t> m_live_time;
boost::optional<bool> m_is_local_ip;
boost::optional<bool> m_is_local_host;
boost::optional<uint64_t> m_num_receives;
boost::optional<uint64_t> m_num_sends;
boost::optional<uint64_t> m_receive_idle_time;
boost::optional<uint64_t> m_send_idle_time;
boost::optional<std::string> m_state;
boost::optional<int> m_num_support_flags;
boost::optional<monero_connection_type> m_connection_type;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_peer>& peer);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Models the result from submitting a tx to a daemon.
*/
struct monero_submit_tx_result : public monero_rpc_payment_info {
boost::optional<bool> m_has_invalid_input;
boost::optional<bool> m_has_invalid_output;
boost::optional<bool> m_has_too_few_outputs;
boost::optional<bool> m_is_good;
boost::optional<bool> m_is_relayed;
boost::optional<bool> m_is_double_spend;
boost::optional<bool> m_is_fee_too_low;
boost::optional<bool> m_is_mixin_too_low;
boost::optional<bool> m_is_overspend;
boost::optional<bool> m_is_too_big;
boost::optional<bool> m_sanity_check_failed;
boost::optional<bool> m_is_tx_extra_too_big;
boost::optional<bool> m_is_nonzero_unlock_time;
boost::optional<std::string> m_reason;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_submit_tx_result>& result);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* TODO.
*/
struct monero_tx_backlog_entry {
// TODO
};
/**
* Monero output distribution entry.
*/
struct monero_output_distribution_entry : public serializable_struct {
boost::optional<uint64_t> m_amount;
boost::optional<uint64_t> m_base;
std::vector<uint64_t> m_distribution;
boost::optional<uint64_t> m_start_height;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_output_distribution_entry>& entry);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Entry in a Monero output histogram (see get_output_histogram of Daemon RPC documentation).
*/
struct monero_output_histogram_entry : public serializable_struct {
boost::optional<uint64_t> m_amount;
boost::optional<uint64_t> m_num_instances;
boost::optional<uint64_t> m_unlocked_instances;
boost::optional<uint64_t> m_recent_instances;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_output_histogram_entry>& entry);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Models transaction pool statistics.
*/
struct monero_tx_pool_stats : public serializable_struct {
boost::optional<int> m_num_txs;
boost::optional<int> m_num_not_relayed;
boost::optional<int> m_num_failing;
boost::optional<int> m_num_double_spends;
boost::optional<int> m_num10m;
boost::optional<uint64_t> m_fee_total;
boost::optional<uint64_t> m_bytes_max;
boost::optional<uint64_t> m_bytes_med;
boost::optional<uint64_t> m_bytes_min;
boost::optional<uint64_t> m_bytes_total;
std::map<uint64_t, uint64_t> m_histo;
boost::optional<uint64_t> m_histo98pc;
boost::optional<uint64_t> m_oldest_timestamp;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_tx_pool_stats>& stats);
};
/**
* Models the result of checking for a daemon update.
*/
struct monero_daemon_update_check_result : public serializable_struct {
boost::optional<bool> m_is_update_available;
boost::optional<std::string> m_version;
boost::optional<std::string> m_hash;
boost::optional<std::string> m_auto_uri;
boost::optional<std::string> m_user_uri;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_daemon_update_check_result>& check);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Models the result of downloading an update.
*/
struct monero_daemon_update_download_result : public monero_daemon_update_check_result {
boost::optional<std::string> m_download_path;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_daemon_update_download_result>& check);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Models a Monero fee estimate.
*/
struct monero_fee_estimate : public serializable_struct {
boost::optional<uint64_t> m_quantization_mask;
boost::optional<uint64_t> m_fee;
std::vector<uint64_t> m_fees;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_fee_estimate>& estimate);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Monero daemon info.
*/
struct monero_daemon_info : public monero_rpc_payment_info {
boost::optional<std::string> m_version;
boost::optional<uint64_t> m_num_alt_blocks;
boost::optional<uint64_t> m_block_size_limit;
boost::optional<uint64_t> m_block_size_median;
boost::optional<uint64_t> m_block_weight_limit;
boost::optional<uint64_t> m_block_weight_median;
boost::optional<std::string> m_bootstrap_daemon_address;
boost::optional<uint64_t> m_difficulty_low;
boost::optional<uint64_t> m_difficulty_high;
boost::optional<uint64_t> m_cumulative_difficulty_low;
boost::optional<uint64_t> m_cumulative_difficulty_high;
boost::optional<uint64_t> m_free_space;
boost::optional<int> m_num_offline_peers;
boost::optional<int> m_num_online_peers;
boost::optional<uint64_t> m_height;
boost::optional<uint64_t> m_height_without_bootstrap;
boost::optional<monero_network_type> m_network_type;
boost::optional<bool> m_is_offline;
boost::optional<int> m_num_incoming_connections;
boost::optional<int> m_num_outgoing_connections;
boost::optional<int> m_num_rpc_connections;
boost::optional<uint64_t> m_start_timestamp;
boost::optional<uint64_t> m_adjusted_timestamp;
boost::optional<uint64_t> m_target;
boost::optional<uint64_t> m_target_height;
boost::optional<int> m_num_txs;
boost::optional<int> m_num_txs_pool;
boost::optional<bool> m_was_bootstrap_ever_used;
boost::optional<uint64_t> m_database_size;
boost::optional<bool> m_update_available;
boost::optional<bool> m_is_busy_syncing;
boost::optional<bool> m_is_synchronized;
boost::optional<bool> m_is_restricted;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_daemon_info>& info);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Models daemon synchronization information.
*/
struct monero_daemon_sync_info : public monero_rpc_payment_info {
boost::optional<std::string> m_overview;
boost::optional<uint64_t> m_height;
boost::optional<uint64_t> m_target_height;
boost::optional<int> m_next_needed_pruning_seed;
std::vector<std::shared_ptr<monero_peer>> m_peers;
std::vector<std::shared_ptr<monero_connection_span>> m_spans;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_daemon_sync_info>& info);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Monero hard fork info.
*/
struct monero_hard_fork_info : public monero_rpc_payment_info {
boost::optional<bool> m_is_enabled;
boost::optional<uint64_t> m_earliest_height;
boost::optional<int> m_state;
boost::optional<int> m_threshold;
boost::optional<int> m_version;
boost::optional<int> m_num_votes;
boost::optional<int> m_window;
boost::optional<int> m_voting;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_hard_fork_info>& info);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Models the result of generating blocks.
*/
struct monero_generate_blocks_result : public serializable_struct {
std::vector<std::string> m_block_hashes;
boost::optional<uint64_t> m_height;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_generate_blocks_result>& result);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
}
@@ -0,0 +1,892 @@
/**
* Copyright (c) everoddandeven
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2025-2026 woodser
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#include "monero_daemon_rpc.h"
#include "monero_daemon_rpc_model.h"
#include "common/monero_error.h"
#include "utils/gen_utils.h"
#include "utils/monero_utils.h"
static const uint64_t MAX_REQ_SIZE = 3000000;
static const uint64_t NUM_HEADERS_PER_REQ = 750;
namespace monero {
/**
* Polls daemon and sends notifications in order to notify external daemon listeners.
*/
class monero_daemon_poller : public gen_utils::thread_poller {
public:
explicit monero_daemon_poller(monero_daemon* daemon, uint64_t poll_period_ms = 10000): m_daemon(daemon) {
init_common("monero_daemon_rpc");
m_poll_period_ms = poll_period_ms;
}
~monero_daemon_poller() override {
set_is_polling(false);
}
void poll() override {
if (!m_last_header) {
m_last_header = m_daemon->get_last_block_header();
return;
}
auto header = m_daemon->get_last_block_header();
if (header->m_hash != m_last_header->m_hash) {
m_last_header = header;
announce_block_header(header);
}
}
private:
monero_daemon* m_daemon;
std::shared_ptr<monero_block_header> m_last_header;
void announce_block_header(const std::shared_ptr<monero_block_header>& header) {
gen_utils::thread_poller::announce_scope scope(*this); // see wait_for_callbacks_idle()
auto listeners = m_daemon->get_listeners();
for (auto& listener : listeners) {
try {
listener->on_block_header(header);
} catch (const std::exception& e) {
MERROR("Error calling listener on new block header: " << e.what());
}
}
}
};
/**
* Sends a notification on a condition variable when a block is added to blockchain.
*/
class block_notifier : public monero_daemon_listener {
public:
block_notifier(boost::mutex* temp, boost::condition_variable* cv, bool* ready) { this->temp = temp; this->cv = cv; this->ready = ready; }
void on_block_header(const std::shared_ptr<monero_block_header>& header) override {
boost::mutex::scoped_lock lock(*temp);
m_last_header = header;
*ready = true;
cv->notify_one();
}
private:
boost::mutex* temp;
boost::condition_variable* cv;
bool* ready;
};
void monero_daemon_rpc::set_poll_period_in_ms(uint64_t period_ms) {
boost::lock_guard<boost::mutex> poller_lock(m_poller_mutex);
if (!m_poller) m_poller = std::make_unique<monero_daemon_poller>(this);
m_poller->set_period_in_ms(period_ms);
}
/**
* Validate RPC response status.
*/
void check_response_status(const boost::property_tree::ptree& node) {
for (boost::property_tree::ptree::const_iterator it = node.begin(); it != node.end(); ++it) {
std::string key = it->first;
if (key == std::string("status")) {
std::string status = it->second.data();
// TODO monero-project empty string status is returned for download update response when an update is available
if (status == std::string("OK") || status == std::string("")) {
return;
}
else throw monero_rpc_error(status);
}
}
throw monero_error("Could not get JSON RPC response status");
}
monero_daemon_rpc::monero_daemon_rpc(const std::shared_ptr<monero_rpc_connection>& rpc): m_rpc(rpc) {
if (rpc == nullptr) throw monero_error("Connection cannot be null");
if (!rpc->is_online().value_or(false) && rpc->m_uri != boost::none) rpc->check_connection();
}
monero_daemon_rpc::monero_daemon_rpc(const std::string& uri, const std::string& username, const std::string& password, const std::string& proxy_uri, const std::string& zmq_uri, const boost::optional<uint32_t>& timeout):
m_rpc(std::make_shared<monero_rpc_connection>(uri, username, password, proxy_uri, zmq_uri, 0, timeout)) {
if (!uri.empty()) m_rpc->check_connection();
}
std::set<monero_daemon_listener*> monero_daemon_rpc::get_listeners() {
boost::lock_guard<boost::recursive_mutex> lock(m_listeners_mutex);
return m_listeners;
}
void monero_daemon_rpc::add_listener(monero_daemon_listener &listener) {
{
boost::lock_guard<boost::recursive_mutex> lock(m_listeners_mutex);
m_listeners.insert(&listener);
}
refresh_listening();
}
void monero_daemon_rpc::remove_listener(monero_daemon_listener &listener) {
{
boost::lock_guard<boost::recursive_mutex> lock(m_listeners_mutex);
m_listeners.erase(&listener);
}
refresh_listening();
wait_for_listener_callbacks_idle();
}
void monero_daemon_rpc::remove_listeners() {
{
boost::lock_guard<boost::recursive_mutex> lock(m_listeners_mutex);
m_listeners.clear();
}
refresh_listening();
wait_for_listener_callbacks_idle();
}
void monero_daemon_rpc::wait_for_listener_callbacks_idle() {
// ensures no in-flight callback dispatch can still reference a just-removed listener
monero_daemon_poller* poller;
{
boost::lock_guard<boost::mutex> poller_lock(m_poller_mutex);
poller = m_poller.get();
}
if (poller != nullptr) poller->wait_for_callbacks_idle();
}
std::shared_ptr<monero_rpc_connection> monero_daemon_rpc::get_rpc_connection() const {
MTRACE("monero_daemon_rpc::get_rpc_connection()");
return m_rpc;
}
bool monero_daemon_rpc::is_connected() {
try {
get_version();
return true;
}
catch (...) {
return false;
}
}
monero_version monero_daemon_rpc::get_version() {
auto result = m_rpc->send_json_request("get_version");
check_response_status(result);
monero_version version;
deserialize_version(result, version);
return version;
}
bool monero_daemon_rpc::is_trusted() {
auto result = m_rpc->send_path_request("get_height");
check_response_status(result);
auto get_height_response = std::make_shared<monero_get_block_result>();
monero_get_block_result::from_property_tree(result, get_height_response);
return gen_utils::bool_equals(false, get_height_response->m_untrusted);
}
uint64_t monero_daemon_rpc::get_height() {
auto result = m_rpc->send_json_request("get_block_count");
check_response_status(result);
std::shared_ptr<monero_get_block_result> block_result = std::make_shared<monero_get_block_result>();
monero_get_block_result::from_property_tree(result, block_result);
if (block_result->m_count == boost::none) throw monero_error("Could not get height");
return block_result->m_count.get();
}
std::string monero_daemon_rpc::get_block_hash(uint64_t height) {
std::shared_ptr<monero_get_block_hash_params> params = std::make_shared<monero_get_block_hash_params>(height);
auto result = m_rpc->send_json_request("on_get_block_hash", params);
return result.data();
}
std::shared_ptr<monero_block_template> monero_daemon_rpc::get_block_template(const std::string& wallet_address, const boost::optional<int>& reserve_size) {
MTRACE("monero_daemon_rpc::get_block_template()");
auto params = std::make_shared<monero_get_block_params>(wallet_address, reserve_size);
auto result = m_rpc->send_json_request("get_block_template", params);
check_response_status(result);
std::shared_ptr<monero_block_template> tmplt = std::make_shared<monero_block_template>();
deserialize_block_template(result, tmplt);
return tmplt;
}
std::shared_ptr<monero_block_header> monero_daemon_rpc::get_last_block_header() {
auto result = m_rpc->send_json_request("get_last_block_header");
check_response_status(result);
std::shared_ptr<monero_block_header> header = std::make_shared<monero_block_header>();
deserialize_block_header(result, header);
return header;
}
std::shared_ptr<monero_block_header> monero_daemon_rpc::get_block_header_by_hash(const std::string& hash) {
std::shared_ptr<monero_get_block_params> params = std::make_shared<monero_get_block_params>(hash);
auto result = m_rpc->send_json_request("get_block_header_by_hash", params);
check_response_status(result);
std::shared_ptr<monero_block_header> header = std::make_shared<monero_block_header>();
deserialize_block_header(result, header);
return header;
}
std::shared_ptr<monero_block_header> monero_daemon_rpc::get_block_header_by_height(uint64_t height) {
std::shared_ptr<monero_get_block_params> params = std::make_shared<monero_get_block_params>(height);
auto result = m_rpc->send_json_request("get_block_header_by_height", params);
check_response_status(result);
std::shared_ptr<monero_block_header> header = std::make_shared<monero_block_header>();
deserialize_block_header(result, header);
return header;
}
std::vector<std::shared_ptr<monero_block_header>> monero_daemon_rpc::get_block_headers_by_range(uint64_t start_height, uint64_t end_height) {
auto params = std::make_shared<monero_get_block_params>(start_height, end_height);
auto result = m_rpc->send_json_request("get_block_headers_range", params);
check_response_status(result);
std::vector<std::shared_ptr<monero_block_header>> headers;
deserialize_block_headers(result, headers);
return headers;
}
std::shared_ptr<monero_block> monero_daemon_rpc::get_block_by_hash(const std::string& hash) {
std::shared_ptr<monero_get_block_params> params = std::make_shared<monero_get_block_params>(hash);
auto result = m_rpc->send_json_request("get_block", params);
check_response_status(result);
auto block = std::make_shared<monero_block>();
deserialize_block(result, block);
return block;
}
std::shared_ptr<monero_block_header> monero_daemon_rpc::get_block_header_by_height_cached(uint64_t height, uint64_t max_height, std::unordered_map<uint64_t, std::shared_ptr<monero_block_header>>& header_cache) {
// get header from cache
auto found = header_cache.find(height);
if (found != header_cache.end()) return found->second;
// fetch and cache headers if not in cache
uint64_t end_height = std::min(max_height, height + NUM_HEADERS_PER_REQ - 1);
auto headers = get_block_headers_by_range(height, end_height);
for(const auto& header : headers) {
if (header->m_height == boost::none) throw monero_error("Malformed block header: expected height to be defined");
header_cache[header->m_height.get()] = header;
}
// look up rather than default-construct via operator[]: a daemon response missing the
// requested height must not silently cache (and return) a null header
found = header_cache.find(height);
if (found == header_cache.end()) throw monero_error("Daemon did not return a header for height " + std::to_string(height));
return found->second;
}
std::vector<std::shared_ptr<monero_block>> monero_daemon_rpc::get_blocks_by_hash(const std::vector<std::string>& block_hashes, uint64_t start_height, bool prune) {
throw monero_error("monero_daemon_rpc::get_blocks_by_hash(): not implemented");
}
std::shared_ptr<monero_block> monero_daemon_rpc::get_block_by_height(uint64_t height) {
std::shared_ptr<monero_get_block_params> params = std::make_shared<monero_get_block_params>(height);
auto result = m_rpc->send_json_request("get_block", params);
check_response_status(result);
auto block = std::make_shared<monero_block>();
deserialize_block(result, block);
return block;
}
std::vector<std::shared_ptr<monero_block>> monero_daemon_rpc::get_blocks_by_height(const std::vector<uint64_t>& heights) {
// fetch blocks in binary
monero_get_blocks_by_height_request request(heights);
auto response = m_rpc->send_binary_request(request);
if (response.m_binary == boost::none) throw monero_error("Invalid Monero Binary response");
boost::property_tree::ptree node;
monero_utils::binary_blocks_to_property_tree(response.m_binary.get(), node);
check_response_status(node);
std::vector<std::shared_ptr<monero_block>> blocks;
deserialize_blocks(node, heights, blocks);
return blocks;
}
std::vector<std::shared_ptr<monero_block>> monero_daemon_rpc::get_blocks_by_range(boost::optional<uint64_t> start_height, boost::optional<uint64_t> end_height) {
if (start_height == boost::none) start_height = 0;
if (end_height == boost::none) {
uint64_t block_height = get_height();
end_height = block_height == 0 ? 0 : block_height - 1;
}
std::vector<uint64_t> heights;
for (uint64_t height = start_height.get(); height <= end_height.get(); height++) heights.push_back(height);
return get_blocks_by_height(heights);
}
std::vector<std::shared_ptr<monero_block>> monero_daemon_rpc::get_blocks_by_range_chunked(boost::optional<uint64_t> start_height, boost::optional<uint64_t> end_height, boost::optional<uint64_t> max_chunk_size) {
if (start_height == boost::none) start_height = 0;
if (end_height == boost::none) {
uint64_t block_height = get_height();
end_height = block_height == 0 ? 0 : block_height - 1;
}
boost::optional<uint64_t> last_height = boost::none;
std::vector<std::shared_ptr<monero_block>> blocks;
if (start_height.get() > end_height.get()) return blocks;
// scoped to this call: see the comment on get_max_blocks()/get_block_header_by_height_cached() in the header
std::unordered_map<uint64_t, std::shared_ptr<monero_block_header>> header_cache;
while (last_height == boost::none || last_height.get() < end_height.get()) {
uint64_t height_to_get = (last_height == boost::none) ? start_height.get() : last_height.get() + 1;
auto max_blocks = get_max_blocks(height_to_get, end_height, max_chunk_size, header_cache);
if (max_blocks.empty()) throw monero_error("Daemon returned an empty block chunk for a non-empty range at height " + std::to_string(height_to_get));
blocks.insert(blocks.end(), max_blocks.begin(), max_blocks.end());
last_height = blocks[blocks.size() - 1]->m_height.get();
}
return blocks;
}
std::vector<std::shared_ptr<monero_block>> monero_daemon_rpc::get_max_blocks(boost::optional<uint64_t> start_height, boost::optional<uint64_t> max_height, boost::optional<uint64_t> chunk_size, std::unordered_map<uint64_t, std::shared_ptr<monero_block_header>>& header_cache) {
if (start_height == boost::none) start_height = 0;
if (max_height == boost::none) {
uint64_t block_height = get_height();
max_height = block_height == 0 ? 0 : block_height - 1;
}
if (chunk_size == boost::none) chunk_size = MAX_REQ_SIZE;
// determine end height to fetch
uint64_t req_size = 0;
boost::optional<uint64_t> end_height = boost::none; // none = no block accepted yet
uint64_t height_to_get = start_height.get();
while (req_size < chunk_size.get() && height_to_get <= max_height.get()) {
// get header of next block
auto header = get_block_header_by_height_cached(height_to_get, max_height.get(), header_cache);
if (header->m_size == boost::none) throw monero_error("Malformed block header: expected size to be defined");
uint64_t header_size = header->m_size.get();
// block cannot be bigger than max request size
if (header_size > chunk_size.get()) throw monero_error("Block exceeds maximum request size: " + std::to_string(header_size));
// done iterating if fetching block would exceed max request size
if (req_size + header_size > chunk_size.get()) break;
// otherwise block is included
req_size += header_size;
end_height = height_to_get;
height_to_get++;
}
if (end_height != boost::none) {
return get_blocks_by_range(start_height, end_height.get());
}
return std::vector<std::shared_ptr<monero_block>>();
}
std::vector<std::string> monero_daemon_rpc::get_block_hashes(const std::vector<std::string>& block_hashes, uint64_t start_height) {
throw monero_error("monero_daemon_rpc::get_block_hashes(): not implemented");
}
std::vector<std::shared_ptr<monero_tx>> monero_daemon_rpc::get_txs(const std::vector<std::string>& tx_hashes, bool prune) {
MTRACE("monero_daemon_rpc::get_txs()");
if (tx_hashes.empty()) throw monero_error("Must provide an array of transaction hashes");
auto params = std::make_shared<monero_get_txs_params>(tx_hashes, prune);
auto result = m_rpc->send_path_request("get_transactions", params);
try { check_response_status(result); }
catch (const std::exception& ex) {
if (std::string(ex.what()).find("Failed to parse hex representation of transaction hash") != std::string::npos) {
throw monero_error("Invalid transaction hash");
}
throw;
}
std::vector<std::shared_ptr<monero_tx>> txs;
deserialize_txs(result, txs);
return txs;
}
std::vector<std::string> monero_daemon_rpc::get_tx_hexes(const std::vector<std::string>& tx_hashes, bool prune) {
MTRACE("monero_daemon_rpc::get_tx_hexes()");
std::vector<std::string> hexes;
for(const auto& tx : get_txs(tx_hashes, prune)) {
// tx may be pruned regardless of configuration
if (tx->m_pruned_hex == boost::none) {
if (tx->m_full_hex == boost::none) throw monero_error("Tx has no hex");
hexes.push_back(tx->m_full_hex.get());
} else {
hexes.push_back(tx->m_pruned_hex.get());
}
}
return hexes;
}
std::shared_ptr<monero_miner_tx_sum> monero_daemon_rpc::get_miner_tx_sum(uint64_t height, uint64_t num_blocks) {
auto params = std::make_shared<monero_get_miner_tx_sum_params>(height, num_blocks);
auto result = m_rpc->send_json_request("get_coinbase_tx_sum", params);
check_response_status(result);
auto sum = std::make_shared<monero_miner_tx_sum>();
deserialize_miner_tx_sum(result, sum);
return sum;
}
std::shared_ptr<monero_fee_estimate> monero_daemon_rpc::get_fee_estimate(uint64_t grace_blocks) {
auto params = std::make_shared<monero_get_fee_estimate_params>(grace_blocks);
auto result = m_rpc->send_json_request("get_fee_estimate", params);
check_response_status(result);
auto estimate = std::make_shared<monero_fee_estimate>();
deserialize_fee_estimate(result, estimate);
return estimate;
}
std::shared_ptr<monero_submit_tx_result> monero_daemon_rpc::submit_tx_hex(const std::string& tx_hex, bool do_not_relay) {
MTRACE("monero_daemon_rpc::submit_tx_hex()");
auto params = std::make_shared<monero_submit_tx_params>(tx_hex, do_not_relay);
auto result = m_rpc->send_path_request("send_raw_transaction", params);
auto sum = std::make_shared<monero_submit_tx_result>();
deserialize_submit_tx_result(result, sum);
// set m_is_good based on status
try {
check_response_status(result);
sum->m_is_good = true;
} catch (...) {
sum->m_is_good = false;
}
return sum;
}
void monero_daemon_rpc::relay_txs_by_hash(const std::vector<std::string>& tx_hashes) {
MTRACE("monero_daemon_rpc::relay_txs_by_hash()");
auto params = std::make_shared<monero_submit_tx_params>(tx_hashes);
auto result = m_rpc->send_json_request("relay_tx", params);
check_response_status(result);
}
std::shared_ptr<monero_tx_pool_stats> monero_daemon_rpc::get_tx_pool_stats() {
auto result = m_rpc->send_path_request("get_transaction_pool_stats");
check_response_status(result);
auto stats = std::make_shared<monero_tx_pool_stats>();
deserialize_tx_pool_stats(result, stats);
return stats;
}
std::vector<std::shared_ptr<monero_tx>> monero_daemon_rpc::get_tx_pool() {
auto result = m_rpc->send_path_request("get_transaction_pool");
check_response_status(result);
std::vector<std::shared_ptr<monero_tx>> pool;
deserialize_txs(result, pool);
return pool;
}
std::vector<std::string> monero_daemon_rpc::get_tx_pool_hashes() {
auto result = m_rpc->send_path_request("get_transaction_pool_hashes");
check_response_status(result);
std::vector<std::string> tx_hashes;
deserialize_tx_hashes(result, tx_hashes);
return tx_hashes;
}
void monero_daemon_rpc::flush_tx_pool(const std::vector<std::string> &hashes) {
MTRACE("monero_daemon_rpc::flush_tx_pool()");
auto params = std::make_shared<monero_submit_tx_params>(hashes);
auto result = m_rpc->send_json_request("flush_txpool", params);
check_response_status(result);
}
void monero_daemon_rpc::flush_tx_pool() {
std::vector<std::string> hashes;
flush_tx_pool(hashes);
}
void monero_daemon_rpc::flush_tx_pool(const std::string &hash) {
std::vector<std::string> hashes;
hashes.push_back(hash);
flush_tx_pool(hashes);
}
std::vector<monero_key_image_spent_status> monero_daemon_rpc::get_key_image_spent_statuses(const std::vector<std::string>& key_images) {
if (key_images.empty()) throw monero_error("Must provide key images to check the status of");
auto params = std::make_shared<monero_is_key_image_spent_params>(key_images);
auto result = m_rpc->send_path_request("is_key_image_spent", params);
check_response_status(result);
std::vector<monero_key_image_spent_status> statuses;
deserialize_key_image_spent_status(result, statuses);
return statuses;
}
std::vector<std::shared_ptr<monero_output>> monero_daemon_rpc::get_outputs(const std::vector<monero_output>& outputs) {
throw monero_error("monero_daemon_rpc::get_outputs(): not implemented");
}
std::vector<std::shared_ptr<monero_output_histogram_entry>> monero_daemon_rpc::get_output_histogram(const std::vector<uint64_t>& amounts, const boost::optional<int>& min_count, const boost::optional<int>& max_count, const boost::optional<bool>& is_unlocked, const boost::optional<int>& recent_cutoff) {
MTRACE("monero_daemon_rpc::get_output_histogram()");
auto params = std::make_shared<monero_get_output_histogram_params>(amounts, min_count, max_count, is_unlocked, recent_cutoff);
auto result = m_rpc->send_json_request("get_output_histogram", params);
check_response_status(result);
std::vector<std::shared_ptr<monero_output_histogram_entry>> entries;
deserialize_output_histogram_entries(result, entries);
return entries;
}
std::vector<std::shared_ptr<monero_output_distribution_entry>> monero_daemon_rpc::get_output_distribution(const std::vector<uint64_t>& amounts, const boost::optional<bool>& is_cumulative, const boost::optional<uint64_t>& start_height, const boost::optional<uint64_t>& end_height) {
MTRACE("monero_daemon_rpc::get_output_distribution()");
auto params = std::make_shared<monero_get_output_distribution_params>(amounts, is_cumulative, start_height, end_height);
auto result = m_rpc->send_json_request("get_output_distribution", params);
check_response_status(result);
std::vector<std::shared_ptr<monero_output_distribution_entry>> entries;
deserialize_output_distribution_entries(result, entries);
return entries;
}
std::shared_ptr<monero_daemon_info> monero_daemon_rpc::get_info() {
auto result = m_rpc->send_json_request("get_info");
check_response_status(result);
std::shared_ptr<monero_daemon_info> info = std::make_shared<monero_daemon_info>();
deserialize_daemon_info(result, info);
return info;
}
std::shared_ptr<monero_daemon_sync_info> monero_daemon_rpc::get_sync_info() {
auto result = m_rpc->send_json_request("sync_info");
check_response_status(result);
std::shared_ptr<monero_daemon_sync_info> info = std::make_shared<monero_daemon_sync_info>();
deserialize_daemon_sync_info(result, info);
return info;
}
std::shared_ptr<monero_hard_fork_info> monero_daemon_rpc::get_hard_fork_info() {
auto result = m_rpc->send_json_request("hard_fork_info");
check_response_status(result);
std::shared_ptr<monero_hard_fork_info> info = std::make_shared<monero_hard_fork_info>();
deserialize_hard_fork_info(result, info);
return info;
}
std::vector<std::shared_ptr<monero_alt_chain>> monero_daemon_rpc::get_alt_chains() {
auto result = m_rpc->send_json_request("get_alternate_chains");
check_response_status(result);
std::vector<std::shared_ptr<monero_alt_chain>> alt_chains;
deserialize_alt_chains(result, alt_chains);
return alt_chains;
}
std::vector<std::string> monero_daemon_rpc::get_alt_block_hashes() {
auto result = m_rpc->send_path_request("get_alt_blocks_hashes");
check_response_status(result);
std::vector<std::string> hashes;
deserialize_alt_block_hashes(result, hashes);
return hashes;
}
int monero_daemon_rpc::get_download_limit() {
MTRACE("monero_daemon_rpc::get_download_limit()");
auto limits = get_bandwidth_limits();
if (limits->m_down != boost::none) return limits->m_down.get();
throw monero_error("Could not get download limit");
}
int monero_daemon_rpc::set_download_limit(int limit) {
MTRACE("monero_daemon_rpc::set_download_limit()");
if (limit == -1) return reset_download_limit();
if (limit <= 0) throw monero_error("Download limit must be an integer greater than 0");
auto result = set_bandwidth_limits(0, limit);
if (result->m_down != boost::none) return result->m_down.get();
throw monero_error("Could not set download limit");
}
int monero_daemon_rpc::reset_download_limit() {
MTRACE("monero_daemon_rpc::reset_download_limit()");
auto result = set_bandwidth_limits(0, -1);
if (result->m_down != boost::none) return result->m_down.get();
throw monero_error("Could not set download limit");
}
int monero_daemon_rpc::get_upload_limit() {
MTRACE("monero_daemon_rpc::get_upload_limit()");
auto limits = get_bandwidth_limits();
if (limits->m_up != boost::none) return limits->m_up.get();
throw monero_error("Could not get upload limit");
}
int monero_daemon_rpc::set_upload_limit(int limit) {
MTRACE("monero_daemon_rpc::set_upload_limit()");
if (limit == -1) return reset_upload_limit();
if (limit <= 0) throw monero_error("Upload limit must be an integer greater than 0");
auto result = set_bandwidth_limits(limit, 0);
if (result->m_up != boost::none) return result->m_up.get();
throw monero_error("Could not set download limit");
}
int monero_daemon_rpc::reset_upload_limit() {
MTRACE("monero_daemon_rpc::reset_upload_limit()");
auto result = set_bandwidth_limits(-1, 0);
if (result->m_up != boost::none) return result->m_up.get();
throw monero_error("Could not set download limit");
}
std::vector<std::shared_ptr<monero_peer>> monero_daemon_rpc::get_peers() {
auto result = m_rpc->send_json_request("get_connections");
check_response_status(result);
std::vector<std::shared_ptr<monero_peer>> peers;
deserialize_peers(result, peers);
return peers;
}
std::vector<std::shared_ptr<monero_peer>> monero_daemon_rpc::get_known_peers() {
auto result = m_rpc->send_path_request("get_peer_list");
check_response_status(result);
std::vector<std::shared_ptr<monero_peer>> peers;
deserialize_peers(result, peers);
return peers;
}
void monero_daemon_rpc::set_outgoing_peer_limit(int limit) {
MTRACE("monero_daemon_rpc::set_outgoing_peer_limit()");
if (limit < 0) throw monero_error("Outgoing peer limit must be >= 0");
auto params = std::make_shared<monero_peer_limits_params>();
params->m_out_peers = limit;
auto result = m_rpc->send_path_request("out_peers", params);
check_response_status(result);
}
void monero_daemon_rpc::set_incoming_peer_limit(int limit) {
MTRACE("monero_daemon_rpc::set_incoming_peer_limit()");
if (limit < 0) throw monero_error("Incoming peer limit must be >= 0");
auto params = std::make_shared<monero_peer_limits_params>();
params->m_in_peers = limit;
auto result = m_rpc->send_path_request("in_peers", params);
check_response_status(result);
}
std::vector<std::shared_ptr<monero_ban>> monero_daemon_rpc::get_peer_bans() {
MTRACE("monero_daemon_rpc::get_peer_bans()");
auto result = m_rpc->send_json_request("get_bans");
check_response_status(result);
std::vector<std::shared_ptr<monero_ban>> bans;
deserialize_bans(result, bans);
return bans;
}
void monero_daemon_rpc::set_peer_bans(const std::vector<std::shared_ptr<monero_ban>>& bans) {
MTRACE("monero_daemon_rpc::set_peer_bans()");
auto params = std::make_shared<monero_set_bans_params>(bans);
auto result = m_rpc->send_json_request("set_bans", params);
check_response_status(result);
}
void monero_daemon_rpc::start_mining(const std::string &address, boost::optional<uint64_t> num_threads, boost::optional<bool> background_mining, boost::optional<bool> ignore_battery) {
MTRACE("monero_daemon_rpc::start_mining()");
if (address.empty()) throw monero_error("Must provide address to mine to");
if (num_threads != boost::none && num_threads.get() == 0) throw monero_error("Number of threads must be an integer greater than 0");
auto params = std::make_shared<monero_start_mining_params>(address, num_threads, background_mining, ignore_battery);
auto result = m_rpc->send_path_request("start_mining", params);
check_response_status(result);
}
void monero_daemon_rpc::stop_mining() {
MTRACE("monero_daemon_rpc::stop_mining()");
auto result = m_rpc->send_path_request("stop_mining");
check_response_status(result);
}
std::shared_ptr<monero_mining_status> monero_daemon_rpc::get_mining_status() {
MTRACE("monero_daemon_rpc::get_mining_status()");
auto result = m_rpc->send_path_request("mining_status");
check_response_status(result);
auto status = std::make_shared<monero_mining_status>();
deserialize_mining_status(result, status);
return status;
}
std::shared_ptr<monero_generate_blocks_result> monero_daemon_rpc::generate_blocks(const std::string& wallet_address, uint64_t num_blocks, const boost::optional<std::string>& prev_block_hash, const boost::optional<uint32_t>& starting_nonce) {
MTRACE("monero_daemon_rpc::generate_blocks()");
if (wallet_address.empty()) throw monero_error("Must provide an address to mine to");
if (num_blocks == 0) throw monero_error("Must provide a number of blocks to generate greater than 0");
auto params = std::make_shared<monero_generate_blocks_params>(wallet_address, num_blocks, prev_block_hash, starting_nonce);
auto result = m_rpc->send_json_request("generateblocks", params);
check_response_status(result);
auto generate_result = std::make_shared<monero_generate_blocks_result>();
deserialize_generate_blocks_result(result, generate_result);
return generate_result;
}
void monero_daemon_rpc::submit_blocks(const std::vector<std::string>& block_blobs) {
MTRACE("monero_daemon_rpc::submit_blocks()");
if (block_blobs.empty()) throw monero_error("Must provide an array of mined block blobs to submit");
auto params = std::make_shared<monero_submit_blocks_params>(block_blobs);
auto result = m_rpc->send_json_request("submit_block", params);
check_response_status(result);
}
std::shared_ptr<monero_prune_result> monero_daemon_rpc::prune_blockchain(bool check) {
MTRACE("monero_daemon_rpc::prune_blockchain()");
auto params = std::make_shared<monero_prune_blockchain_params>(check);
auto result = m_rpc->send_json_request("prune_blockchain", params);
check_response_status(result);
std::shared_ptr<monero_prune_result> prune_result = std::make_shared<monero_prune_result>();
deserialize_prune_result(result, prune_result);
return prune_result;
}
std::shared_ptr<monero_daemon_update_check_result> monero_daemon_rpc::check_for_update() {
MTRACE("monero_daemon_rpc::check_for_update()");
auto params = std::make_shared<monero_download_update_params>("check");
auto result = m_rpc->send_path_request("update", params);
check_response_status(result);
auto check_result = std::make_shared<monero_daemon_update_check_result>();
deserialize_update_check_result(result, check_result);
return check_result;
}
std::shared_ptr<monero_daemon_update_download_result> monero_daemon_rpc::download_update(const std::string& path) {
MTRACE("monero_daemon_rpc::download_update()");
auto params = std::make_shared<monero_download_update_params>("download", path);
auto result = m_rpc->send_path_request("update", params);
check_response_status(result);
auto download_result = std::make_shared<monero_daemon_update_download_result>();
deserialize_update_download_result(result, download_result);
return download_result;
}
void monero_daemon_rpc::stop() {
MTRACE("monero_daemon_rpc::stop()");
auto result = m_rpc->send_path_request("stop_daemon");
check_response_status(result);
}
std::shared_ptr<monero_block_header> monero_daemon_rpc::wait_for_next_block_header() {
MTRACE("monero_daemon_rpc::wait_for_next_block_header()");
// use mutex and condition variable with predicate to wait for block
boost::mutex temp;
boost::condition_variable cv;
bool ready = false;
// create listener which notifies condition variable when block is added
block_notifier block_listener(&temp, &cv, &ready);
// register the listener, and unregister it unconditionally on the way out
// otherwise m_listeners keeps a pointer into this stack
// frame after it's gone, and the next block dispatch calls through it (UAF)
struct listener_guard {
monero_daemon_rpc* daemon;
monero_daemon_listener& listener;
~listener_guard() {
// never let remove_listener() escape a destructor since if we're
// already unwinding from another exception, that would call std::terminate
try { daemon->remove_listener(listener); } catch (...) {}
}
} unregister_guard{this, block_listener};
add_listener(block_listener);
// guard against an offline/unresponsive daemon (no connection, stalled node, etc.):
// such a daemon will never announce a new block, so bound the wait instead of hanging
// this call forever.
// TODO monero-java waits indefinitely.
bool timed_out;
std::shared_ptr<monero_block_header> header;
{
boost::mutex::scoped_lock lock(temp);
cv.timed_wait(lock, boost::posix_time::minutes(30), [&]() { return ready; });
// check ready directly rather than trust timed_wait's return value, still under the
// lock: avoids any discrepancy if a header arrives right at the timeout boundary
timed_out = !ready;
// copy out while still holding the lock: block_listener stays registered until the
// unregister_guard destructor runs (at function exit, after this point), so
// on_block_header() could still write m_last_header under temp concurrently with an
// unlocked read here
if (!timed_out) header = block_listener.m_last_header;
}
if (timed_out) throw monero_error("Timed out waiting for next block header");
// return last height
if (header == nullptr) throw monero_error("Could not get last block header.");
return header;
}
std::shared_ptr<monero_bandwidth_limits> monero_daemon_rpc::get_bandwidth_limits() {
MTRACE("monero_daemon_rpc::get_bandwidth_limits()");
auto result = m_rpc->send_path_request("get_limit");
check_response_status(result);
auto limits = std::make_shared<monero_bandwidth_limits>();
monero_bandwidth_limits::from_property_tree(result, limits);
return limits;
}
std::shared_ptr<monero_bandwidth_limits> monero_daemon_rpc::set_bandwidth_limits(int up, int down) {
MTRACE("monero_daemon_rpc::set_bandwidth_limits()");
auto limits = std::make_shared<monero_bandwidth_limits>(up, down);
auto result = m_rpc->send_path_request("set_limit", limits);
check_response_status(result);
monero_bandwidth_limits::from_property_tree(result, limits);
return limits;
}
void monero_daemon_rpc::refresh_listening() {
boost::lock_guard<boost::mutex> poller_lock(m_poller_mutex);
bool should_poll;
{
boost::lock_guard<boost::recursive_mutex> lock(m_listeners_mutex);
should_poll = m_listeners.size() > 0;
}
if (!m_poller && should_poll) {
m_poller = std::make_unique<monero_daemon_poller>(this);
}
if (m_poller) m_poller->request_is_polling(should_poll);
}
monero_daemon_rpc::~monero_daemon_rpc() {
MTRACE("~monero_daemon_rpc()");
remove_listeners();
}
}
@@ -0,0 +1,189 @@
/**
* Copyright (c) everoddandeven
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2025-2026 woodser
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#pragma once
#include "monero_daemon.h"
/**
* Implements a monero_daemon.h using monero-project's monerod.
*/
namespace monero {
// forward declaration of internal daemon poller
class monero_daemon_poller;
/**
* Implements a Monero daemon using monerod-rpc.
*/
class monero_daemon_rpc : public monero_daemon {
public:
/**
* Destruct the daemon.
*/
~monero_daemon_rpc() override;
monero_daemon_rpc(const std::shared_ptr<monero_rpc_connection>& rpc);
monero_daemon_rpc(const std::string& uri, const std::string& username = "", const std::string& password = "", const std::string& proxy_uri = "", const std::string& zmq_uri = "", const boost::optional<uint32_t>& timeout = boost::none);
/**
* Get the daemon's RPC connection.
*
* @return the daemon's rpc connection
*/
std::shared_ptr<monero_rpc_connection> get_rpc_connection() const;
/**
* Indicates if the client is connected to the daemon via RPC.
*
* @return true if the client is connected to the daemon, false otherwise
*/
bool is_connected();
/**
* Set the daemon poll period.
*
* @param period_ms poll period in milliseconds.
*/
void set_poll_period_in_ms(uint64_t period_ms);
/**
* Supported daemon methods.
*/
std::set<monero_daemon_listener*> get_listeners() override;
void add_listener(monero_daemon_listener &listener) override;
void remove_listener(monero_daemon_listener &listener) override;
void remove_listeners() override;
monero_version get_version() override;
bool is_trusted() override;
uint64_t get_height() override;
std::string get_block_hash(uint64_t height) override;
std::shared_ptr<monero_block_template> get_block_template(const std::string& wallet_address, const boost::optional<int>& reserve_size = boost::none) override;
std::shared_ptr<monero_block_header> get_last_block_header() override;
std::shared_ptr<monero_block_header> get_block_header_by_hash(const std::string& hash) override;
std::shared_ptr<monero_block_header> get_block_header_by_height(uint64_t height) override;
std::vector<std::shared_ptr<monero_block_header>> get_block_headers_by_range(uint64_t start_height, uint64_t end_height) override;
std::shared_ptr<monero_block> get_block_by_hash(const std::string& hash) override;
std::vector<std::shared_ptr<monero_block>> get_blocks_by_hash(const std::vector<std::string>& block_hashes, uint64_t start_height, bool prune) override;
std::shared_ptr<monero_block> get_block_by_height(uint64_t height) override;
std::vector<std::shared_ptr<monero_block>> get_blocks_by_height(const std::vector<uint64_t>& heights) override;
std::vector<std::shared_ptr<monero_block>> get_blocks_by_range(boost::optional<uint64_t> start_height, boost::optional<uint64_t> end_height) override;
std::vector<std::shared_ptr<monero_block>> get_blocks_by_range_chunked(boost::optional<uint64_t> start_height, boost::optional<uint64_t> end_height, boost::optional<uint64_t> max_chunk_size) override;
std::vector<std::string> get_block_hashes(const std::vector<std::string>& block_hashes, uint64_t start_height) override;
std::vector<std::shared_ptr<monero_tx>> get_txs(const std::vector<std::string>& tx_hashes, bool prune = false) override;
std::vector<std::string> get_tx_hexes(const std::vector<std::string>& tx_hashes, bool prune = false) override;
std::shared_ptr<monero_miner_tx_sum> get_miner_tx_sum(uint64_t height, uint64_t num_blocks) override;
std::shared_ptr<monero_fee_estimate> get_fee_estimate(uint64_t grace_blocks = 0) override;
std::shared_ptr<monero_submit_tx_result> submit_tx_hex(const std::string& tx_hex, bool do_not_relay = false) override;
void relay_txs_by_hash(const std::vector<std::string>& tx_hashes) override;
std::shared_ptr<monero_tx_pool_stats> get_tx_pool_stats() override;
std::vector<std::shared_ptr<monero_tx>> get_tx_pool() override;
std::vector<std::string> get_tx_pool_hashes() override;
void flush_tx_pool(const std::vector<std::string> &hashes) override;
void flush_tx_pool() override;
void flush_tx_pool(const std::string &hash) override;
std::vector<monero_key_image_spent_status> get_key_image_spent_statuses(const std::vector<std::string>& key_images) override;
std::vector<std::shared_ptr<monero_output>> get_outputs(const std::vector<monero_output>& outputs) override;
std::vector<std::shared_ptr<monero_output_histogram_entry>> get_output_histogram(const std::vector<uint64_t>& amounts, const boost::optional<int>& min_count, const boost::optional<int>& max_count, const boost::optional<bool>& is_unlocked, const boost::optional<int>& recent_cutoff) override;
std::vector<std::shared_ptr<monero_output_distribution_entry>> get_output_distribution(const std::vector<uint64_t>& amounts, const boost::optional<bool>& is_cumulative = boost::none, const boost::optional<uint64_t>& start_height = boost::none, const boost::optional<uint64_t>& end_height = boost::none) override;
std::shared_ptr<monero_daemon_info> get_info() override;
std::shared_ptr<monero_daemon_sync_info> get_sync_info() override;
std::shared_ptr<monero_hard_fork_info> get_hard_fork_info() override;
std::vector<std::shared_ptr<monero_alt_chain>> get_alt_chains() override;
std::vector<std::string> get_alt_block_hashes() override;
int get_download_limit() override;
int set_download_limit(int limit) override;
int reset_download_limit() override;
int get_upload_limit() override;
int set_upload_limit(int limit) override;
int reset_upload_limit() override;
std::vector<std::shared_ptr<monero_peer>> get_peers() override;
std::vector<std::shared_ptr<monero_peer>> get_known_peers() override;
void set_outgoing_peer_limit(int limit) override;
void set_incoming_peer_limit(int limit) override;
std::vector<std::shared_ptr<monero_ban>> get_peer_bans() override;
void set_peer_bans(const std::vector<std::shared_ptr<monero_ban>>& bans) override;
void start_mining(const std::string &address, boost::optional<uint64_t> num_threads, boost::optional<bool> background_mining, boost::optional<bool> ignore_battery) override;
void stop_mining() override;
std::shared_ptr<monero_mining_status> get_mining_status() override;
std::shared_ptr<monero_generate_blocks_result> generate_blocks(const std::string& wallet_address, uint64_t num_blocks, const boost::optional<std::string>& prev_block_hash = boost::none, const boost::optional<uint32_t>& starting_nonce = boost::none) override;
void submit_blocks(const std::vector<std::string>& block_blobs) override;
std::shared_ptr<monero_prune_result> prune_blockchain(bool check) override;
std::shared_ptr<monero_daemon_update_check_result> check_for_update() override;
std::shared_ptr<monero_daemon_update_download_result> download_update(const std::string& path = "") override;
void stop() override;
std::shared_ptr<monero_block_header> wait_for_next_block_header() override;
// --------------------------------- PRIVATE --------------------------------
private:
friend class monero_daemon_poller;
mutable boost::recursive_mutex m_listeners_mutex;
std::set<monero_daemon_listener*> m_listeners;
std::shared_ptr<monero_rpc_connection> m_rpc;
boost::mutex m_poller_mutex;
std::unique_ptr<monero_daemon_poller> m_poller;
// header_cache is scoped to a single get_blocks_by_range_chunked() call (see below) rather than
// stored as a member: it only helps avoid re-fetching a header already seen earlier in that one
// chunking pass, and keeping it as instance state would let it grow unbounded over the connection's
// lifetime and serve stale headers across reorgs on later calls
std::vector<std::shared_ptr<monero_block>> get_max_blocks(boost::optional<uint64_t> start_height, boost::optional<uint64_t> max_height, boost::optional<uint64_t> chunk_size, std::unordered_map<uint64_t, std::shared_ptr<monero_block_header>>& header_cache);
std::shared_ptr<monero_block_header> get_block_header_by_height_cached(uint64_t height, uint64_t max_height, std::unordered_map<uint64_t, std::shared_ptr<monero_block_header>>& header_cache);
std::shared_ptr<monero_bandwidth_limits> get_bandwidth_limits();
std::shared_ptr<monero_bandwidth_limits> set_bandwidth_limits(int up, int down);
void refresh_listening();
void wait_for_listener_callbacks_idle();
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,288 @@
/**
* Copyright (c) everoddandeven
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2025-2026 woodser
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#pragma once
#include "common/monero_rpc_connection.h"
/**
* Internal data model for monero_daemon_rpc.h.
*/
namespace monero {
/**
* Models a Monero RPC banhammer.
*/
struct monero_rpc_ban : public monero_ban {
monero_rpc_ban(const std::shared_ptr<monero_ban> &ban);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
// ------------------------------ Binary Request ---------------------------------
/**
* Models paramaters for monerod-rpc `/get_blocks_by_height.bin` method.
*/
struct monero_get_blocks_by_height_request : public monero_rpc_request {
std::vector<uint64_t> m_heights;
monero_get_blocks_by_height_request(uint64_t num_blocks);
monero_get_blocks_by_height_request(const std::vector<uint64_t>& heights): m_heights(heights) { m_method = "get_blocks_by_height.bin"; }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
// ------------------------------ RPC Params ---------------------------------
struct monero_download_update_params : public serializable_struct {
boost::optional<std::string> m_command;
boost::optional<std::string> m_path;
monero_download_update_params(const std::string& command = "download", const std::string& path = "");
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_submit_tx_params : public serializable_struct {
boost::optional<std::string> m_tx_hex;
boost::optional<bool> m_do_not_relay;
std::vector<std::string> m_tx_hashes;
monero_submit_tx_params(const std::vector<std::string>& tx_hashes): m_tx_hashes(tx_hashes) { }
monero_submit_tx_params(const std::string& tx_hex, bool do_not_relay): m_tx_hex(tx_hex), m_do_not_relay(do_not_relay) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_peer_limits_params : public serializable_struct {
boost::optional<int> m_in_peers;
boost::optional<int> m_out_peers;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_get_txs_params : public serializable_struct {
std::vector<std::string> m_tx_hashes;
boost::optional<bool> m_decode_as_json;
boost::optional<bool> m_prune;
monero_get_txs_params(const std::vector<std::string> &tx_hashes, bool prune, bool decode_as_json = true): m_tx_hashes(tx_hashes), m_prune(prune), m_decode_as_json(decode_as_json) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_is_key_image_spent_params : public serializable_struct {
std::vector<std::string> m_key_images;
monero_is_key_image_spent_params(const std::vector<std::string>& key_images): m_key_images(key_images) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
// ------------------------------ JSON-RPC Params ---------------------------------
struct monero_start_mining_params : public serializable_struct {
boost::optional<std::string> m_miner_address;
boost::optional<uint64_t> m_num_threads;
boost::optional<bool> m_is_background;
boost::optional<bool> m_ignore_battery;
monero_start_mining_params(const boost::optional<std::string>& address, const boost::optional<uint64_t>& num_threads, const boost::optional<bool>& is_background, const boost::optional<bool>& ignore_battery): m_miner_address(address), m_num_threads(num_threads), m_is_background(is_background), m_ignore_battery(ignore_battery) { }
monero_start_mining_params(const boost::optional<uint64_t>& num_threads, const boost::optional<bool>& is_background, const boost::optional<bool>& ignore_battery): m_num_threads(num_threads), m_is_background(is_background), m_ignore_battery(ignore_battery) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_generate_blocks_params : public serializable_struct {
boost::optional<std::string> m_wallet_address;
boost::optional<uint64_t> m_num_blocks;
boost::optional<std::string> m_prev_block_hash;
boost::optional<uint32_t> m_starting_nonce;
monero_generate_blocks_params(const std::string& wallet_address, uint64_t num_blocks, const boost::optional<std::string>& prev_block_hash, const boost::optional<uint32_t>& starting_nonce): m_wallet_address(wallet_address), m_num_blocks(num_blocks), m_prev_block_hash(prev_block_hash), m_starting_nonce(starting_nonce) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_prune_blockchain_params : public serializable_struct {
boost::optional<bool> m_check;
monero_prune_blockchain_params(bool check = true): m_check(check) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_submit_blocks_params : public serializable_struct {
std::vector<std::string> m_block_blobs;
monero_submit_blocks_params(const std::vector<std::string>& block_blobs): m_block_blobs(block_blobs) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_get_block_params : public serializable_struct {
boost::optional<uint64_t> m_height;
boost::optional<std::string> m_hash;
boost::optional<bool> m_fill_pow_hash;
boost::optional<uint64_t> m_start_height;
boost::optional<uint64_t> m_end_height;
boost::optional<std::string> m_wallet_address;
boost::optional<int> m_reserve_size;
monero_get_block_params(uint64_t height, bool fill_pow_hash = false): m_height(height), m_fill_pow_hash(fill_pow_hash) { }
monero_get_block_params(const std::string& hash, bool fill_pow_hash = false): m_hash(hash), m_fill_pow_hash(fill_pow_hash) { }
monero_get_block_params(uint64_t start_height, uint64_t end_height): m_start_height(start_height), m_end_height(end_height) { }
monero_get_block_params(const std::string& wallet_address, const boost::optional<int>& reserve_size): m_wallet_address(wallet_address), m_reserve_size(reserve_size) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_get_block_hash_params : public serializable_struct {
boost::optional<uint64_t> m_height;
monero_get_block_hash_params(uint64_t height): m_height(height) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_get_miner_tx_sum_params : public serializable_struct {
boost::optional<uint64_t> m_height;
boost::optional<uint64_t> m_count;
monero_get_miner_tx_sum_params(uint64_t height, uint64_t count): m_height(height), m_count(count) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_get_fee_estimate_params : public serializable_struct {
boost::optional<uint64_t> m_grace_blocks;
monero_get_fee_estimate_params(uint64_t grace_blocks = 0): m_grace_blocks(grace_blocks) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_set_bans_params : public serializable_struct {
std::vector<std::shared_ptr<monero_rpc_ban>> m_bans;
monero_set_bans_params(const std::vector<std::shared_ptr<monero_ban>>& bans);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_get_output_histogram_params : public serializable_struct {
std::vector<uint64_t> m_amounts;
boost::optional<int> m_min_count;
boost::optional<int> m_max_count;
boost::optional<bool> m_is_unlocked;
boost::optional<int> m_recent_cutoff;
monero_get_output_histogram_params(const std::vector<uint64_t>& amounts, const boost::optional<int>& min_count, const boost::optional<int>& max_count, const boost::optional<bool>& is_unlocked, const boost::optional<int>& recent_cutoff) : m_amounts(amounts), m_min_count(min_count), m_max_count(max_count), m_is_unlocked(is_unlocked), m_recent_cutoff(recent_cutoff) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_get_output_distribution_params : public serializable_struct {
std::vector<uint64_t> m_amounts;
boost::optional<bool> m_cumulative;
boost::optional<bool> m_binary;
boost::optional<uint64_t> m_from_height;
boost::optional<uint64_t> m_to_height;
monero_get_output_distribution_params(const std::vector<uint64_t>& amounts, const boost::optional<bool>& cumulative, const boost::optional<uint64_t>& from_height, const boost::optional<uint64_t>& to_height) : m_amounts(amounts), m_cumulative(cumulative), m_from_height(from_height), m_to_height(to_height), m_binary(false) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
// ------------------------------ JSON-RPC Response ---------------------------------
struct monero_get_block_result {
boost::optional<uint64_t> m_count;
boost::optional<uint64_t> m_height;
boost::optional<bool> m_untrusted;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_get_block_result>& result);
};
// ------------------------------ RPC Deserialization ---------------------------------
void deserialize_version(const boost::property_tree::ptree& node, monero_version& version);
void deserialize_block_header(const boost::property_tree::ptree& node, const std::shared_ptr<monero_block_header>& header);
void deserialize_block_headers(const boost::property_tree::ptree& node, std::vector<std::shared_ptr<monero_block_header>>& headers);
void deserialize_block(const boost::property_tree::ptree& node, const std::shared_ptr<monero_block>& block, bool is_nested = false);
void deserialize_blocks(const boost::property_tree::ptree& node, const std::vector<uint64_t>& heights, std::vector<std::shared_ptr<monero_block>>& blocks);
void deserialize_alt_block_hashes(const boost::property_tree::ptree& node, std::vector<std::string>& block_hashes);
void deserialize_txs(const boost::property_tree::ptree& node, std::vector<std::shared_ptr<monero_tx>>& txs);
void deserialize_tx_hashes(const boost::property_tree::ptree& node, std::vector<std::string>& tx_hashes);
void deserialize_key_image_spent_status(const boost::property_tree::ptree& node, std::vector<monero_key_image_spent_status>& statuses);
void deserialize_alt_chains(const boost::property_tree::ptree& node, std::vector<std::shared_ptr<monero_alt_chain>>& alt_chains);
void deserialize_bans(const boost::property_tree::ptree& node, std::vector<std::shared_ptr<monero_ban>>& bans);
void deserialize_prune_result(const boost::property_tree::ptree& node, const std::shared_ptr<monero_prune_result>& result);
void deserialize_mining_status(const boost::property_tree::ptree& node, const std::shared_ptr<monero_mining_status>& status);
void deserialize_generate_blocks_result(const boost::property_tree::ptree& node, const std::shared_ptr<monero_generate_blocks_result>& result);
void deserialize_miner_tx_sum(const boost::property_tree::ptree& node, const std::shared_ptr<monero_miner_tx_sum>& sum);
void deserialize_block_template(const boost::property_tree::ptree& node, const std::shared_ptr<monero_block_template>& tmplt);
void deserialize_peers(const boost::property_tree::ptree& node, std::vector<std::shared_ptr<monero_peer>>& peers);
void deserialize_submit_tx_result(const boost::property_tree::ptree& node, const std::shared_ptr<monero_submit_tx_result>& result);
void deserialize_output_distribution_entries(const boost::property_tree::ptree& node, std::vector<std::shared_ptr<monero_output_distribution_entry>>& entries);
void deserialize_output_histogram_entries(const boost::property_tree::ptree& node, std::vector<std::shared_ptr<monero_output_histogram_entry>>& entries);
void deserialize_tx_pool_stats(const boost::property_tree::ptree& node, const std::shared_ptr<monero_tx_pool_stats>& stats);
void deserialize_update_check_result(const boost::property_tree::ptree& node, const std::shared_ptr<monero_daemon_update_check_result>& check);
void deserialize_update_download_result(const boost::property_tree::ptree& node, const std::shared_ptr<monero_daemon_update_download_result>& check);
void deserialize_fee_estimate(const boost::property_tree::ptree& node, const std::shared_ptr<monero_fee_estimate>& estimate);
void deserialize_daemon_info(const boost::property_tree::ptree& node, const std::shared_ptr<monero_daemon_info>& info);
void deserialize_daemon_sync_info(const boost::property_tree::ptree& node, const std::shared_ptr<monero_daemon_sync_info>& info);
void deserialize_hard_fork_info(const boost::property_tree::ptree& node, const std::shared_ptr<monero_hard_fork_info>& info);
}
@@ -0,0 +1,220 @@
/**
* Copyright (c) woodser
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#include "gen_utils.h"
#include "rapidjson/stringbuffer.h"
#include "rapidjson/writer.h"
using namespace gen_utils;
// ------------------------- SERIALIZATION ---------------------------
std::string gen_utils::serialize(const rapidjson::Document& doc) {
rapidjson::StringBuffer buffer;
rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
doc.Accept(writer);
return buffer.GetString();
}
std::string gen_utils::serialize(const boost::property_tree::ptree& node) {
std::stringstream ss;
boost::property_tree::write_json(ss, node, false);
std::string str = ss.str();
return str.substr(0, str.size() - 1); // strip newline
}
void gen_utils::deserialize(const std::string& json, boost::property_tree::ptree& root) {
std::istringstream iss = json.empty() ? std::istringstream() : std::istringstream(json);
try {
boost::property_tree::read_json(iss, root);
} catch (std::exception const& e) {
throw std::runtime_error("Invalid JSON");
}
}
// --------------------------- THREAD POLLER ---------------------------
thread_poller::thread_poller(): m_is_polling(false), m_poll_loop_running(false), m_poll_period_ms(20000) {
}
thread_poller::~thread_poller() {
// safety net only: derived classes must stop polling in their own destructor
// (before their members are torn down) since by the time this base destructor
// runs, the derived part of the object is already gone and a concurrently
// running poll() (a virtual call) would hit a partially-destructed object
set_is_polling(false);
}
namespace {
// stack of poller instances whose announce_scope is currently active on the current
// thread, innermost last. Per-instance so that a callback of poller A calling into
// a *different* poller B's wait_for_callbacks_idle() still waits normally.
thread_local std::vector<const thread_poller*> t_active_dispatch_stack;
}
thread_poller::announce_scope::announce_scope(thread_poller& poller): m_poller(poller) {
t_active_dispatch_stack.push_back(&poller);
boost::lock_guard<boost::mutex> lock(m_poller.m_announce_mutex);
++m_poller.m_announce_count;
}
thread_poller::announce_scope::~announce_scope() {
{
boost::lock_guard<boost::mutex> lock(m_poller.m_announce_mutex);
if (--m_poller.m_announce_count == 0) m_poller.m_announce_cv.notify_all();
}
t_active_dispatch_stack.pop_back();
}
void thread_poller::wait_for_callbacks_idle() {
// skip the wait only if this poller's own dispatch is active on the current thread;
// waiting would deadlock in that case. A different poller's active dispatch must not
// short-circuit this wait.
for (const thread_poller* active : t_active_dispatch_stack) {
if (active == this) return;
}
boost::unique_lock<boost::mutex> lock(m_announce_mutex);
m_announce_cv.wait(lock, [&]() { return m_announce_count == 0; });
}
void thread_poller::init_common(const std::string& name) {
m_name = name;
}
void thread_poller::request_is_polling(bool is_polling) {
boost::lock_guard<boost::mutex> lifecycle_lock(m_lifecycle_mutex);
if (is_polling) {
if (m_is_polling) return;
m_is_polling = true;
run_poll_loop();
return;
}
bool was_polling = m_is_polling.exchange(false);
if (was_polling) {
boost::lock_guard<boost::mutex> polling_lock(m_polling_mutex);
m_poll_cv.notify_one();
}
}
void thread_poller::set_is_polling(bool is_polling) {
boost::unique_lock<boost::mutex> lifecycle_lock(m_lifecycle_mutex);
if (is_polling) {
if (m_is_polling) return;
m_is_polling = true;
run_poll_loop();
return;
}
bool was_polling = m_is_polling.exchange(false);
if (was_polling) {
boost::lock_guard<boost::mutex> polling_lock(m_polling_mutex);
m_poll_cv.notify_one();
}
if (!m_poll_loop_running) return; // loop already fully exited; nothing to wait for
if (boost::this_thread::get_id() == m_poll_thread_id) {
// called from the poll thread itself (e.g. a listener removed itself from within a
// callback invoked by poll()): waiting for our own loop to exit would deadlock. Just
// return -- the loop re-checks m_is_polling itself and will exit and clear
// m_poll_loop_running/m_poll_thread_id on its own (see run_poll_loop).
return;
}
// wait for the loop to actually finish
m_lifecycle_cv.wait(lifecycle_lock, [&]() { return !m_poll_loop_running; });
}
void thread_poller::run_poll_loop() {
// called with m_lifecycle_mutex held
if (m_poll_loop_running) return; // only run one loop at a time
m_poll_loop_running = true;
// TODO: use global threadpool, background sync wasm wallet in c++ thread
try {
boost::thread([this]() {
{
boost::lock_guard<boost::mutex> lifecycle_lock(m_lifecycle_mutex);
m_poll_thread_id = boost::this_thread::get_id();
}
while (true) {
while (m_is_polling) {
try { poll(); }
catch (const std::exception& e) { MERROR(m_name << " failed to background poll: " << e.what()); }
catch (...) { MERROR(m_name << " failed to background poll"); }
// only wait if polling still enabled
if (m_is_polling) {
boost::mutex::scoped_lock lock(m_polling_mutex);
boost::posix_time::milliseconds wait_for_ms(m_poll_period_ms.load());
m_poll_cv.timed_wait(lock, wait_for_ms, [&]() { return !m_is_polling; });
}
}
boost::unique_lock<boost::mutex> lifecycle_lock(m_lifecycle_mutex);
if (m_is_polling) continue;
m_poll_loop_running = false;
m_poll_thread_id = boost::thread::id();
m_lifecycle_cv.notify_all();
return;
}
}).detach();
} catch (...) {
m_poll_loop_running = false;
m_is_polling = false;
throw;
}
}
@@ -0,0 +1,220 @@
/**
* Copyright (c) woodser
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#pragma once
#ifndef gen_utils_h
#define gen_utils_h
#include <boost/lexical_cast.hpp>
#include <boost/uuid/uuid.hpp>
#include <boost/uuid/uuid_generators.hpp>
#include <boost/uuid/uuid_io.hpp>
#include <boost/property_tree/ptree.hpp>
#include <boost/property_tree/json_parser.hpp>
#include "rapidjson/document.h"
#include <boost/thread/recursive_mutex.hpp>
#include <boost/thread/thread.hpp>
#include <boost/thread/condition_variable.hpp>
#include <chrono>
#include <thread>
#include <atomic>
#include "include_base_utils.h"
#include "common/util.h"
/**
* Collection of generic utilities.
*/
namespace gen_utils
{
/**
* Return a unique identifier.
*
* @return a unique id
*/
static std::string get_uuid() {
boost::uuids::random_generator generator;
boost::uuids::uuid uuid = generator();
return boost::uuids::to_string(uuid);
}
/**
* Wait for the given duration.
*
* @param duration_ms the duration to wait in milliseconds
*/
static void wait_for(uint64_t duration_ms) {
std::this_thread::sleep_for(std::chrono::milliseconds(duration_ms));
}
static bool bool_equals(bool val, const boost::optional<bool>& opt_val) {
return opt_val == boost::none ? false : val == *opt_val;
}
// ------------------------- SERIALIZATION ---------------------------
// TODO: fully switch from property trees to rapidjson
std::string serialize(const rapidjson::Document& doc);
std::string serialize(const boost::property_tree::ptree& node);
void deserialize(const std::string& json, boost::property_tree::ptree& root);
// ------------------------- VALUE RECONCILATION ----------------------------
// TODO: refactor common template code
template <class T, typename std::enable_if<std::is_same<T, std::string>::value, T>::type* = nullptr>
boost::optional<T> reconcile(const boost::optional<T>& val1, const boost::optional<T>& val2, boost::optional<bool> resolve_defined, boost::optional<bool> resolve_true, boost::optional<bool> resolve_max, const std::string& err_msg = "") {
// check for equality
if (val1 == val2) return val1;
// resolve one value none
if (val1 == boost::none || val2 == boost::none) {
if (resolve_defined != boost::none && *resolve_defined == false) return boost::none;
else return val1 == boost::none ? val2 : val1;
}
throw std::runtime_error(std::string("Cannot reconcile strings: ") + boost::lexical_cast<std::string>(val1) + std::string(" vs ") + boost::lexical_cast<std::string>(val2) + (!err_msg.empty() ? std::string(". ") + err_msg : std::string("")));
}
template <class T, typename std::enable_if<std::is_same<T, std::string>::value, T>::type* = nullptr>
boost::optional<T> reconcile(const boost::optional<T>& val1, const boost::optional<T>& val2, const std::string& err_msg = "") {
return reconcile(val1, val2, boost::none, boost::none, boost::none, err_msg);
}
template <class T, typename std::enable_if<std::is_integral<T>::value, T>::type* = nullptr>
boost::optional<T> reconcile(const boost::optional<T>& val1, const boost::optional<T>& val2, boost::optional<bool> resolve_defined, boost::optional<bool> resolve_true, boost::optional<bool> resolve_max, const std::string& err_msg = "") {
// check for equality
if (val1 == val2) return val1;
// resolve one value none
if (val1 == boost::none || val2 == boost::none) {
if (resolve_defined != boost::none && *resolve_defined == false) return boost::none;
else return val1 == boost::none ? val2 : val1;
}
// resolve different booleans
if (resolve_true != boost::none) return (bool) val1 == *resolve_true ? val1 : val2; // if resolve true, return true, else return false
// resolve different numbers
if (resolve_max != boost::none) return *resolve_max ? std::max(*val1, *val2) : std::min(*val1, *val2);
// cannot reconcile
throw std::runtime_error(std::string("Cannot reconcile integrals: ") + boost::lexical_cast<std::string>(val1) + std::string(" vs ") + boost::lexical_cast<std::string>(val2) + (!err_msg.empty() ? std::string(". ") + err_msg : std::string("")));
}
template <class T, typename std::enable_if<std::is_integral<T>::value, T>::type* = nullptr>
boost::optional<T> reconcile(const boost::optional<T>& val1, const boost::optional<T>& val2, const std::string& err_msg = "") {
return reconcile(val1, val2, boost::none, boost::none, boost::none, err_msg);
}
template <class T>
std::vector<T> reconcile(const std::vector<T>& v1, const std::vector<T>& v2, const std::string& err_msg = "") {
// check for equality
if (v1 == v2) return v1;
// resolve one vector empty
if (v1.empty()) return v2;
if (v2.empty()) return v1;
// otherwise cannot reconcile
throw std::runtime_error("Cannot reconcile vectors" + (!err_msg.empty() ? std::string(". ") + err_msg : std::string("")));
}
// ------------------------- THREAD POLLER ----------------------------
// WARNING: Only destroy a thread_poller from a thread other than its own pool thread.
// (e.g. a listener callback running on the poll thread, drops the last owning reference/unique_ptr
// to the poller.)
class thread_poller {
public:
thread_poller();
virtual ~thread_poller();
bool is_polling() const { return m_is_polling; }
void set_is_polling(bool is_polling);
void request_is_polling(bool is_polling);
void set_period_in_ms(uint64_t period_ms) { m_poll_period_ms = period_ms; }
virtual void poll() = 0;
void wait_for_callbacks_idle();
class announce_scope {
public:
explicit announce_scope(thread_poller& poller);
~announce_scope();
announce_scope(const announce_scope&) = delete;
announce_scope& operator=(const announce_scope&) = delete;
private:
thread_poller& m_poller;
};
protected:
std::string m_name;
boost::recursive_mutex m_mutex;
boost::mutex m_polling_mutex; // guards the interruptible periodic sleep (m_poll_cv)
boost::mutex m_lifecycle_mutex; // guards m_poll_loop_running/m_poll_thread_id and start/stop coordination
boost::thread::id m_poll_thread_id; // id of the currently running loop thread; guarded by m_lifecycle_mutex
std::atomic<bool> m_is_polling;
bool m_poll_loop_running; // guarded by m_lifecycle_mutex
std::atomic<uint64_t> m_poll_period_ms;
boost::condition_variable m_poll_cv; // periodic-sleep interrupt
boost::condition_variable m_lifecycle_cv; // notified (under m_lifecycle_mutex) when the loop actually exits
boost::mutex m_announce_mutex; // guards m_announce_count
boost::condition_variable m_announce_cv; // notified (under m_announce_mutex) when m_announce_count reaches 0
int m_announce_count = 0; // number of announce_scope instances currently alive, across all threads
void init_common(const std::string& name);
void run_poll_loop();
};
}
#endif /* gen_utils_h */
@@ -0,0 +1,624 @@
/**
* Copyright (c) woodser
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#include "monero_utils.h"
#include "rpc/core_rpc_server_commands_defs.h"
#include "storages/portable_storage_template_helper.h"
#include "cryptonote_basic/cryptonote_format_utils.h"
#include "mnemonics/electrum-words.h"
#include "mnemonics/english.h"
#include "string_tools.h"
#include "byte_stream.h"
#include "gen_utils.h"
using namespace cryptonote;
using namespace monero_utils;
void monero_utils::set_log_level(int level) {
mlog_set_log_level(level);
}
void monero_utils::set_log_categories(const std::string& categories) {
mlog_set_categories(categories.c_str());
}
void monero_utils::configure_logging(const std::string& path, bool console) {
mlog_configure(path, console);
}
// --------------------------- VALIDATION UTILS -------------------------------
monero_integrated_address monero_utils::get_integrated_address(monero_network_type network_type, const std::string& standard_address, const std::string& payment_id) {
// parse and validate address
cryptonote::address_parse_info address_info;
if (!get_account_address_from_str(address_info, static_cast<cryptonote::network_type>(network_type), standard_address)) throw std::runtime_error("Invalid address");
if (address_info.has_payment_id) throw std::runtime_error("The given address already has a payment id");
// randomly generate payment id if not given, else validate
crypto::hash8 payment_id_h8;
if (payment_id.empty()) {
payment_id_h8 = crypto::rand<crypto::hash8>();
} else {
cryptonote::blobdata payment_id_data;
if (!epee::string_tools::parse_hexstr_to_binbuff(payment_id, payment_id_data) || sizeof(crypto::hash8) != payment_id_data.size()) throw std::runtime_error("Invalid payment id");
payment_id_h8 = *reinterpret_cast<const crypto::hash8*>(payment_id_data.data());
}
// build integrated address
monero_integrated_address integrated_address;
integrated_address.m_integrated_address = cryptonote::get_account_integrated_address_as_str(static_cast<cryptonote::network_type>(network_type), address_info.address, payment_id_h8);
integrated_address.m_standard_address = standard_address;
integrated_address.m_payment_id = epee::string_tools::pod_to_hex(payment_id_h8);
return integrated_address;
}
bool monero_utils::is_valid_address(const std::string& address, monero_network_type network_type) {
try {
validate_address(address, network_type);
return true;
} catch (...) {
return false;
}
}
bool monero_utils::is_valid_private_view_key(const std::string& private_view_key) {
try {
validate_private_view_key(private_view_key);
return true;
} catch (...) {
return false;
}
}
bool monero_utils::is_valid_private_spend_key(const std::string& private_spend_key) {
try {
validate_private_spend_key(private_spend_key);
return true;
} catch (...) {
return false;
}
}
bool monero_utils::is_valid_public_view_key(const std::string& public_view_key) {
try {
validate_public_view_key(public_view_key);
return true;
} catch (...) {
return false;
}
}
bool monero_utils::is_valid_public_spend_key(const std::string& public_spend_key) {
try {
validate_public_spend_key(public_spend_key);
return true;
} catch (...) {
return false;
}
}
bool monero_utils::is_valid_payment_id(const std::string& payment_id) {
try {
validate_payment_id(payment_id);
return true;
} catch (...) {
return false;
}
}
bool monero_utils::is_valid_mnemonic(const std::string& mnemonic, const std::string& language) {
try {
validate_mnemonic(mnemonic, language);
return true;
}
catch (...) {
return false;
}
}
void monero_utils::validate_address(const std::string& address, monero_network_type network_type) {
cryptonote::address_parse_info info;
if (!get_account_address_from_str(info, static_cast<cryptonote::network_type>(network_type), address)) throw std::runtime_error("Invalid address");
}
void monero_utils::validate_private_view_key(const std::string& private_view_key) {
if (private_view_key.length() != 64) throw std::runtime_error("private view key expected to be 64 hex characters");
cryptonote::blobdata private_view_key_data;
if (!epee::string_tools::parse_hexstr_to_binbuff(private_view_key, private_view_key_data) || private_view_key_data.size() != sizeof(crypto::secret_key)) {
throw std::runtime_error("private view key expected to be 64 hex characters");
}
}
void monero_utils::validate_private_spend_key(const std::string& private_spend_key) {
if (private_spend_key.length() != 64) throw std::runtime_error("private spend key expected to be 64 hex characters");
cryptonote::blobdata private_spend_key_data;
if (!epee::string_tools::parse_hexstr_to_binbuff(private_spend_key, private_spend_key_data) || private_spend_key_data.size() != sizeof(crypto::secret_key)) {
throw std::runtime_error("private spend key expected to be 64 hex characters");
}
}
void monero_utils::validate_public_view_key(const std::string& public_view_key) {
if (public_view_key.length() != 64) throw std::runtime_error("public view key expected to be 64 hex characters");
cryptonote::blobdata public_view_key_data;
if (!epee::string_tools::parse_hexstr_to_binbuff(public_view_key, public_view_key_data) || public_view_key_data.size() != sizeof(crypto::public_key)) {
throw std::runtime_error("public view key expected to be 64 hex characters");
}
}
void monero_utils::validate_public_spend_key(const std::string& public_spend_key) {
if (public_spend_key.length() != 64) throw std::runtime_error("public spend key expected to be 64 hex characters");
cryptonote::blobdata public_spend_key_data;
if (!epee::string_tools::parse_hexstr_to_binbuff(public_spend_key, public_spend_key_data) || public_spend_key_data.size() != sizeof(crypto::public_key)) {
throw std::runtime_error("public spend key expected to be 64 hex characters");
}
}
void monero_utils::validate_payment_id(const std::string& payment_id) {
if (payment_id.length() != 64 && payment_id.length() != 16) throw std::runtime_error("payment id expected to be 64 or 16 hex characters");
cryptonote::blobdata payment_id_data;
if(!epee::string_tools::parse_hexstr_to_binbuff(payment_id, payment_id_data) || (payment_id_data.size() != sizeof(crypto::hash8) && payment_id_data.size() != sizeof(crypto::hash))) {
throw std::runtime_error("payment id expected to be 64 or 16 hex characters");
}
}
void monero_utils::validate_mnemonic(const std::string& mnemonic, const std::string& language) {
if (mnemonic.empty()) throw std::runtime_error("Mnemonic phrase is empty");
std::string seed_language;
crypto::secret_key recovery_key;
if (crypto::ElectrumWords::get_is_old_style_seed(mnemonic)) throw std::runtime_error("Mnemonic phrased words must be 25");
if(!crypto::ElectrumWords::words_to_bytes(mnemonic, recovery_key, seed_language)) throw std::runtime_error("Invalid mnemonic");
if (seed_language == crypto::ElectrumWords::old_language_name) seed_language = "English";
// validate language
if (!language.empty()) {
if (!crypto::ElectrumWords::is_valid_language(language)) throw std::runtime_error("Invalid language: " + language);
if (language != seed_language) throw std::runtime_error("Seed language mismatch");
}
}
// -------------------------- BINARY SERIALIZATION ----------------------------
void monero_utils::json_to_binary(const std::string &json, std::string &bin) {
epee::serialization::portable_storage ps;
ps.load_from_json(json);
epee::byte_stream bs;
ps.store_to_binary(bs);
bin = std::string((char*) bs.data(), bs.size());
}
void monero_utils::binary_to_json(const std::string &bin, std::string &json) {
epee::serialization::portable_storage ps;
ps.load_from_binary(bin);
ps.dump_as_json(json);
}
void monero_utils::binary_blocks_to_json(const std::string &bin, std::string &json) {
// load binary rpc response to struct
cryptonote::COMMAND_RPC_GET_BLOCKS_BY_HEIGHT::response resp_struct;
epee::serialization::load_t_from_binary(resp_struct, bin);
// build property tree from deserialized blocks and transactions
boost::property_tree::ptree root;
boost::property_tree::ptree blocksNode; // array of block strings
boost::property_tree::ptree txsNodes; // array of txs per block (array of array)
for (int blockIdx = 0; blockIdx < resp_struct.blocks.size(); blockIdx++) {
// parse and validate block
cryptonote::block block;
if (cryptonote::parse_and_validate_block_from_blob(resp_struct.blocks[blockIdx].block, block)) {
// add block node to blocks node
boost::property_tree::ptree blockNode;
blockNode.put("", cryptonote::obj_to_json_str(block)); // TODO: no pretty print
blocksNode.push_back(std::make_pair("", blockNode));
} else {
throw std::runtime_error("failed to parse block blob at index " + std::to_string(blockIdx));
}
// parse and validate txs
boost::property_tree::ptree txs_node;
for (int txIdx = 0; txIdx < resp_struct.blocks[blockIdx].txs.size(); txIdx++) {
cryptonote::transaction tx;
if (cryptonote::parse_and_validate_tx_from_blob(resp_struct.blocks[blockIdx].txs[txIdx].blob, tx)) {
// add tx node to txs node
boost::property_tree::ptree txNode;
//MTRACE("PRUNED:\n" << monero_utils::get_pruned_tx_json(tx));
txNode.put("", monero_utils::get_pruned_tx_json(tx)); // TODO: no pretty print
txs_node.push_back(std::make_pair("", txNode));
} else {
throw std::runtime_error("failed to parse tx blob at index " + std::to_string(txIdx));
}
}
txsNodes.push_back(std::make_pair("", txs_node)); // array of array of transactions, one array per block
}
root.add_child("blocks", blocksNode);
root.add_child("txs", txsNodes);
root.put("status", resp_struct.status);
root.put("untrusted", resp_struct.untrusted); // TODO: loss of ints and bools
// convert root to string // TODO: common utility with serial_bridge
std::stringstream ss;
boost::property_tree::write_json(ss, root, false/*pretty*/);
json = ss.str();
}
// ------------------------------- RAPIDJSON ----------------------------------
void monero_utils::add_json_member(std::string key, std::string val, rapidjson::Document::AllocatorType& allocator, rapidjson::Value& root, rapidjson::Value& field) {
rapidjson::Value field_key(key.c_str(), key.size(), allocator);
field.SetString(val.c_str(), val.size(), allocator);
root.AddMember(field_key, field, allocator);
}
void monero_utils::add_json_member(std::string key, bool val, rapidjson::Document::AllocatorType& allocator, rapidjson::Value& root) {
rapidjson::Value field_key(key.c_str(), key.size(), allocator);
if (val) {
rapidjson::Value field_val(rapidjson::kTrueType);
root.AddMember(field_key, field_val, allocator);
} else {
rapidjson::Value field_val(rapidjson::kFalseType);
root.AddMember(field_key, field_val, allocator);
}
}
rapidjson::Value monero_utils::to_rapidjson_val(rapidjson::Document::AllocatorType& allocator, const std::vector<std::string>& strs) {
rapidjson::Value value_arr(rapidjson::kArrayType);
rapidjson::Value value_str(rapidjson::kStringType);
for (const std::string& str : strs) {
value_str.SetString(str.c_str(), str.size(), allocator);
value_arr.PushBack(value_str, allocator);
}
return value_arr;
}
rapidjson::Value monero_utils::to_rapidjson_val(rapidjson::Document::AllocatorType& allocator, const std::vector<int>& nums) {
rapidjson::Value value_arr(rapidjson::kArrayType);
rapidjson::Value value_num(rapidjson::kNumberType);
for (const auto& num : nums) {
value_num.SetInt(num);
value_arr.PushBack(value_num, allocator);
}
return value_arr;
}
rapidjson::Value monero_utils::to_rapidjson_val(rapidjson::Document::AllocatorType& allocator, const std::vector<uint8_t>& nums) {
rapidjson::Value value_arr(rapidjson::kArrayType);
rapidjson::Value value_num(rapidjson::kNumberType);
for (const auto& num : nums) {
value_num.SetInt(num);
value_arr.PushBack(value_num, allocator);
}
return value_arr;
}
// TODO: remove these redundant implementations for different sizes?
rapidjson::Value monero_utils::to_rapidjson_val(rapidjson::Document::AllocatorType& allocator, const std::vector<uint32_t>& nums) {
rapidjson::Value value_arr(rapidjson::kArrayType);
rapidjson::Value value_num(rapidjson::kNumberType);
for (const auto& num : nums) {
value_num.SetUint64(num);
value_arr.PushBack(value_num, allocator);
}
return value_arr;
}
rapidjson::Value monero_utils::to_rapidjson_val(rapidjson::Document::AllocatorType& allocator, const std::vector<uint64_t>& nums) {
rapidjson::Value value_arr(rapidjson::kArrayType);
rapidjson::Value value_num(rapidjson::kNumberType);
for (const auto& num : nums) {
value_num.SetUint64(num);
value_arr.PushBack(value_num, allocator);
}
return value_arr;
}
// ----------------------------------------------------------------------------
bool monero_utils::is_valid_language(const std::string& language) {
std::vector<std::string> languages;
crypto::ElectrumWords::get_language_list(languages, false);
std::vector<std::string>::iterator it = std::find(languages.begin(), languages.end(), language);
if (it == languages.end()) {
crypto::ElectrumWords::get_language_list(languages, true);
it = std::find(languages.begin(), languages.end(), language);
}
if (it == languages.end()) return false;
return true;
}
// TODO: this is unused
std::shared_ptr<monero_block> monero_utils::cn_block_to_block(const cryptonote::block& cn_block) {
cryptonote::block temp = cn_block;
std::cout << cryptonote::obj_to_json_str(temp) << std::endl;
std::shared_ptr<monero_block> block = std::make_shared<monero_block>();
block->m_major_version = cn_block.major_version;
block->m_minor_version = cn_block.minor_version;
block->m_timestamp = cn_block.timestamp;
block->m_prev_hash = epee::string_tools::pod_to_hex(cn_block.prev_id);
block->m_nonce = cn_block.nonce;
block->m_miner_tx = monero_utils::cn_tx_to_tx(cn_block.miner_tx);
for (const crypto::hash& tx_hash : cn_block.tx_hashes) {
block->m_tx_hashes.push_back(epee::string_tools::pod_to_hex(tx_hash));
}
return block;
}
std::shared_ptr<monero_tx> monero_utils::cn_tx_to_tx(const cryptonote::transaction& cn_tx, bool init_as_tx_wallet) {
std::shared_ptr<monero_tx> tx = init_as_tx_wallet ? std::make_shared<monero_tx_wallet>() : std::make_shared<monero_tx>();
tx->m_version = cn_tx.version;
tx->m_unlock_time = cn_tx.unlock_time;
tx->m_hash = epee::string_tools::pod_to_hex(cn_tx.hash);
tx->m_extra = cn_tx.extra;
// init inputs
for (const txin_v& cnVin : cn_tx.vin) {
if (cnVin.which() != 0 && cnVin.which() != 3) throw std::runtime_error("Unsupported variant type");
if (tx->m_is_miner_tx == boost::none) tx->m_is_miner_tx = cnVin.which() == 0;
if (cnVin.which() != 3) continue; // only process txin_to_key of variant TODO: support other types, like 0 "gen" which is miner tx?
std::shared_ptr<monero_output> input = init_as_tx_wallet ? std::make_shared<monero_output_wallet>() : std::make_shared<monero_output>();
input->m_tx = tx;
tx->m_inputs.push_back(input);
const txin_to_key& txin = boost::get<txin_to_key>(cnVin);
input->m_amount = txin.amount;
input->m_ring_output_indices = txin.key_offsets;
crypto::key_image cnKeyImage = txin.k_image;
input->m_key_image = std::make_shared<monero_key_image>();
input->m_key_image->m_hex = epee::string_tools::pod_to_hex(cnKeyImage);
}
// init outputs
for (const tx_out& cnVout : cn_tx.vout) {
std::shared_ptr<monero_output> output = init_as_tx_wallet ? std::make_shared<monero_output_wallet>() : std::make_shared<monero_output>();
output->m_tx = tx;
tx->m_outputs.push_back(output);
output->m_amount = cnVout.amount;
// before HF_VERSION_VIEW_TAGS, outputs with public keys are of type txout_to_key
// after HF_VERSION_VIEW_TAGS, outputs with public keys are of type txout_to_tagged_key
crypto::public_key cnStealthPublicKey;
if (cnVout.target.type() == typeid(txout_to_key))
cnStealthPublicKey = boost::get<txout_to_key>(cnVout.target).key;
else if (cnVout.target.type() == typeid(txout_to_tagged_key))
cnStealthPublicKey = boost::get<txout_to_tagged_key>(cnVout.target).key;
else
throw std::runtime_error(std::string("Unexpected output target type found: ") + std::string(cnVout.target.type().name()));
output->m_stealth_public_key = epee::string_tools::pod_to_hex(cnStealthPublicKey);
}
return tx;
// TODO: finish this, cryptonote::transaction has:
// std::vector<std::vector<crypto::signature> > m_signatures;
// rct::rctSig m_rct_signatures;
// mutable size_t blob_size;
}
void monero_utils::binary_blocks_to_property_tree(const std::string &bin, boost::property_tree::ptree &node) {
std::string response_json;
monero_utils::binary_blocks_to_json(bin, response_json);
std::istringstream iss(response_json);
boost::property_tree::read_json(iss, node);
auto blocks = node.get_child("blocks");
boost::property_tree::ptree parsed_blocks;
for (auto &entry : blocks) {
const std::string &block_str = entry.second.get_value<std::string>();
boost::property_tree::ptree block_node;
gen_utils::deserialize(block_str, block_node);
parsed_blocks.push_back(std::make_pair("", block_node));
}
node.put_child("blocks", parsed_blocks);
auto txs = node.get_child("txs");
boost::property_tree::ptree all_txs;
for (auto &rpc_txs_entry : txs) {
boost::property_tree::ptree txs_for_block;
const auto &rpc_txs = rpc_txs_entry.second;
if (!rpc_txs.empty() || !rpc_txs.data().empty()) {
for (auto &tx_entry : rpc_txs) {
std::string tx_str = tx_entry.second.get_value<std::string>();
auto pos = tx_str.find(',');
if (pos != std::string::npos) {
tx_str.replace(pos, 1, "{");
tx_str += "}";
}
boost::property_tree::ptree tx_node;
gen_utils::deserialize(tx_str, tx_node);
txs_for_block.push_back(std::make_pair("", tx_node));
}
}
all_txs.push_back(std::make_pair("", txs_for_block));
}
node.put_child("txs", all_txs);
}
void monero_utils::merge_tx(const std::shared_ptr<monero_tx_wallet>& tx, std::map<std::string, std::shared_ptr<monero_tx_wallet>>& tx_map, std::map<uint64_t, std::shared_ptr<monero_block>>& block_map) {
if (tx->m_hash == boost::none) throw std::runtime_error("Tx hash is not initialized");
// merge tx
std::map<std::string, std::shared_ptr<monero_tx_wallet>>::const_iterator tx_iter = tx_map.find(*tx->m_hash);
if (tx_iter == tx_map.end()) {
tx_map[*tx->m_hash] = tx; // cache new tx
} else {
std::shared_ptr<monero_tx_wallet>& a_tx = tx_map[*tx->m_hash];
a_tx->merge(a_tx, tx); // merge with existing tx
}
// merge tx's block if confirmed
if (tx->get_height() != boost::none) {
std::map<uint64_t, std::shared_ptr<monero_block>>::const_iterator block_iter = block_map.find(tx->get_height().get());
if (block_iter == block_map.end()) {
block_map[tx->get_height().get()] = tx->m_block; // cache new block
} else {
std::shared_ptr<monero_block>& a_block = block_map[tx->get_height().get()];
a_block->merge(a_block, tx->m_block); // merge with existing block
}
}
}
bool monero_utils::tx_height_less_than(const std::shared_ptr<monero_tx>& tx1, const std::shared_ptr<monero_tx>& tx2) {
if (tx1->m_block != nullptr && tx2->m_block != nullptr) return tx1->get_height() < tx2->get_height();
else if (tx1->m_block == nullptr) return false;
else return true;
}
bool monero_utils::incoming_transfer_before(const std::shared_ptr<monero_incoming_transfer>& transfer1, const std::shared_ptr<monero_incoming_transfer>& transfer2) {
// compare by height
if (tx_height_less_than(transfer1->m_tx, transfer2->m_tx)) return true;
// compare by account and subaddress index
if (transfer1->m_account_index.get() < transfer2->m_account_index.get()) return true;
else if (transfer1->m_account_index.get() == transfer2->m_account_index.get()) return transfer1->m_subaddress_index.get() < transfer2->m_subaddress_index.get();
else return false;
}
bool monero_utils::vout_before(const std::shared_ptr<monero_output>& o1, const std::shared_ptr<monero_output>& o2) {
if (o1 == o2) return false; // ignore equal references
std::shared_ptr<monero_output_wallet> ow1 = std::static_pointer_cast<monero_output_wallet>(o1);
std::shared_ptr<monero_output_wallet> ow2 = std::static_pointer_cast<monero_output_wallet>(o2);
// compare by height
if (tx_height_less_than(ow1->m_tx, ow2->m_tx)) return true;
// compare by account index, subaddress index, output index, then key image hex
if (ow1->m_account_index.get() < ow2->m_account_index.get()) return true;
if (ow1->m_account_index.get() == ow2->m_account_index.get()) {
if (ow1->m_subaddress_index.get() < ow2->m_subaddress_index.get()) return true;
if (ow1->m_subaddress_index.get() == ow2->m_subaddress_index.get()) {
if (ow1->m_index.get() < ow2->m_index.get()) return true;
if (ow1->m_index.get() == ow2->m_index.get()) throw std::runtime_error("Should never sort outputs with duplicate indices");
}
}
return false;
}
static std::string make_uri(const std::string &address, const std::string &payment_id, uint64_t amount, const std::string &tx_description, const std::string &recipient_name, monero_network_type network_type) {
cryptonote::address_parse_info info;
if(!get_account_address_from_str(info, static_cast<cryptonote::network_type>(network_type), address)) {
throw std::runtime_error(std::string("Invalid address: ") + address);
}
if (!payment_id.empty()) {
throw std::runtime_error("Standalone payment id deprecated, use integrated address instead");
}
std::string uri = "monero:" + address;
unsigned int n_fields = 0;
if (amount > 0) {
// URI encoded amount is in decimal units, not atomic units
uri += (n_fields++ ? "&" : "?") + std::string("tx_amount=") + cryptonote::print_money(amount);
}
if (!recipient_name.empty()) {
uri += (n_fields++ ? "&" : "?") + std::string("recipient_name=") + epee::net_utils::conver_to_url_format(recipient_name);
}
if (!tx_description.empty()) {
uri += (n_fields++ ? "&" : "?") + std::string("tx_description=") + epee::net_utils::conver_to_url_format(tx_description);
}
return uri;
}
std::string monero_utils::get_payment_uri(const monero_tx_config& config, monero_network_type network_type) {
// validate config
std::vector<std::shared_ptr<monero_destination>> destinations = config.get_normalized_destinations();
if (destinations.size() != 1) throw std::runtime_error("Cannot make URI from supplied parameters: must provide exactly one destination to send funds");
if (destinations.at(0)->m_address == boost::none) throw std::runtime_error("Cannot make URI from supplied parameters: must provide destination address");
if (destinations.at(0)->m_amount == boost::none) throw std::runtime_error("Cannot make URI from supplied parameters: must provide destination amount");
// prepare wallet2 params
std::string address = destinations.at(0)->m_address.get();
std::string payment_id = config.m_payment_id == boost::none ? "" : config.m_payment_id.get();
uint64_t amount = destinations.at(0)->m_amount.get();
std::string note = config.m_note == boost::none ? "" : config.m_note.get();
std::string m_recipient_name = config.m_recipient_name == boost::none ? "" : config.m_recipient_name.get();
// make uri
std::string uri = make_uri(address, payment_id, amount, note, m_recipient_name, network_type);
if (uri.empty()) throw std::runtime_error("Cannot make URI from supplied parameters");
return uri;
}
uint64_t monero_utils::xmr_to_atomic_units(double amount_xmr) {
if (!std::isfinite(amount_xmr) || amount_xmr < 0) throw std::invalid_argument("amount must be a finite, non-negative number");
long double atomic = std::round(static_cast<long double>(amount_xmr) * static_cast<long double>(XMR_AU_MULTIPLIER));
// validate the actual rounded value about to be cast, not a precomputed "amount_xmr" threshold:
// a threshold like UINT64_MAX / XMR_AU_MULTIPLIER carries its own division rounding error and can
// let a value that overflows uint64_t slip through, and casting an out-of-range floating point
// value to uint64_t is undefined behavior (not a wraparound). 2^64 is exactly representable in any
// binary floating point type with enough exponent range (it's a pure power of two), so this bound
// check is exact regardless of long double's precision on a given platform.
static const long double MAX_ATOMIC_UNITS_EXCLUSIVE = std::ldexp(1.0L, 64); // 2^64
if (atomic < 0.0L || atomic >= MAX_ATOMIC_UNITS_EXCLUSIVE) throw std::invalid_argument("amount exceeds maximum representable atomic units");
return static_cast<uint64_t>(atomic);
}
double monero_utils::atomic_units_to_xmr(uint64_t amount_atomic_units) {
return static_cast<double>(amount_atomic_units) / static_cast<double>(XMR_AU_MULTIPLIER);
}
@@ -0,0 +1,322 @@
/**
* Copyright (c) woodser
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#pragma once
#ifndef monero_utils_h
#define monero_utils_h
#include "wallet/monero_wallet_model.h"
#include "cryptonote_basic/cryptonote_basic.h"
#include "serialization/keyvalue_serialization.h" // TODO: consolidate with other binary deps?
#include "storages/portable_storage.h"
/**
* Collection of utilities for the Monero library.
*/
namespace monero_utils
{
using namespace cryptonote;
// ------------------------------ CONSTANTS ---------------------------------
static const int RING_SIZE = 12; // network-enforced ring size
static const uint64_t XMR_AU_MULTIPLIER = 1000000000000ULL;
// -------------------------------- UTILS -----------------------------------
void set_log_level(int level);
void set_log_categories(const std::string& categories);
void configure_logging(const std::string& path, bool console);
monero_integrated_address get_integrated_address(monero_network_type network_type, const std::string& standard_address, const std::string& payment_id);
std::string get_payment_uri(const monero_tx_config& config, monero_network_type network_type);
bool is_valid_address(const std::string& address, monero_network_type network_type);
bool is_valid_private_view_key(const std::string& private_view_key);
bool is_valid_private_spend_key(const std::string& private_spend_key);
bool is_valid_public_view_key(const std::string& public_view_key);
bool is_valid_public_spend_key(const std::string& public_spend_key);
bool is_valid_payment_id(const std::string& payment_id);
bool is_valid_mnemonic(const std::string& mnemonic, const std::string& language = "");
void validate_address(const std::string& address, monero_network_type network_type);
void validate_private_view_key(const std::string& private_view_key);
void validate_private_spend_key(const std::string& private_spend_key);
void validate_public_view_key(const std::string& public_view_key);
void validate_public_spend_key(const std::string& public_spend_key);
void validate_payment_id(const std::string& payment_id);
void validate_mnemonic(const std::string& mnemonic, const std::string& language = "");
void json_to_binary(const std::string &json, std::string &bin);
void binary_to_json(const std::string &bin, std::string &json);
void binary_blocks_to_json(const std::string &bin, std::string &json);
uint64_t xmr_to_atomic_units(double amount_xmr);
double atomic_units_to_xmr(uint64_t amount_atomic_units);
// ------------------------------ RAPIDJSON ---------------------------------
/**
* Add number, string, and boolean json members using template specialization.
*
* TODO: add_json_member("key", "val", ...) treated as integer instead of string literal
*/
template <class T>
void add_json_member(std::string key, T val, rapidjson::Document::AllocatorType& allocator, rapidjson::Value& root, rapidjson::Value& field) {
rapidjson::Value field_key(key.c_str(), key.size(), allocator);
if (std::is_signed<T>::value) field.SetInt64((int64_t) val);
else field.SetUint64((uint64_t) val);
root.AddMember(field_key, field, allocator);
}
void add_json_member(std::string key, std::string val, rapidjson::Document::AllocatorType& allocator, rapidjson::Value& root, rapidjson::Value& field);
void add_json_member(std::string key, bool val, rapidjson::Document::AllocatorType& allocator, rapidjson::Value& root);
// TODO: template implementation here, could move to monero_utils.hpp per https://stackoverflow.com/questions/3040480/c-template-function-compiles-in-header-but-not-implementation
template <class T> rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator, const std::vector<std::shared_ptr<T>>& vals) {
rapidjson::Value value_arr(rapidjson::kArrayType);
for (const auto& val : vals) {
value_arr.PushBack(val->to_rapidjson_val(allocator), allocator);
}
return value_arr;
}
// TODO: template implementation here, could move to monero_utils.hpp per https://stackoverflow.com/questions/3040480/c-template-function-compiles-in-header-but-not-implementation
template <class T> rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator, const std::vector<T>& vals) {
rapidjson::Value value_arr(rapidjson::kArrayType);
for (const auto& val : vals) {
value_arr.PushBack(val.to_rapidjson_val(allocator), allocator);
}
return value_arr;
}
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator, const std::vector<std::string>& strs);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator, const std::vector<int>& nums);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator, const std::vector<uint8_t>& nums);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator, const std::vector<uint32_t>& nums);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator, const std::vector<uint64_t>& nums);
// --------------------------------------------------------------------------
/**
* Indicates if the given language is valid.
*
* @param language is the language to validate
* @return true if the language is valid, false otherwise
*/
bool is_valid_language(const std::string& language);
/**
* Convert a cryptonote::block to a block in this library's native model.
*
* @param cn_block is the block to convert
* @return a block in this library's native model
*/
std::shared_ptr<monero_block> cn_block_to_block(const cryptonote::block& cn_block);
/**
* Convert a cryptonote::transaction to a transaction in this library's
* native model.
*
* @param cn_tx is the transaction to convert
* @param init_as_tx_wallet specifies if a monero_tx xor monero_tx_wallet should be initialized
*/
std::shared_ptr<monero_tx> cn_tx_to_tx(const cryptonote::transaction& cn_tx, bool init_as_tx_wallet = false);
/**
* Modified from core_rpc_server.cpp to return a std::string.
*
* TODO: remove this duplicate, use core_rpc_server instead
*/
static std::string get_pruned_tx_json(cryptonote::transaction &tx)
{
std::stringstream ss;
json_archive<true> ar(ss);
bool r = tx.serialize_base(ar);
CHECK_AND_ASSERT_MES(r, std::string(), "Failed to serialize rct signatures base");
return ss.str();
}
void binary_blocks_to_property_tree(const std::string &bin, boost::property_tree::ptree &node);
/**
* Merges a transaction into a unique set of transactions.
*
* @param tx is the transaction to merge into the existing txs
* @param tx_map maps tx hashes to txs
* @param block_map maps block heights to blocks
*/
void merge_tx(const std::shared_ptr<monero_tx_wallet>& tx, std::map<std::string, std::shared_ptr<monero_tx_wallet>>& tx_map, std::map<uint64_t, std::shared_ptr<monero_block>>& block_map);
/**
* Returns true iff tx1's height is known to be less than tx2's height for sorting.
*/
bool tx_height_less_than(const std::shared_ptr<monero_tx>& tx1, const std::shared_ptr<monero_tx>& tx2);
/**
* Returns true iff transfer1 is ordered before transfer2 by ascending account and subaddress indices.
*/
bool incoming_transfer_before(const std::shared_ptr<monero_incoming_transfer>& transfer1, const std::shared_ptr<monero_incoming_transfer>& transfer2);
/**
* Returns true iff wallet vout1 is ordered before vout2 by ascending account and subaddress indices then index.
*/
bool vout_before(const std::shared_ptr<monero_output>& o1, const std::shared_ptr<monero_output>& o2);
// ----------------------------- GATHER BLOCKS ------------------------------
static std::vector<std::shared_ptr<monero_block>> get_blocks_from_txs(std::vector<std::shared_ptr<monero_tx_wallet>> txs) {
std::shared_ptr<monero_block> unconfirmed_block = nullptr; // placeholder for unconfirmed txs
std::vector<std::shared_ptr<monero_block>> blocks;
std::unordered_set<std::shared_ptr<monero_block>> seen_block_ptrs;
for (const std::shared_ptr<monero_tx_wallet>& tx : txs) {
if (tx->m_block == nullptr) {
if (unconfirmed_block == nullptr) unconfirmed_block = std::make_shared<monero_block>();
tx->m_block = unconfirmed_block;
unconfirmed_block->m_txs.push_back(tx);
}
std::unordered_set<std::shared_ptr<monero_block>>::const_iterator got = seen_block_ptrs.find(tx->m_block);
if (got == seen_block_ptrs.end()) {
seen_block_ptrs.insert(tx->m_block);
blocks.push_back(tx->m_block);
}
}
return blocks;
}
static std::vector<std::shared_ptr<monero_block>> get_blocks_from_transfers(std::vector<std::shared_ptr<monero_transfer>> transfers) {
std::shared_ptr<monero_block> unconfirmed_block = nullptr; // placeholder for unconfirmed txs in return json
std::vector<std::shared_ptr<monero_block>> blocks;
std::unordered_set<std::shared_ptr<monero_block>> seen_block_ptrs;
for (auto const& transfer : transfers) {
std::shared_ptr<monero_tx_wallet> tx = transfer->m_tx;
if (tx->m_block == nullptr) {
if (unconfirmed_block == nullptr) unconfirmed_block = std::make_shared<monero_block>();
tx->m_block = unconfirmed_block;
unconfirmed_block->m_txs.push_back(tx);
}
std::unordered_set<std::shared_ptr<monero_block>>::const_iterator got = seen_block_ptrs.find(tx->m_block);
if (got == seen_block_ptrs.end()) {
seen_block_ptrs.insert(tx->m_block);
blocks.push_back(tx->m_block);
}
}
return blocks;
}
static std::vector<std::shared_ptr<monero_block>> get_blocks_from_outputs(std::vector<std::shared_ptr<monero_output_wallet>> outputs) {
std::vector<std::shared_ptr<monero_block>> blocks;
std::unordered_set<std::shared_ptr<monero_block>> seen_block_ptrs;
for (auto const& output : outputs) {
std::shared_ptr<monero_tx_wallet> tx = std::static_pointer_cast<monero_tx_wallet>(output->m_tx);
if (tx->m_block == nullptr) throw std::runtime_error("Need to handle unconfirmed output");
std::unordered_set<std::shared_ptr<monero_block>>::const_iterator got = seen_block_ptrs.find(tx->m_block);
if (got == seen_block_ptrs.end()) {
seen_block_ptrs.insert(tx->m_block);
blocks.push_back(tx->m_block);
}
}
return blocks;
}
// ------------------------------ FREE MEMORY -------------------------------
static void free(std::shared_ptr<monero_block> block) {
for (std::shared_ptr<monero_tx>& tx : block->m_txs) {
tx->m_block.reset();
monero_tx_wallet* tx_wallet = dynamic_cast<monero_tx_wallet*>(tx.get());
if (tx_wallet != nullptr) {
if (tx_wallet->m_tx_set != nullptr) tx_wallet->m_tx_set.reset();
if (tx_wallet->m_outgoing_transfer != nullptr) tx_wallet->m_outgoing_transfer->m_tx.reset();
for (std::shared_ptr<monero_transfer> transfer : tx_wallet->m_incoming_transfers) transfer->m_tx.reset();
for (std::shared_ptr<monero_output> output : tx_wallet->m_outputs) output->m_tx.reset();
for (std::shared_ptr<monero_output> input : tx_wallet->m_inputs) {
input->m_key_image.reset();
input->m_tx.reset();
}
}
monero_tx_query* tx_query = dynamic_cast<monero_tx_query*>(tx.get());
if (tx_query != nullptr) {
if (tx_query->m_transfer_query != nullptr) {
tx_query->m_transfer_query->m_tx_query.reset();
tx_query->m_transfer_query.reset();
}
if (tx_query->m_output_query != nullptr) {
tx_query->m_output_query->m_tx_query.reset();
tx_query->m_output_query.reset();
}
}
}
block.reset();
}
static void free(std::vector<std::shared_ptr<monero_block>> blocks) {
for (std::shared_ptr<monero_block>& block : blocks) monero_utils::free(block);
}
static void free(std::shared_ptr<monero_tx> tx) {
if (tx->m_block == nullptr) {
std::shared_ptr<monero_block> block = std::make_shared<monero_block>();
tx->m_block = block;
block->m_txs.push_back(tx);
}
monero_utils::free(tx->m_block);
}
static void free(std::vector<std::shared_ptr<monero_tx_wallet>> txs) {
return monero_utils::free(monero_utils::get_blocks_from_txs(txs));
}
static void free(std::vector<std::shared_ptr<monero_transfer>> transfers) {
return monero_utils::free(monero_utils::get_blocks_from_transfers(transfers));
}
static void free(std::vector<std::shared_ptr<monero_output_wallet>> outputs) {
return monero_utils::free(monero_utils::get_blocks_from_outputs(outputs));
}
}
#endif /* monero_utils_h */
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,311 @@
/**
* Copyright (c) woodser
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#pragma once
#include <boost/optional.hpp>
#include <boost/thread/mutex.hpp>
#include <boost/thread/thread.hpp>
#include <boost/thread/condition_variable.hpp>
#include <atomic>
#include "monero_wallet.h"
#include "wallet/wallet2.h"
/**
* Implements a monero_wallet.h by wrapping monero-project's wallet2.
*/
namespace monero {
// -------------------------------- LISTENERS -------------------------------
// forward declaration of internal wallet2 listener
struct wallet2_listener;
// --------------------------- STATIC WALLET UTILS --------------------------
/**
* Monero wallet implementation which uses monero-project's wallet2.
*/
class monero_wallet_full : public monero_wallet {
public:
/**
* Indicates if a wallet exists at the given path.
*
* @param path is the path to check for a wallet
* @return true if a wallet exists at the given path, false otherwise
*/
static bool wallet_exists(const std::string& path);
/**
* Open an existing wallet from disk.
*
* @param path is the path to the wallet file to open
* @param password is the password of the wallet file to open
* @param network_type is the wallet's network type
* @param regtest enable regtest
* @return a pointer to the wallet instance
*/
static monero_wallet_full* open_wallet(const std::string& path, const std::string& password, const monero_network_type network_type, bool regtest = false);
/**
* Open an in-memory wallet from existing data buffers.
*
* @param password is the password of the wallet file to open
* @param network_type is the wallet's network type
* @param keys_data contains the contents of the ".keys" file
* @param cache_data contains the contents of the wallet cache file (no extension)
* @param daemon_connection is connection information to a daemon (default = an unconnected wallet)
* @param http_client_factory allows use of custom http clients
* @param regtest enable regtest
* @return a pointer to the wallet instance
*/
static monero_wallet_full* open_wallet_data(const std::string& password, const monero_network_type, const std::string& keys_data, const std::string& cache_data, const std::shared_ptr<monero_rpc_connection>& daemon_connection = std::make_shared<monero_rpc_connection>(), std::unique_ptr<epee::net_utils::http::http_client_factory> http_client_factory = nullptr, bool regtest = false);
/**
* Create a new wallet with the given configuration.
*
* @param config is the wallet configuration
* @param http_client_factory allows use of custom http clients
* @return a pointer to the wallet instance
*/
static monero_wallet_full* create_wallet(const monero_wallet_config& config, std::unique_ptr<epee::net_utils::http::http_client_factory> http_client_factory = nullptr);
/**
* Get a list of available languages for the wallet's seed.
*
* @return the available languages for the wallet's seed
*/
static std::vector<std::string> get_seed_languages();
// ----------------------------- WALLET METHODS -----------------------------
/**
* Destruct the wallet.
*/
~monero_wallet_full();
/**
* Supported wallet methods.
*/
bool is_view_only() const override { return m_w2->watch_only(); }
void set_daemon_connection(const std::string& uri, const std::string& username = "", const std::string& password = "", const std::string& proxy_uri = "", const boost::optional<bool>& is_trusted = boost::none) override;
void set_daemon_connection(const std::shared_ptr<monero_rpc_connection>& connection, const boost::optional<bool>& is_trusted = boost::none) override;
std::shared_ptr<monero_rpc_connection> get_daemon_connection() const override;
bool is_connected_to_daemon() const override;
bool is_daemon_synced() const override;
bool is_daemon_trusted() const override;
bool is_synced() const override;
monero_version get_version() const override;
std::string get_path() const override;
monero_network_type get_network_type() const override;
std::string get_seed() const override;
std::string get_seed_language() const override;
std::string get_public_view_key() const override;
std::string get_private_view_key() const override;
std::string get_public_spend_key() const override;
std::string get_private_spend_key() const override;
std::string get_address(const uint32_t account_idx, const uint32_t subaddress_idx) const override;
monero_subaddress get_address_index(const std::string& address) const override;
monero_integrated_address get_integrated_address(const std::string& standard_address = "", const std::string& payment_id = "") const override;
monero_integrated_address decode_integrated_address(const std::string& integrated_address) const override;
uint64_t get_height() const override;
uint64_t get_restore_height() const override;
void set_restore_height(uint64_t restore_height) override;
uint64_t get_daemon_height() const override;
uint64_t get_daemon_max_peer_height() const override;
uint64_t get_height_by_date(uint16_t year, uint8_t month, uint8_t day) const override;
void add_listener(monero_wallet_listener& listener) override;
void remove_listener(monero_wallet_listener& listener) override;
std::set<monero_wallet_listener*> get_listeners() override;
monero_sync_result sync() override;
monero_sync_result sync(monero_wallet_listener& listener) override;
monero_sync_result sync(uint64_t start_height) override;
monero_sync_result sync(uint64_t start_height, monero_wallet_listener& listener) override;
void start_syncing(uint64_t sync_period_in_ms) override;
void stop_syncing() override;
void scan_txs(const std::vector<std::string>& tx_hashes) override;
void rescan_spent() override;
void rescan_blockchain() override;
uint64_t get_balance() const override;
uint64_t get_balance(uint32_t account_idx) const override;
uint64_t get_balance(uint32_t account_idx, uint32_t subaddress_idx) const override;
uint64_t get_unlocked_balance() const override;
uint64_t get_unlocked_balance(uint32_t account_idx) const override;
uint64_t get_unlocked_balance(uint32_t account_idx, uint32_t subaddress_idx) const override;
std::vector<monero_account> get_accounts(bool include_subaddresses, const std::string& tag) const override;
monero_account get_account(const uint32_t account_idx, bool include_subaddresses) const override;
monero_account create_account(const std::string& label = "") override;
std::vector<monero_subaddress> get_subaddresses(const uint32_t account_idx, const std::vector<uint32_t>& subaddress_indices) const override;
monero_subaddress create_subaddress(uint32_t account_idx, const std::string& label = "") override;
void set_subaddress_label(uint32_t account_idx, uint32_t subaddress_idx, const std::string& label = "") override;
std::vector<std::shared_ptr<monero_tx_wallet>> get_txs() const override;
std::vector<std::shared_ptr<monero_tx_wallet>> get_txs(const monero_tx_query& query) const override;
std::vector<std::shared_ptr<monero_transfer>> get_transfers(const monero_transfer_query& query) const override;
std::vector<std::shared_ptr<monero_output_wallet>> get_outputs(const monero_output_query& query) const override;
std::string export_outputs(bool all = false) const override;
int import_outputs(const std::string& outputs_hex) override;
std::shared_ptr<monero_key_image_export_result> export_key_images(bool all = false) const override;
std::shared_ptr<monero_key_image_import_result> import_key_images(const std::vector<std::shared_ptr<monero_key_image>>& key_images, uint64_t offset = 0) override;
void freeze_output(const std::string& key_image) override;
void thaw_output(const std::string& key_image) override;
bool is_output_frozen(const std::string& key_image) override;
monero_tx_priority get_default_fee_priority() const override;
std::vector<std::shared_ptr<monero_tx_wallet>> create_txs(const monero_tx_config& config) override;
std::vector<std::shared_ptr<monero_tx_wallet>> sweep_unlocked(const monero_tx_config& config) override;
std::shared_ptr<monero_tx_wallet> sweep_output(const monero_tx_config& config) override;
std::vector<std::shared_ptr<monero_tx_wallet>> sweep_dust(bool relay = false) override;
std::vector<std::string> relay_txs(const std::vector<std::string>& tx_metadatas) override;
monero_tx_set describe_tx_set(const monero_tx_set& tx_set) override;
monero_tx_set sign_txs(const std::string& unsigned_tx_hex) override;
std::vector<std::string> submit_txs(const std::string& signed_tx_hex) override;
std::string sign_message(const std::string& msg, monero_message_signature_type signature_type, uint32_t account_idx = 0, uint32_t subaddress_idx = 0) const override;
monero_message_signature_result verify_message(const std::string& msg, const std::string& address, const std::string& signature) const override;
std::string get_tx_key(const std::string& tx_hash) const override;
std::shared_ptr<monero_check_tx> check_tx_key(const std::string& tx_hash, const std::string& txKey, const std::string& address) const override;
std::string get_tx_proof(const std::string& tx_hash, const std::string& address, const std::string& message) const override;
std::shared_ptr<monero_check_tx> check_tx_proof(const std::string& tx_hash, const std::string& address, const std::string& message, const std::string& signature) const override;
std::string get_spend_proof(const std::string& tx_hash, const std::string& message) const override;
bool check_spend_proof(const std::string& tx_hash, const std::string& message, const std::string& signature) const override;
std::string get_reserve_proof_wallet(const std::string& message) const override;
std::string get_reserve_proof_account(uint32_t account_idx, uint64_t amount, const std::string& message) const override;
std::shared_ptr<monero_check_reserve> check_reserve_proof(const std::string& address, const std::string& message, const std::string& signature) const override;
std::string get_tx_note(const std::string& tx_hash) const override;
std::vector<std::string> get_tx_notes(const std::vector<std::string>& tx_hashes) const override;
void set_tx_note(const std::string& tx_hash, const std::string& note) override;
void set_tx_notes(const std::vector<std::string>& tx_hashes, const std::vector<std::string>& notes) override;
std::vector<monero_address_book_entry> get_address_book_entries(const std::vector<uint64_t>& indices) const override;
uint64_t add_address_book_entry(const std::string& address, const std::string& description) override;
void edit_address_book_entry(uint64_t index, bool set_address, const std::string& address, bool set_description, const std::string& description) override;
void delete_address_book_entry(uint64_t index) override;
std::string get_payment_uri(const monero_tx_config& config) const override;
std::shared_ptr<monero_tx_config> parse_payment_uri(const std::string& uri) const override;
bool get_attribute(const std::string& key, std::string& value) const override;
void set_attribute(const std::string& key, const std::string& val) override;
void start_mining(boost::optional<uint64_t> num_threads, boost::optional<bool> background_mining, boost::optional<bool> ignore_battery) override;
void stop_mining() override;
uint64_t wait_for_next_block() override;
bool is_multisig_import_needed() const override;
monero_multisig_info get_multisig_info() const override;
std::string prepare_multisig() override;
std::string make_multisig(const std::vector<std::string>& multisig_hexes, int threshold, const std::string& password) override;
monero_multisig_init_result exchange_multisig_keys(const std::vector<std::string>& multisig_hexes, const std::string& password) override;
std::string export_multisig_hex() override;
int import_multisig_hex(const std::vector<std::string>& multisig_hexes, const bool refresh_after_import = true) override;
monero_multisig_sign_result sign_multisig_tx_hex(const std::string& multisig_tx_hex) override;
std::vector<std::string> submit_multisig_tx_hex(const std::string& signed_multisig_tx_hex) override;
void change_password(const std::string& old_password, const std::string& new_password) override;
void move_to(const std::string& path, const std::string& password) override;
void save() override;
void close(bool save = false) override;
bool is_closed() const override { return m_is_closed; }
/**
* Wallet import and export using buffers and not the file system.
*/
std::string get_keys_file_buffer(const epee::wipeable_string& password, bool view_only) const;
std::string get_cache_file_buffer() const;
// --------------------------------- PROTECTED --------------------------------
protected:
std::unique_ptr<tools::wallet2> m_w2; // internal wallet implementation
void init_common();
// ---------------------------------- PRIVATE ---------------------------------
private:
friend struct wallet2_listener;
std::unique_ptr<wallet2_listener> m_w2_listener; // internal wallet implementation listener
std::set<monero_wallet_listener*> m_listeners; // external wallet listeners
std::atomic<bool> m_is_closed;
static monero_wallet_full* create_wallet_from_seed(monero_wallet_config& config, std::unique_ptr<epee::net_utils::http::http_client_factory> http_client_factory);
static monero_wallet_full* create_wallet_from_keys(monero_wallet_config& config, std::unique_ptr<epee::net_utils::http::http_client_factory> http_client_factory);
static monero_wallet_full* create_wallet_random(monero_wallet_config& config, std::unique_ptr<epee::net_utils::http::http_client_factory> http_client_factory);
std::vector<monero_subaddress> get_subaddresses_aux(uint32_t account_idx, const std::vector<uint32_t>& subaddress_indices, const std::vector<tools::wallet2::transfer_details>& transfers) const;
std::vector<std::shared_ptr<monero_transfer>> get_transfers_aux(const monero_transfer_query& query) const;
std::vector<std::shared_ptr<monero_output_wallet>> get_outputs_aux(const monero_output_query& query) const;
std::vector<std::shared_ptr<monero_tx_wallet>> sweep_account(const monero_tx_config& config); // sweeps unlocked funds within an account; private helper to sweep_unlocked()
void assert_not_closed() const;
// serializes a wallet operation with background sync: pauses the sync loop, interrupts background refresh, and locks m_sync_mutex
struct sync_op_lock {
sync_op_lock(const monero_wallet_full& wallet);
sync_op_lock(const sync_op_lock&) = delete;
~sync_op_lock();
const monero_wallet_full& m_wallet;
};
// blockchain sync management
mutable std::atomic<bool> m_is_synced; // whether or not wallet is synced
mutable std::atomic<bool> m_is_connected; // cache connection status to avoid unecessary RPC calls
boost::condition_variable m_sync_cv; // to make sync threads woke
mutable boost::mutex m_sync_mutex; // synchronize sync and other wallet operations
mutable std::atomic<uint32_t> m_num_sync_pauses; // number of operations pausing background sync
std::atomic<bool> m_background_syncing; // whether or not a background sync pass is in progress
std::atomic<bool> m_rescan_on_sync; // whether or not to rescan on sync
std::atomic<bool> m_syncing_enabled; // whether or not auto sync is enabled
std::atomic<bool> m_sync_loop_running; // whether or not the syncing thread is shut down
std::atomic<int> m_syncing_interval; // auto sync loop interval in milliseconds
boost::thread m_syncing_thread; // thread for auto sync loop
boost::mutex m_syncing_mutex; // synchronize auto sync loop
void run_sync_loop(); // run the sync loop in a thread
monero_sync_result lock_and_sync(boost::optional<uint64_t> start_height = boost::none, bool background = false); // internal function to synchronize request to sync and rescan
monero_sync_result sync_aux(boost::optional<uint64_t> start_height = boost::none); // internal function to immediately block, sync, and report progress
};
}
@@ -0,0 +1,320 @@
/**
* Copyright (c) woodser
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#include "monero_wallet_keys.h"
#include "utils/monero_utils.h"
#include <chrono>
#include <iostream>
#include "mnemonics/electrum-words.h"
#include "mnemonics/english.h"
#include "cryptonote_basic/cryptonote_format_utils.h"
#include "cryptonote_basic/cryptonote_basic_impl.h"
#include "string_tools.h"
#include "device/device.hpp"
using namespace epee;
using namespace tools;
using namespace crypto;
/**
* Public library interface.
*/
namespace monero {
// ---------------------------- WALLET MANAGEMENT ---------------------------
monero_wallet_keys* monero_wallet_keys::create_wallet_random(const monero_wallet_config& config) {
// validate and normalize config
monero_wallet_config config_normalized = config.copy();
if (config_normalized.m_network_type == boost::none) throw std::runtime_error("Must provide wallet network type");
if (config_normalized.m_language == boost::none || config_normalized.m_language.get().empty()) config_normalized.m_language = "English";
if (!monero_utils::is_valid_language(config_normalized.m_language.get())) throw std::runtime_error("Unknown language: " + config_normalized.m_language.get());
// initialize random wallet account
monero_wallet_keys* wallet = new monero_wallet_keys();
crypto::secret_key spend_key_sk = wallet->m_account.generate();
// initialize remaining wallet
wallet->m_network_type = config_normalized.m_network_type.get();
wallet->m_language = config_normalized.m_language.get();
epee::wipeable_string wipeable_mnemonic;
if (!crypto::ElectrumWords::bytes_to_words(spend_key_sk, wipeable_mnemonic, wallet->m_language)) {
throw std::runtime_error("Failed to create mnemonic from private spend key for language: " + std::string(wallet->m_language));
}
wallet->m_seed = std::string(wipeable_mnemonic.data(), wipeable_mnemonic.size());
wallet->init_common();
return wallet;
}
monero_wallet_keys* monero_wallet_keys::create_wallet_from_seed(const monero_wallet_config& config) {
// validate config
if (config.m_is_multisig != boost::none && config.m_is_multisig.get()) throw std::runtime_error("Restoring from multisig seed not supported");
if (config.m_network_type == boost::none) throw std::runtime_error("Must provide wallet network type");
if (config.m_seed == boost::none || config.m_seed.get().empty()) throw std::runtime_error("Must provide wallet seed");
// validate mnemonic and get recovery key and language
crypto::secret_key spend_key_sk;
std::string language;
bool is_valid = crypto::ElectrumWords::words_to_bytes(config.m_seed.get(), spend_key_sk, language);
if (!is_valid) throw std::runtime_error("Invalid mnemonic");
if (language == crypto::ElectrumWords::old_language_name) language = Language::English().get_language_name();
// apply offset if given
if (config.m_seed_offset != boost::none && !config.m_seed_offset.get().empty()) spend_key_sk = cryptonote::decrypt_key(spend_key_sk, config.m_seed_offset.get());
// initialize wallet account
monero_wallet_keys* wallet = new monero_wallet_keys();
wallet->m_account = cryptonote::account_base{};
wallet->m_account.generate(spend_key_sk, true, false);
// initialize remaining wallet
wallet->m_network_type = config.m_network_type.get();
wallet->m_language = language;
epee::wipeable_string wipeable_mnemonic;
if (!crypto::ElectrumWords::bytes_to_words(spend_key_sk, wipeable_mnemonic, wallet->m_language)) {
throw std::runtime_error("Failed to create mnemonic from private spend key for language: " + std::string(wallet->m_language));
}
wallet->m_seed = std::string(wipeable_mnemonic.data(), wipeable_mnemonic.size());
wallet->init_common();
return wallet;
}
monero_wallet_keys* monero_wallet_keys::create_wallet_from_keys(const monero_wallet_config& config) {
// validate and normalize config
monero_wallet_config config_normalized = config.copy();
if (config.m_network_type == boost::none) throw std::runtime_error("Must provide wallet network type");
if (config.m_language == boost::none || config_normalized.m_language.get().empty()) config_normalized.m_language = "English";
if (config.m_private_spend_key == boost::none) config_normalized.m_private_spend_key = std::string("");
if (config.m_private_view_key == boost::none) config_normalized.m_private_view_key = std::string("");
if (!monero_utils::is_valid_language(config_normalized.m_language.get())) throw std::runtime_error("Unknown language: " + config_normalized.m_language.get());
// parse and validate private spend key
crypto::secret_key spend_key_sk;
bool has_spend_key = false;
if (!config_normalized.m_private_spend_key.get().empty()) {
cryptonote::blobdata spend_key_data;
if (!epee::string_tools::parse_hexstr_to_binbuff(config.m_private_spend_key.get(), spend_key_data) || spend_key_data.size() != sizeof(crypto::secret_key)) {
throw std::runtime_error("failed to parse secret spend key");
}
has_spend_key = true;
spend_key_sk = *reinterpret_cast<const crypto::secret_key*>(spend_key_data.data());
}
// parse and validate private view key
bool has_view_key = true;
crypto::secret_key view_key_sk;
if (config_normalized.m_private_view_key.get().empty()) {
if (has_spend_key) has_view_key = false;
else throw std::runtime_error("Neither spend key nor view key supplied");
}
if (has_view_key) {
cryptonote::blobdata view_key_data;
if (!epee::string_tools::parse_hexstr_to_binbuff(config_normalized.m_private_view_key.get(), view_key_data) || view_key_data.size() != sizeof(crypto::secret_key)) {
throw std::runtime_error("failed to parse secret view key");
}
view_key_sk = *reinterpret_cast<const crypto::secret_key*>(view_key_data.data());
}
// parse and validate address
cryptonote::address_parse_info address_info;
if (config_normalized.m_primary_address.get().empty()) {
if (has_view_key) throw std::runtime_error("must provide address if providing private view key");
} else {
if (!get_account_address_from_str(address_info, static_cast<cryptonote::network_type>(config_normalized.m_network_type.get()), config_normalized.m_primary_address.get())) throw std::runtime_error("failed to parse address");
// check the spend and view keys match the given address
crypto::public_key pkey;
if (has_spend_key) {
if (!crypto::secret_key_to_public_key(spend_key_sk, pkey)) throw std::runtime_error("failed to verify secret spend key");
if (address_info.address.m_spend_public_key != pkey) throw std::runtime_error("spend key does not match address");
}
if (has_view_key) {
if (!crypto::secret_key_to_public_key(view_key_sk, pkey)) throw std::runtime_error("failed to verify secret view key");
if (address_info.address.m_view_public_key != pkey) throw std::runtime_error("view key does not match address");
}
}
// initialize wallet account
monero_wallet_keys* wallet = new monero_wallet_keys();
if (has_spend_key && has_view_key) {
wallet->m_account.create_from_keys(address_info.address, spend_key_sk, view_key_sk);
} else if (has_spend_key) {
wallet->m_account.generate(spend_key_sk, true, false);
} else {
wallet->m_account.create_from_viewkey(address_info.address, view_key_sk);
}
// initialize remaining wallet
wallet->m_is_view_only = !has_spend_key;
wallet->m_network_type = config_normalized.m_network_type.get();
if (!config_normalized.m_private_spend_key.get().empty()) {
wallet->m_language = config_normalized.m_language.get();
epee::wipeable_string wipeable_mnemonic;
if (!crypto::ElectrumWords::bytes_to_words(spend_key_sk, wipeable_mnemonic, wallet->m_language)) {
throw std::runtime_error("Failed to create mnemonic from private spend key for language: " + std::string(wallet->m_language));
}
wallet->m_seed = std::string(wipeable_mnemonic.data(), wipeable_mnemonic.size());
}
wallet->init_common();
return wallet;
}
std::vector<std::string> monero_wallet_keys::get_seed_languages() {
std::vector<std::string> languages;
crypto::ElectrumWords::get_language_list(languages, true); // TODO: support getting names in language
return languages;
}
// ----------------------------- WALLET METHODS -----------------------------
monero_wallet_keys::~monero_wallet_keys() {
MTRACE("~monero_wallet_keys()");
close();
}
monero_version monero_wallet_keys::get_version() const {
monero_version version;
version.m_number = 65552; // same as monero-wallet-rpc v0.15.0.1 release
version.m_is_release = false; // TODO: could pull from MONERO_VERSION_IS_RELEASE in version.cpp
return version;
}
std::string monero_wallet_keys::get_address(uint32_t account_idx, uint32_t subaddress_idx) const {
hw::device &hwdev = m_account.get_device();
cryptonote::subaddress_index index{account_idx, subaddress_idx};
cryptonote::account_public_address address = hwdev.get_subaddress(m_account.get_keys(), index);
return cryptonote::get_account_address_as_str(static_cast<cryptonote::network_type>(m_network_type), !index.is_zero(), address);
}
monero_integrated_address monero_wallet_keys::get_integrated_address(const std::string& standard_address, const std::string& payment_id) const {
MTRACE("monero_wallet_keys::get_integrated_address()");
throw std::runtime_error("monero_wallet_keys::get_integrated_address() not implemented");
}
monero_integrated_address monero_wallet_keys::decode_integrated_address(const std::string& integrated_address) const {
MTRACE("monero_wallet_keys::decode_integrated_address()");
throw std::runtime_error("monero_wallet_keys::decode_integrated_address() not implemented");
}
monero_account monero_wallet_keys::get_account(uint32_t account_idx, bool include_subaddresses) const {
MTRACE("monero_wallet_keys::get_account()");
if (include_subaddresses) {
std::string err = "monero_wallet_keys::get_account(account_idx, include_subaddresses) include_subaddresses must be false";
MERROR(err);
throw std::runtime_error(err);
}
// build and return account
monero_account account;
account.m_index = account_idx;
account.m_primary_address = get_address(account_idx, 0);
return account;
}
std::vector<monero_subaddress> monero_wallet_keys::get_subaddresses(const uint32_t account_idx, const std::vector<uint32_t>& subaddress_indices) const {
// must provide subaddress indices
if (subaddress_indices.empty()) {
std::string err = "Keys-only wallet does not have enumerable set of subaddresses; specific specific subaddresses";
MERROR(err);
throw std::runtime_error(err);
}
// initialize subaddresses at indices
std::vector<monero_subaddress> subaddresses;
for (uint32_t subaddressIndicesIdx = 0; subaddressIndicesIdx < subaddress_indices.size(); subaddressIndicesIdx++) {
monero_subaddress subaddress;
subaddress.m_account_index = account_idx;
uint32_t subaddress_idx = subaddress_indices.at(subaddressIndicesIdx);
subaddress.m_index = subaddress_idx;
subaddress.m_address = get_address(account_idx, subaddress_idx);
subaddresses.push_back(subaddress);
}
return subaddresses;
}
std::string monero_wallet_keys::sign_message(const std::string& msg, monero_message_signature_type signature_type, uint32_t account_idx, uint32_t subaddress_idx) const {
MTRACE("monero_wallet_keys::sign_message()");
throw std::runtime_error("monero_wallet_keys::sign_message() not implemented");
}
monero_message_signature_result monero_wallet_keys::verify_message(const std::string& msg, const std::string& address, const std::string& signature) const {
MTRACE("monero_wallet_keys::verify_message()");
throw std::runtime_error("monero_wallet_keys::verify_message() not implemented");
}
void monero_wallet_keys::close(bool save) {
if (save) throw std::runtime_error("MoneroWalletKeys does not support saving");
// no pointers to destroy
}
// ------------------------------- PRIVATE HELPERS ----------------------------
void monero_wallet_keys::init_common() {
m_primary_address = m_account.get_public_address_str(static_cast<cryptonote::network_type>(m_network_type));
const cryptonote::account_keys& keys = m_account.get_keys();
m_pub_view_key = epee::string_tools::pod_to_hex(keys.m_account_address.m_view_public_key);
m_prv_view_key = epee::string_tools::pod_to_hex(unwrap(unwrap(keys.m_view_secret_key)));
m_pub_spend_key = epee::string_tools::pod_to_hex(keys.m_account_address.m_spend_public_key);
m_prv_spend_key = epee::string_tools::pod_to_hex(unwrap(unwrap(keys.m_spend_secret_key)));
if (m_prv_spend_key == "0000000000000000000000000000000000000000000000000000000000000000") m_prv_spend_key = "";
}
}
@@ -0,0 +1,147 @@
/**
* Copyright (c) woodser
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#pragma once
#include "monero_wallet.h"
#include "cryptonote_basic/account.h"
using namespace monero;
/**
* Public library interface.
*/
namespace monero {
/**
* Implements a Monero wallet to provide basic key management.
*/
class monero_wallet_keys : public monero_wallet {
public:
// --------------------------- STATIC WALLET UTILS --------------------------
/**
* Create a new wallet with a randomly generated seed.
*
* @param config is the wallet configuration (network type and language)
*/
static monero_wallet_keys* create_wallet_random(const monero_wallet_config& config);
/**
* Create a wallet from an existing mnemonic phrase or seed.
*
* @param config is the wallet configuration (network type, seed, seed offset, isMultisig)
*/
static monero_wallet_keys* create_wallet_from_seed(const monero_wallet_config& config);
/**
* Create a wallet from an address, view key, and spend key.
*
* @param config is the wallet configuration (network type, address, view key, spend key, language)
*/
static monero_wallet_keys* create_wallet_from_keys(const monero_wallet_config& config);
/**
* Get a list of available languages for the wallet's seed.
*
* @return the available languages for the wallet's seed
*/
static std::vector<std::string> get_seed_languages();
// ----------------------------- WALLET METHODS -----------------------------
/**
* Destruct the wallet.
*/
~monero_wallet_keys();
/**
* Supported wallet methods.
*/
bool is_view_only() const override { return m_is_view_only; }
monero_version get_version() const override;
monero_network_type get_network_type() const override { return m_network_type; }
std::string get_seed() const override { return m_seed; }
std::string get_seed_language() const override { return m_language; }
std::string get_private_view_key() const override { return m_prv_view_key; }
std::string get_private_spend_key() const override { return m_prv_spend_key; }
std::string get_public_view_key() const override { return m_pub_view_key; }
std::string get_public_spend_key() const override { return m_pub_spend_key; }
std::string get_primary_address() const override { return m_primary_address; }
std::string get_address(const uint32_t account_idx, const uint32_t subaddress_idx) const override;
monero_integrated_address get_integrated_address(const std::string& standard_address = "", const std::string& payment_id = "") const override;
monero_integrated_address decode_integrated_address(const std::string& integrated_address) const override;
monero_account get_account(const uint32_t account_idx, bool include_subaddresses) const override;
std::vector<monero_subaddress> get_subaddresses(const uint32_t account_idx, const std::vector<uint32_t>& subaddress_indices) const override;
std::string sign_message(const std::string& msg, monero_message_signature_type signature_type, uint32_t account_idx = 0, uint32_t subaddress_idx = 0) const override;
monero_message_signature_result verify_message(const std::string& msg, const std::string& address, const std::string& signature) const override;
void close(bool save = false) override;
// --------------------------------- PRIVATE --------------------------------
private:
bool m_is_view_only;
monero_network_type m_network_type;
cryptonote::account_base m_account;
std::string m_seed;
std::string m_language;
std::string m_pub_view_key;
std::string m_prv_view_key;
std::string m_pub_spend_key;
std::string m_prv_spend_key;
std::string m_primary_address;
void init_common();
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,609 @@
/**
* Copyright (c) woodser
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#pragma once
#include "common/monero_rpc_connection.h"
#include "daemon/monero_daemon_model.h"
using namespace monero;
/**
* Public library interface.
*/
namespace monero {
/**
* Configures a wallet to create.
*/
struct monero_wallet_config : public serializable_struct {
boost::optional<std::string> m_path;
boost::optional<std::string> m_password;
boost::optional<monero_network_type> m_network_type;
std::shared_ptr<monero_rpc_connection> m_server;
boost::optional<bool> m_is_trusted_daemon;
boost::optional<std::string> m_seed;
boost::optional<std::string> m_seed_offset;
boost::optional<std::string> m_primary_address;
boost::optional<std::string> m_private_view_key;
boost::optional<std::string> m_private_spend_key;
boost::optional<uint64_t> m_restore_height;
boost::optional<std::string> m_language;
boost::optional<bool> m_save_current;
boost::optional<uint64_t> m_account_lookahead;
boost::optional<uint64_t> m_subaddress_lookahead;
boost::optional<bool> m_is_multisig;
boost::optional<bool> m_regtest;
monero_wallet_config() {}
monero_wallet_config(const monero_wallet_config& config);
monero_wallet_config copy() const;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static std::shared_ptr<monero_wallet_config> deserialize(const std::string& config_json);
};
/**
* Models a result of syncing a wallet.
*/
struct monero_sync_result : public serializable_struct {
uint64_t m_num_blocks_fetched;
bool m_received_money;
monero_sync_result() {}
monero_sync_result(const uint64_t num_blocks_fetched, const bool received_money) : m_num_blocks_fetched(num_blocks_fetched), m_received_money(received_money) {}
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
};
/**
* Models a Monero subaddress.
*/
struct monero_subaddress : public serializable_struct {
boost::optional<uint32_t> m_account_index;
boost::optional<uint32_t> m_index;
boost::optional<std::string> m_address;
boost::optional<std::string> m_label;
boost::optional<uint64_t> m_balance;
boost::optional<uint64_t> m_unlocked_balance;
boost::optional<uint64_t> m_num_unspent_outputs;
boost::optional<bool> m_is_used;
boost::optional<uint64_t> m_num_blocks_to_unlock;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_subaddress>& subaddress);
};
/**
* Models a Monero account.
*/
struct monero_account : public serializable_struct {
boost::optional<uint32_t> m_index;
boost::optional<std::string> m_primary_address;
boost::optional<uint64_t> m_balance;
boost::optional<uint64_t> m_unlocked_balance;
boost::optional<std::string> m_tag;
std::vector<monero_subaddress> m_subaddresses;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_account>& account);
};
/**
* Models an outgoing transfer destination.
*/
struct monero_destination : public serializable_struct {
boost::optional<std::string> m_address;
boost::optional<uint64_t> m_amount;
monero_destination(boost::optional<std::string> address = boost::none, boost::optional<uint64_t> amount = boost::none) : m_address(address), m_amount(amount) {}
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_destination>& destination);
std::shared_ptr<monero_destination> copy(const std::shared_ptr<monero_destination>& src, const std::shared_ptr<monero_destination>& tgt) const;
};
// forward declarations
struct monero_tx_config;
struct monero_tx_wallet;
struct monero_tx_query;
struct monero_tx_set;
/**
* Models a base transfer of funds to or from the wallet.
*
* TODO: m_is_incoming for api consistency
*/
struct monero_transfer : public serializable_struct {
std::shared_ptr<monero_tx_wallet> m_tx;
boost::optional<uint64_t> m_amount;
boost::optional<uint32_t> m_account_index;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_transfer>& transfer);
virtual boost::optional<bool> is_incoming() const = 0; // derived class must implement
std::shared_ptr<monero_transfer> copy(const std::shared_ptr<monero_transfer>& src, const std::shared_ptr<monero_transfer>& tgt) const;
boost::optional<bool> is_outgoing() const {
if (is_incoming() == boost::none) return boost::none;
return !(*is_incoming());
}
void merge(const std::shared_ptr<monero_transfer>& self, const std::shared_ptr<monero_transfer>& other);
};
/**
* Models an incoming transfer of funds to the wallet.
*/
struct monero_incoming_transfer : public monero_transfer {
boost::optional<uint32_t> m_subaddress_index;
boost::optional<std::string> m_address;
boost::optional<uint64_t> m_num_suggested_confirmations;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
std::shared_ptr<monero_incoming_transfer> copy(const std::shared_ptr<monero_transfer>& src, const std::shared_ptr<monero_transfer>& tgt) const;
std::shared_ptr<monero_incoming_transfer> copy(const std::shared_ptr<monero_incoming_transfer>& src, const std::shared_ptr<monero_incoming_transfer>& tgt) const;
boost::optional<bool> is_incoming() const;
void merge(const std::shared_ptr<monero_transfer>& self, const std::shared_ptr<monero_transfer>& other);
void merge(const std::shared_ptr<monero_incoming_transfer>& self, const std::shared_ptr<monero_incoming_transfer>& other);
};
/**
* Models an outgoing transfer of funds from the wallet.
*/
struct monero_outgoing_transfer : public monero_transfer {
std::vector<uint32_t> m_subaddress_indices;
std::vector<std::string> m_addresses;
std::vector<std::shared_ptr<monero_destination>> m_destinations;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
std::shared_ptr<monero_outgoing_transfer> copy(const std::shared_ptr<monero_transfer>& src, const std::shared_ptr<monero_transfer>& tgt) const;
std::shared_ptr<monero_outgoing_transfer> copy(const std::shared_ptr<monero_outgoing_transfer>& src, const std::shared_ptr<monero_outgoing_transfer>& tgt) const;
boost::optional<bool> is_incoming() const;
void merge(const std::shared_ptr<monero_transfer>& self, const std::shared_ptr<monero_transfer>& other);
void merge(const std::shared_ptr<monero_outgoing_transfer>& self, const std::shared_ptr<monero_outgoing_transfer>& other);
};
/**
* Configures a query to retrieve transfers.
*
* All transfers are returned except those that do not meet the criteria defined in this query.
*/
struct monero_transfer_query : public monero_transfer {
boost::optional<bool> m_is_incoming;
boost::optional<std::string> m_address;
std::vector<std::string> m_addresses;
boost::optional<uint32_t> m_subaddress_index;
std::vector<uint32_t> m_subaddress_indices;
std::vector<std::shared_ptr<monero_destination>> m_destinations;
boost::optional<bool> m_has_destinations;
std::shared_ptr<monero_tx_query> m_tx_query;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_transfer_query>& transfer_query);
static std::shared_ptr<monero_transfer_query> deserialize_from_block(const std::string& transfer_query_json);
static bool is_contextual(const monero_transfer_query& query);
std::shared_ptr<monero_transfer_query> copy(const std::shared_ptr<monero_transfer>& src, const std::shared_ptr<monero_transfer>& tgt) const;
std::shared_ptr<monero_transfer_query> copy(const std::shared_ptr<monero_transfer_query>& src, const std::shared_ptr<monero_transfer_query>& tgt) const;
boost::optional<bool> is_incoming() const;
bool meets_criteria(monero_transfer* transfer, bool query_parent = true) const;
};
/**
* Models a Monero output with wallet extensions.
*/
struct monero_output_wallet : public monero_output {
boost::optional<uint32_t> m_account_index;
boost::optional<uint32_t> m_subaddress_index;
boost::optional<bool> m_is_spent;
boost::optional<bool> m_is_frozen;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_output_wallet>& output_wallet);
std::shared_ptr<monero_output_wallet> copy(const std::shared_ptr<monero_output>& src, const std::shared_ptr<monero_output>& tgt) const;
std::shared_ptr<monero_output_wallet> copy(const std::shared_ptr<monero_output_wallet>& src, const std::shared_ptr<monero_output_wallet>& tgt) const;
void merge(const std::shared_ptr<monero_output>& self, const std::shared_ptr<monero_output>& other);
void merge(const std::shared_ptr<monero_output_wallet>& self, const std::shared_ptr<monero_output_wallet>& other);
};
/**
* Configures a query to retrieve wallet outputs (i.e. outputs that the wallet has or had the
* ability to spend).
*
* All outputs are returned except those that do not meet the criteria defined in this query.
*/
struct monero_output_query : public monero_output_wallet {
std::vector<uint32_t> m_subaddress_indices;
boost::optional<uint64_t> m_min_amount;
boost::optional<uint64_t> m_max_amount;
std::shared_ptr<monero_tx_query> m_tx_query;
//boost::property_tree::ptree to_property_tree() const;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_output_query>& output_query);
static std::shared_ptr<monero_output_query> deserialize_from_block(const std::string& output_query_json);
static bool is_contextual(const monero_output_query& query);
std::shared_ptr<monero_output_query> copy(const std::shared_ptr<monero_output>& src, const std::shared_ptr<monero_output>& tgt) const;
std::shared_ptr<monero_output_query> copy(const std::shared_ptr<monero_output_wallet>& src, const std::shared_ptr<monero_output_wallet>& tgt) const; // TODO: necessary to override all super classes?
std::shared_ptr<monero_output_query> copy(const std::shared_ptr<monero_output_query>& src, const std::shared_ptr<monero_output_query>& tgt) const;
bool meets_criteria(monero_output_wallet* output, bool query_parent = true) const;
};
/**
* Models a Monero transaction in the context of a wallet.
*/
struct monero_tx_wallet : public monero_tx {
std::shared_ptr<monero_tx_set> m_tx_set;
boost::optional<bool> m_is_incoming;
boost::optional<bool> m_is_outgoing;
std::vector<std::shared_ptr<monero_incoming_transfer>> m_incoming_transfers;
std::shared_ptr<monero_outgoing_transfer> m_outgoing_transfer;
boost::optional<std::string> m_note;
boost::optional<bool> m_is_locked;
boost::optional<uint64_t> m_input_sum;
boost::optional<uint64_t> m_output_sum;
boost::optional<std::string> m_change_address;
boost::optional<uint64_t> m_change_amount;
boost::optional<uint32_t> m_num_dummy_outputs;
boost::optional<std::string> m_extra_hex;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_tx_wallet>& tx_wallet);
std::shared_ptr<monero_tx_wallet> copy(const std::shared_ptr<monero_tx>& src, const std::shared_ptr<monero_tx>& tgt) const;
std::shared_ptr<monero_tx_wallet> copy(const std::shared_ptr<monero_tx_wallet>& src, const std::shared_ptr<monero_tx_wallet>& tgt) const;
void merge(const std::shared_ptr<monero_tx>& self, const std::shared_ptr<monero_tx>& other);
void merge(const std::shared_ptr<monero_tx_wallet>& self, const std::shared_ptr<monero_tx_wallet>& other);
std::vector<std::shared_ptr<monero_transfer>> get_transfers() const;
std::vector<std::shared_ptr<monero_transfer>> get_transfers(const monero_transfer_query& query) const;
std::vector<std::shared_ptr<monero_transfer>> filter_transfers(const monero_transfer_query& query);
std::vector<std::shared_ptr<monero_output_wallet>> get_outputs_wallet() const;
std::vector<std::shared_ptr<monero_output_wallet>> get_outputs_wallet(const monero_output_query& query) const;
std::vector<std::shared_ptr<monero_output_wallet>> filter_outputs_wallet(const monero_output_query& query);
};
/**
* Configures a query to retrieve transactions.
*
* All transactions are returned except those that do not meet the criteria defined in this query.
*/
struct monero_tx_query : public monero_tx_wallet {
boost::optional<bool> m_is_outgoing;
boost::optional<bool> m_is_incoming;
std::vector<std::string> m_hashes;
boost::optional<bool> m_has_payment_id;
std::vector<std::string> m_payment_ids;
boost::optional<uint64_t> m_height;
boost::optional<uint64_t> m_min_height;
boost::optional<uint64_t> m_max_height;
boost::optional<uint64_t> m_include_outputs;
std::shared_ptr<monero_transfer_query> m_transfer_query;
std::shared_ptr<monero_output_query> m_input_query;
std::shared_ptr<monero_output_query> m_output_query;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_tx_query>& tx_query);
static std::shared_ptr<monero_tx_query> deserialize_from_block(const std::string& tx_query_json);
/**
* Remove query criteria which require looking up other transfers/outputs to
* fulfill query.
*
* @param query the query to decontextualize
* @return a reference to the query for convenience
*/
static std::shared_ptr<monero_tx_query> decontextualize(std::shared_ptr<monero_tx_query> query);
std::shared_ptr<monero_tx_query> copy(const std::shared_ptr<monero_tx>& src, const std::shared_ptr<monero_tx>& tgt) const;
std::shared_ptr<monero_tx_query> copy(const std::shared_ptr<monero_tx_wallet>& src, const std::shared_ptr<monero_tx_wallet>& tgt) const; // TODO: necessary to override all super classes?
std::shared_ptr<monero_tx_query> copy(const std::shared_ptr<monero_tx_query>& src, const std::shared_ptr<monero_tx_query>& tgt) const;
bool meets_criteria(monero_tx_wallet* tx, bool query_children = true) const;
};
/**
* Groups transactions who share common hex data which is needed in order to
* sign and submit the transactions.
*
* For example, multisig transactions created from create_txs() share a common
* hex string which is needed in order to sign and submit the multisig
* transactions.
*/
struct monero_tx_set : public serializable_struct {
std::vector<std::shared_ptr<monero_tx_wallet>> m_txs;
boost::optional<std::string> m_signed_tx_hex;
boost::optional<std::string> m_unsigned_tx_hex;
boost::optional<std::string> m_multisig_tx_hex;
//boost::property_tree::ptree to_property_tree() const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_tx_set>& set);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static monero_tx_set deserialize(const std::string& tx_set_json);
};
/**
* Monero integrated address model.
*/
struct monero_integrated_address : public serializable_struct {
std::string m_standard_address;
std::string m_payment_id;
std::string m_integrated_address;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_integrated_address>& subaddress);
};
/**
* Enumerates Monero network types.
*/
enum monero_tx_priority : uint8_t {
DEFAULT = 0,
UNIMPORTANT,
NORMAL,
ELEVATED
};
/**
* Configures a transaction to send, sweep, or create a payment URI.
*/
struct monero_tx_config : public serializable_struct {
boost::optional<std::string> m_address;
boost::optional<uint64_t> m_amount;
std::vector<std::shared_ptr<monero_destination>> m_destinations;
std::vector<uint32_t> m_subtract_fee_from;
boost::optional<std::string> m_payment_id;
boost::optional<monero_tx_priority> m_priority;
boost::optional<uint32_t> m_ring_size;
boost::optional<uint64_t> m_fee;
boost::optional<uint32_t> m_account_index;
std::vector<uint32_t> m_subaddress_indices;
boost::optional<bool> m_can_split;
boost::optional<bool> m_relay;
boost::optional<std::string> m_note;
boost::optional<std::string> m_recipient_name;
boost::optional<uint64_t> m_below_amount;
boost::optional<bool> m_sweep_each_subaddress;
boost::optional<std::string> m_key_image;
monero_tx_config() {}
monero_tx_config(const monero_tx_config& config);
monero_tx_config copy() const;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static std::shared_ptr<monero_tx_config> deserialize(const std::string& config_json);
std::vector<std::shared_ptr<monero_destination>> get_normalized_destinations() const;
};
/**
* Models results from exporting signed key images.
*/
struct monero_key_image_export_result : public serializable_struct {
boost::optional<uint64_t> m_offset;
std::vector<std::shared_ptr<monero_key_image>> m_key_images;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static std::shared_ptr<monero_key_image_export_result> deserialize(const std::string& result_json);
};
/**
* Models results from importing key images.
*/
struct monero_key_image_import_result : public serializable_struct {
boost::optional<uint64_t> m_height;
boost::optional<uint64_t> m_spent_amount;
boost::optional<uint64_t> m_unspent_amount;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_key_image_import_result>& result);
};
/**
* Enumerates message verification results.
*/
enum monero_message_signature_type : uint8_t {
SIGN_WITH_SPEND_KEY = 0,
SIGN_WITH_VIEW_KEY
};
/**
* Models results from message verification.
*/
struct monero_message_signature_result : public serializable_struct {
bool m_is_good;
uint32_t m_version;
bool m_is_old;
monero_message_signature_type m_signature_type;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_message_signature_result> result);
};
/**
* Base class for results from checking a transaction or reserve proof.
*/
struct monero_check : public serializable_struct {
bool m_is_good;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
};
/**
* Results from checking a transaction key.
*/
struct monero_check_tx : public monero_check {
boost::optional<bool> m_in_tx_pool;
boost::optional<uint64_t> m_num_confirmations;
boost::optional<uint64_t> m_received_amount;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_check_tx>& check);
};
/**
* Results from checking a reserve proof.
*/
struct monero_check_reserve : public monero_check {
boost::optional<uint64_t> m_total_amount;
boost::optional<uint64_t> m_unconfirmed_spent_amount;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_check_reserve>& check);
};
/**
* Models information about a multisig wallet.
*/
struct monero_multisig_info : public serializable_struct {
bool m_is_multisig;
bool m_is_ready;
uint32_t m_threshold;
uint32_t m_num_participants;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_multisig_info>& info);
};
/**
* Models the result of initializing a multisig wallet which results in the
* multisig wallet's address xor another multisig hex to share with
* participants to create the wallet.
*/
struct monero_multisig_init_result : public serializable_struct {
boost::optional<std::string> m_address;
boost::optional<std::string> m_multisig_hex;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_multisig_init_result>& res);
};
/**
* Models the result of signing multisig tx hex.
*/
struct monero_multisig_sign_result : public serializable_struct {
boost::optional<std::string> m_signed_multisig_tx_hex;
std::vector<std::string> m_tx_hashes;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_multisig_sign_result>& res);
};
/**
* Monero address book entry model.
*/
struct monero_address_book_entry : public serializable_struct {
boost::optional<uint64_t> m_index; // TODO: not boost::optional
boost::optional<std::string> m_address;
boost::optional<std::string> m_description;
boost::optional<std::string> m_payment_id;
monero_address_book_entry() {}
monero_address_book_entry(uint64_t index, const std::string& address, const std::string& description) : m_index(index), m_address(address), m_description(description) {}
monero_address_book_entry(uint64_t index, const std::string& address, const std::string& description, const std::string& payment_id) : m_index(index), m_address(address), m_description(description), m_payment_id(payment_id) {}
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_address_book_entry>& entry);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const;
};
/**
* Models a Monero address type.
*/
enum monero_address_type : uint8_t {
PRIMARY_ADDRESS = 0,
INTEGRATED_ADDRESS,
SUBADDRESS
};
/**
* Compares two transactions by their height.
*/
struct monero_tx_height_comparator {
bool operator()(const std::shared_ptr<monero_tx>& tx1, const std::shared_ptr<monero_tx>& tx2) const;
};
/**
* Compares two transfers by ascending account and subaddress indices.
*/
struct monero_incoming_transfer_comparator {
bool operator()(const std::shared_ptr<monero_incoming_transfer>& t1, const std::shared_ptr<monero_incoming_transfer>& t2) const;
bool operator()(const monero_incoming_transfer& t1, const monero_incoming_transfer& t2) const;
};
/**
* Compares two outputs by ascending account and subaddress indices.
*/
struct monero_output_comparator {
bool operator()(const monero_output_wallet& o1, const monero_output_wallet& o2) const;
};
/**
* Maintains metadata for a decoded address.
*/
struct monero_decoded_address : public serializable_struct {
std::string m_address;
monero_address_type m_address_type;
monero_network_type m_network_type;
monero_decoded_address(const std::string& address, monero_address_type address_type, monero_network_type network_type);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
/**
* Represents an account tag.
*/
struct monero_account_tag : public serializable_struct {
boost::optional<std::string> m_tag;
boost::optional<std::string> m_label;
std::vector<uint32_t> m_account_indices;
monero_account_tag() { }
monero_account_tag(const std::string& tag, const std::string& label): m_tag(tag), m_label(label) { }
monero_account_tag(const std::string& tag, const std::string& label, const std::vector<uint32_t>& account_indices): m_tag(tag), m_label(label), m_account_indices(account_indices) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_account_tag>& account_tag);
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,282 @@
/**
* Copyright (c) everoddandeven
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2025-2026 woodser
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#pragma once
#include "monero_wallet.h"
/**
* Implements a monero_wallet.h by wrapping monero-project's monero-wallet-rpc.
*/
namespace monero {
// forward declaration of internal wallet poller
class monero_wallet_poller;
/**
* Implements a Monero wallet using monero-wallet-rpc.
*/
class monero_wallet_rpc : public monero_wallet {
public:
/**
* Destruct the wallet.
*/
~monero_wallet_rpc();
monero_wallet_rpc(const std::shared_ptr<monero_rpc_connection>& rpc_connection);
monero_wallet_rpc(const std::string& uri = "", const std::string& username = "", const std::string& password = "", const std::string& proxy_uri = "", const std::string& zmq_uri = "", const boost::optional<uint32_t>& timeout = boost::none);
/**
* Set the wallet poll period.
*
* @param period_ms poll period in milliseconds.
*/
void set_poll_period_in_ms(uint64_t period_ms);
/**
* Open an existing wallet from rpc server.
*
* @param config is the wallet configuration
* @return a pointer to the wallet instance
*/
monero_wallet_rpc* open_wallet(const std::shared_ptr<monero_wallet_config> &config);
/**
* Open an existing wallet from rpc server.
*
* @param name is the wallet's name to open
* @param password is the password of the wallet file to open
* @return a pointer to the wallet instance
*/
monero_wallet_rpc* open_wallet(const std::string& name, const std::string& password);
/**
* Create a new wallet with the given configuration.
*
* @param config is the wallet configuration
* @return a pointer to the wallet instance
*/
monero_wallet_rpc* create_wallet(const std::shared_ptr<monero_wallet_config> &config);
/**
* Get the wallet's RPC connection.
*
* @return the wallet's rpc connection
*/
std::shared_ptr<monero_rpc_connection> get_rpc_connection() const { return m_rpc; }
/**
* Get a list of available languages for the wallet's seed.
*
* @return the available languages for the wallet's seed
*/
std::vector<std::string> get_seed_languages() const;
/**
* Save and close the current wallet and stop the RPC server.
*/
void stop();
/**
* Supported wallet methods.
*/
void add_listener(monero_wallet_listener& listener) override;
void remove_listener(monero_wallet_listener& listener) override;
std::set<monero_wallet_listener*> get_listeners() override;
bool is_view_only() const override;
std::shared_ptr<monero_rpc_connection> get_daemon_connection() const override;
void set_daemon_connection(const std::shared_ptr<monero_rpc_connection>& connection, bool is_trusted, const boost::optional<ssl_options>& ssl_options);
void set_daemon_connection(const std::shared_ptr<monero_rpc_connection>& connection, const boost::optional<bool>& is_trusted = boost::none) override;
void set_daemon_connection(const std::string& uri, const std::string& username = "", const std::string& password = "", const std::string& proxy_uri = "", const boost::optional<bool>& is_trusted = boost::none) override;
bool is_connected_to_daemon() const override;
monero_version get_version() const override;
std::string get_path() const override;
std::string get_seed() const override;
std::string get_seed_language() const override;
std::string get_public_view_key() const override;
std::string get_private_view_key() const override;
std::string get_public_spend_key() const override;
std::string get_private_spend_key() const override;
std::string get_address(const uint32_t account_idx, const uint32_t subaddress_idx) const override;
monero_subaddress get_address_index(const std::string& address) const override;
monero_integrated_address get_integrated_address(const std::string& standard_address = "", const std::string& payment_id = "") const override;
monero_integrated_address decode_integrated_address(const std::string& integrated_address) const override;
uint64_t get_height() const override;
uint64_t get_daemon_height() const override;
uint64_t get_height_by_date(uint16_t year, uint8_t month, uint8_t day) const override;
monero_sync_result sync() override;
monero_sync_result sync(monero_wallet_listener& listener) override;
monero_sync_result sync(uint64_t start_height, monero_wallet_listener& listener) override;
monero_sync_result sync(uint64_t start_height) override;
void start_syncing(uint64_t sync_period_in_ms = 10000) override;
void stop_syncing() override;
void scan_txs(const std::vector<std::string>& tx_hashes) override;
void rescan_spent() override;
void rescan_blockchain() override;
uint64_t get_balance() const override;
uint64_t get_balance(uint32_t account_index) const override;
uint64_t get_balance(uint32_t account_idx, uint32_t subaddress_idx) const override;
uint64_t get_unlocked_balance() const override;
uint64_t get_unlocked_balance(uint32_t account_index) const override;
uint64_t get_unlocked_balance(uint32_t account_idx, uint32_t subaddress_idx) const override;
monero_account get_account(const uint32_t account_idx, bool include_subaddresses) const override;
monero_account get_account(const uint32_t account_idx, bool include_subaddresses, bool skip_balances) const;
std::vector<monero_account> get_accounts(bool include_subaddresses, const std::string& tag) const override;
std::vector<monero_account> get_accounts(bool include_subaddresses, const std::string& tag, bool skip_balances) const;
monero_account create_account(const std::string& label = "") override;
std::vector<monero_subaddress> get_subaddresses(const uint32_t account_idx, const std::vector<uint32_t>& subaddress_indices, bool skip_balances) const;
std::vector<monero_subaddress> get_subaddresses(uint32_t account_idx, const std::vector<uint32_t>& subaddress_indices) const override;
std::vector<monero_subaddress> get_subaddresses(const uint32_t account_idx) const override;
monero_subaddress get_subaddress(const uint32_t account_idx, const uint32_t subaddress_idx) const override;
monero_subaddress create_subaddress(uint32_t account_idx, const std::string& label = "") override;
void set_subaddress_label(uint32_t account_idx, uint32_t subaddress_idx, const std::string& label = "") override;
std::vector<std::shared_ptr<monero_tx_wallet>> get_txs() const override;
std::vector<std::shared_ptr<monero_tx_wallet>> get_txs(const monero_tx_query& query) const override;
std::vector<std::shared_ptr<monero_transfer>> get_transfers(const monero_transfer_query& query) const override;
std::vector<std::shared_ptr<monero_output_wallet>> get_outputs(const monero_output_query& query) const override;
std::string export_outputs(bool all = false) const override;
int import_outputs(const std::string& outputs_hex) override;
std::shared_ptr<monero_key_image_export_result> export_key_images(bool all = false) const override;
std::shared_ptr<monero_key_image_import_result> import_key_images(const std::vector<std::shared_ptr<monero_key_image>>& key_images, uint64_t offset = 0) override;
void freeze_output(const std::string& key_image) override;
void thaw_output(const std::string& key_image) override;
bool is_output_frozen(const std::string& key_image) override;
monero_tx_priority get_default_fee_priority() const override;
std::vector<std::shared_ptr<monero_tx_wallet>> create_txs(const monero_tx_config& conf) override;
std::shared_ptr<monero_tx_wallet> sweep_output(const monero_tx_config& config) override;
std::vector<std::shared_ptr<monero_tx_wallet>> sweep_dust(bool relay = false) override;
std::vector<std::shared_ptr<monero_tx_wallet>> sweep_unlocked(const monero_tx_config& config) override;
std::vector<std::string> relay_txs(const std::vector<std::string>& tx_metadatas) override;
monero_tx_set describe_tx_set(const monero_tx_set& tx_set) override;
monero_tx_set sign_txs(const std::string& unsigned_tx_hex) override;
std::vector<std::string> submit_txs(const std::string& signed_tx_hex) override;
std::string sign_message(const std::string& msg, monero_message_signature_type signature_type, uint32_t account_idx = 0, uint32_t subaddress_idx = 0) const override;
monero_message_signature_result verify_message(const std::string& msg, const std::string& address, const std::string& signature) const override;
std::string get_tx_key(const std::string& tx_hash) const override;
std::shared_ptr<monero_check_tx> check_tx_key(const std::string& tx_hash, const std::string& tx_key, const std::string& address) const override;
std::string get_tx_proof(const std::string& tx_hash, const std::string& address, const std::string& message) const override;
std::shared_ptr<monero_check_tx> check_tx_proof(const std::string& tx_hash, const std::string& address, const std::string& message, const std::string& signature) const override;
std::string get_spend_proof(const std::string& tx_hash, const std::string& message) const override;
bool check_spend_proof(const std::string& tx_hash, const std::string& message, const std::string& signature) const override;
std::string get_reserve_proof_wallet(const std::string& message) const override;
std::string get_reserve_proof_account(uint32_t account_idx, uint64_t amount, const std::string& message) const override;
std::shared_ptr<monero_check_reserve> check_reserve_proof(const std::string& address, const std::string& message, const std::string& signature) const override;
std::string get_tx_note(const std::string& tx_hash) const override;
std::vector<std::string> get_tx_notes(const std::vector<std::string>& tx_hashes) const override;
void set_tx_note(const std::string& tx_hashes, const std::string& notes) override;
void set_tx_notes(const std::vector<std::string>& tx_hashes, const std::vector<std::string>& notes) override;
std::vector<monero_address_book_entry> get_address_book_entries(const std::vector<uint64_t>& indices) const override;
uint64_t add_address_book_entry(const std::string& address, const std::string& description) override;
void edit_address_book_entry(uint64_t index, bool set_address, const std::string& address, bool set_description, const std::string& description) override;
void delete_address_book_entry(uint64_t index) override;
void tag_accounts(const std::string& tag, const std::vector<uint32_t>& account_indices) override;
void untag_accounts(const std::vector<uint32_t>& account_indices) override;
std::vector<std::shared_ptr<monero_account_tag>> get_account_tags() const override;
void set_account_tag_label(const std::string& tag, const std::string& label) override;
std::string get_payment_uri(const monero_tx_config& config) const override;
std::shared_ptr<monero_tx_config> parse_payment_uri(const std::string& uri) const override;
void set_attribute(const std::string& key, const std::string& val) override;
bool get_attribute(const std::string& key, std::string& value) const override;
void start_mining(boost::optional<uint64_t> num_threads, boost::optional<bool> background_mining, boost::optional<bool> ignore_battery) override;
void stop_mining() override;
bool is_multisig_import_needed() const override;
monero_multisig_info get_multisig_info() const override;
std::string prepare_multisig() override;
std::string make_multisig(const std::vector<std::string>& multisig_hexes, int threshold, const std::string& password) override;
monero_multisig_init_result exchange_multisig_keys(const std::vector<std::string>& multisig_hexes, const std::string& password) override;
std::string export_multisig_hex() override;
int import_multisig_hex(const std::vector<std::string>& multisig_hexes, const bool refresh_after_import = true) override;
monero_multisig_sign_result sign_multisig_tx_hex(const std::string& multisig_tx_hex) override;
std::vector<std::string> submit_multisig_tx_hex(const std::string& signed_multisig_tx_hex) override;
void change_password(const std::string& old_password, const std::string& new_password) override;
void save() override;
bool is_closed() const override;
void close(bool save = false) override;
std::shared_ptr<monero_subaddress> get_balances(const boost::optional<uint32_t>& account_idx, const boost::optional<uint32_t>& subaddress_idx) const;
// --------------------------------- PRIVATE --------------------------------
private:
friend class monero_wallet_poller;
boost::optional<uint64_t> m_sync_period_in_ms;
std::string m_path = "";
std::shared_ptr<monero_rpc_connection> m_rpc;
std::shared_ptr<monero_rpc_connection> m_daemon_connection;
mutable boost::recursive_mutex m_listeners_mutex;
std::set<monero_wallet_listener*> m_listeners;
mutable boost::recursive_mutex m_sync_mutex;
mutable boost::mutex m_address_cache_mutex;
mutable std::unordered_map<uint32_t, std::unordered_map<uint32_t, std::string>> m_address_cache;
boost::mutex m_poller_mutex;
std::unique_ptr<monero_wallet_poller> m_poller;
monero_wallet_rpc* create_wallet_random(const std::shared_ptr<monero_wallet_config> &config);
monero_wallet_rpc* create_wallet_from_seed(const std::shared_ptr<monero_wallet_config> &config);
monero_wallet_rpc* create_wallet_from_keys(const std::shared_ptr<monero_wallet_config> &config);
monero_sync_result refresh(const std::shared_ptr<serializable_struct>& params);
std::map<uint32_t, std::vector<uint32_t>> get_account_indices(bool get_subaddress_indices) const;
std::vector<uint32_t> get_subaddress_indices(uint32_t account_idx) const;
std::vector<std::shared_ptr<monero_output_wallet>> get_outputs_aux(const monero_output_query& query) const;
std::vector<std::shared_ptr<monero_transfer>> get_transfers_aux(const monero_transfer_query& query) const;
std::string query_key(const std::string& key_type) const;
std::vector<std::shared_ptr<monero_tx_wallet>> sweep_account(const monero_tx_config &conf);
void clear_address_cache();
void refresh_listening();
void wait_for_listener_callbacks_idle();
void poll();
void clear();
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,500 @@
/**
* Copyright (c) everoddandeven
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Parts of this file are originally copyright (c) 2025-2026 woodser
*
* Parts of this file are originally copyright (c) 2014-2019, The Monero Project
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* All rights reserved.
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be
* used to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Parts of this file are originally copyright (c) 2012-2013 The Cryptonote developers
*/
#pragma once
#include "daemon/monero_daemon_rpc_model.h"
#include "monero_wallet_model.h"
/**
* Internal data model for monero_wallet_rpc.
*/
namespace monero {
/**
* key-value struct which can be serialized.
*/
struct key_value : public serializable_struct {
boost::optional<std::string> m_key;
boost::optional<std::string> m_value;
key_value() { }
key_value(const std::string& key): m_key(key) { }
key_value(const std::string& key, const std::string& value): m_key(key), m_value(value) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<key_value>& attributes);
};
/**
* Models a RPC Monero key image.
*/
struct monero_rpc_key_image : public monero_key_image {
monero_rpc_key_image(const std::shared_ptr<monero_key_image> &key_image);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
// ------------------------------ JSON-RPC Params ---------------------------------
struct monero_query_key_params : public serializable_struct {
boost::optional<std::string> m_key_type;
monero_query_key_params(const std::string& key_type): m_key_type(key_type) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_query_output_params : public serializable_struct {
boost::optional<std::string> m_key_image;
monero_query_output_params(const std::string& key_image): m_key_image(key_image) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_get_address_params : public serializable_struct {
boost::optional<std::string> m_address;
boost::optional<uint32_t> m_account_index;
std::vector<uint32_t> m_subaddress_indices;
monero_get_address_params(const std::string& address): m_address(address) { }
monero_get_address_params(uint32_t account_index): m_account_index(account_index) { }
monero_get_address_params(uint32_t account_index, const std::vector<uint32_t>& subaddress_indices): m_account_index(account_index), m_subaddress_indices(subaddress_indices) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_integrated_address_params : public serializable_struct {
boost::optional<std::string> m_integrated_address;
boost::optional<std::string> m_standard_address;
boost::optional<std::string> m_payment_id;
monero_integrated_address_params(const std::string& integrated_address): m_integrated_address(integrated_address) {}
monero_integrated_address_params(const std::string& standard_address, const std::string& payment_id): m_standard_address(standard_address), m_payment_id(payment_id) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_multisig_params : public serializable_struct {
// TODO monero-docs document this parameter
boost::optional<bool> m_enable_multisig_experimental;
boost::optional<bool> m_refresh_after_import;
std::vector<std::string> m_multisig_info;
std::vector<std::string> m_multisig_hexes;
boost::optional<std::string> m_multisig_tx_hex;
boost::optional<int> m_threshold;
boost::optional<std::string> m_password;
monero_multisig_params(bool enable_multisig_experimental = true): m_enable_multisig_experimental(enable_multisig_experimental) { }
monero_multisig_params(const std::vector<std::string>& multisig_hexes, const std::string& password): m_multisig_info(multisig_hexes), m_password(password) { }
monero_multisig_params(const std::vector<std::string>& multisig_hexes, const char* password): monero_multisig_params(multisig_hexes, std::string(password)) { }
monero_multisig_params(const std::vector<std::string>& multisig_hexes, int threshold, const std::string& password): m_multisig_info(multisig_hexes), m_threshold(threshold), m_password(password) { }
monero_multisig_params(const std::vector<std::string>& multisig_hexes, const bool refresh_after_import): m_multisig_hexes(multisig_hexes), m_refresh_after_import(refresh_after_import) { }
monero_multisig_params(const std::string& multisig_tx_hex): m_multisig_tx_hex(multisig_tx_hex) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_payment_uri_params : public serializable_struct {
boost::optional<std::string> m_uri;
boost::optional<std::string> m_address;
boost::optional<uint64_t> m_amount;
boost::optional<std::string> m_payment_id;
boost::optional<std::string> m_recipient_name;
boost::optional<std::string> m_tx_description;
monero_payment_uri_params() { }
monero_payment_uri_params(const std::string& uri): m_uri(uri) { }
monero_payment_uri_params(const monero_tx_config& config);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_get_balance_params : public serializable_struct {
boost::optional<uint32_t> m_account_idx;
std::vector<uint32_t> m_address_indices;
boost::optional<bool> m_all_accounts;
boost::optional<bool> m_strict;
monero_get_balance_params(bool all_accounts, bool strict = false): m_all_accounts(all_accounts), m_strict(strict) { }
monero_get_balance_params(uint32_t account_idx, const std::vector<uint32_t>& address_indices, bool all_accounts = false, bool strict = false): m_account_idx(account_idx), m_address_indices(address_indices), m_all_accounts(all_accounts), m_strict(strict) { }
monero_get_balance_params(uint32_t account_idx, boost::optional<uint32_t> address_idx, bool all_accounts = false, bool strict = false);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_close_wallet_params : public serializable_struct {
boost::optional<bool> m_save;
monero_close_wallet_params(bool save = false): m_save(save) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_change_wallet_password_params : public serializable_struct {
boost::optional<std::string> m_old_password;
boost::optional<std::string> m_new_password;
monero_change_wallet_password_params(const std::string& old_password, const std::string& new_password): m_old_password(old_password), m_new_password(new_password) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_set_daemon_params : public serializable_struct {
boost::optional<std::string> m_address;
boost::optional<std::string> m_username;
boost::optional<std::string> m_password;
boost::optional<std::string> m_proxy;
boost::optional<bool> m_trusted;
boost::optional<std::string> m_ssl_support;
boost::optional<ssl_options> m_ssl_options;
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_account_tag_params : public serializable_struct {
std::vector<uint32_t> m_account_indices;
boost::optional<std::string> m_tag;
boost::optional<std::string> m_label;
boost::optional<std::string> m_description;
monero_account_tag_params() {}
monero_account_tag_params(const std::string& tag, const std::string& description = ""): m_tag(tag), m_description(description) { }
monero_account_tag_params(const std::vector<uint32_t>& account_indices): m_account_indices(account_indices) { }
monero_account_tag_params(const std::string& tag, const std::vector<uint32_t>& account_indices): m_tag(tag), m_account_indices(account_indices) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_tx_notes_params : public serializable_struct {
std::vector<std::string> m_tx_hashes;
std::vector<std::string> m_notes;
monero_tx_notes_params(const std::vector<std::string>& tx_hashes): m_tx_hashes(tx_hashes) { }
monero_tx_notes_params(const std::vector<std::string>& tx_hashes, const std::vector<std::string>& notes): m_tx_hashes(tx_hashes), m_notes(notes) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_address_book_entry_params : public serializable_struct {
boost::optional<uint64_t> m_index; // TODO: not boost::optional
boost::optional<bool> m_set_address;
boost::optional<std::string> m_address;
boost::optional<bool> m_set_description;
boost::optional<std::string> m_description;
std::vector<uint64_t> m_entries;
monero_address_book_entry_params(uint64_t index): m_index(index) { }
monero_address_book_entry_params(const std::vector<uint64_t>& entries): m_entries(entries) { }
monero_address_book_entry_params(uint64_t index, bool set_address, const std::string& address, bool set_description, const std::string& description): m_index(index), m_set_address(set_address), m_address(address), m_set_description(set_description), m_description(description) { }
monero_address_book_entry_params(const std::string& address, const std::string& description): m_address(address), m_description(description) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_verify_sign_message_params : public serializable_struct {
boost::optional<std::string> m_data;
boost::optional<std::string> m_address;
boost::optional<std::string> m_signature;
boost::optional<monero_message_signature_type> m_signature_type;
boost::optional<uint32_t> m_account_index;
boost::optional<uint32_t> m_address_index;
monero_verify_sign_message_params(const std::string &data, const std::string &address, const std::string& signature): m_data(data), m_address(address), m_signature(signature) { }
monero_verify_sign_message_params(const std::string &data, monero_message_signature_type signature_type, uint32_t account_index, uint32_t address_index): m_data(data), m_signature_type(signature_type), m_account_index(account_index), m_address_index(address_index) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_check_tx_key_params : public serializable_struct {
boost::optional<std::string> m_tx_hash;
boost::optional<std::string> m_address;
boost::optional<std::string> m_tx_key;
monero_check_tx_key_params(const std::string &tx_hash): m_tx_hash(tx_hash) { }
monero_check_tx_key_params(const std::string &tx_hash, const std::string &tx_key, const std::string &address): m_tx_hash(tx_hash), m_tx_key(tx_key), m_address(address) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_sign_describe_transfer_params : public serializable_struct {
boost::optional<std::string> m_unsigned_txset;
boost::optional<std::string> m_multisig_txset;
boost::optional<bool> m_export_raw;
boost::optional<bool> m_get_tx_keys;
monero_sign_describe_transfer_params() { }
monero_sign_describe_transfer_params(const std::string &unsigned_txset) : m_unsigned_txset(unsigned_txset) { }
monero_sign_describe_transfer_params(const std::string &unsigned_txset, const std::string &multisig_txset) : m_unsigned_txset(unsigned_txset), m_multisig_txset(multisig_txset) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_wallet_relay_tx_params : public serializable_struct {
boost::optional<std::string> m_hex;
boost::optional<std::string> m_signed_tx_hex;
monero_wallet_relay_tx_params() {}
monero_wallet_relay_tx_params(const std::string &hex): m_hex(hex) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_sweep_params : public serializable_struct {
boost::optional<std::string> m_address;
boost::optional<uint32_t> m_account_index;
std::vector<uint32_t> m_subaddr_indices;
boost::optional<std::string> m_key_image;
boost::optional<bool> m_relay;
boost::optional<monero_tx_priority> m_priority;
boost::optional<std::string> m_payment_id;
boost::optional<uint64_t> m_below_amount;
boost::optional<bool> m_get_tx_key;
boost::optional<bool> m_get_tx_keys;
boost::optional<bool> m_get_tx_hex;
boost::optional<bool> m_get_tx_metadata;
monero_sweep_params(bool relay = false): m_relay(relay) { }
monero_sweep_params(const monero_tx_config& config);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_create_edit_subaddress_params : public serializable_struct {
boost::optional<std::string> m_label;
boost::optional<uint32_t> m_account_index;
boost::optional<uint32_t> m_subaddress_index;
monero_create_edit_subaddress_params(uint32_t account_idx, const std::string& label): m_account_index(account_idx), m_label(label) { }
monero_create_edit_subaddress_params(uint32_t account_idx, uint32_t subaddress_idx, const std::string& label): m_account_index(account_idx), m_subaddress_index(subaddress_idx), m_label(label) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_wallet_data_params : public serializable_struct {
boost::optional<bool> m_all;
std::vector<std::shared_ptr<monero_key_image>> m_key_images;
boost::optional<uint64_t> m_offset;
boost::optional<std::string> m_outputs_hex;
monero_wallet_data_params(const std::vector<std::shared_ptr<monero_key_image>> &key_images, uint64_t offset): m_key_images(key_images), m_offset(offset) { };
monero_wallet_data_params(bool all): m_all(all) { }
monero_wallet_data_params(const std::string& outputs_hex): m_outputs_hex(outputs_hex) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_create_open_wallet_params : public serializable_struct {
boost::optional<std::string> m_filename;
boost::optional<std::string> m_password;
boost::optional<std::string> m_language;
boost::optional<std::string> m_seed;
boost::optional<std::string> m_seed_offset;
boost::optional<uint64_t> m_restore_height;
boost::optional<bool> m_autosave_current;
boost::optional<bool> m_enable_multisig_experimental;
boost::optional<std::string> m_address;
boost::optional<std::string> m_view_key;
boost::optional<std::string> m_spend_key;
monero_create_open_wallet_params(const boost::optional<std::string>& filename, const boost::optional<std::string> &password): m_filename(filename), m_password(password), m_autosave_current(false) { }
monero_create_open_wallet_params(const boost::optional<std::string>& filename, const boost::optional<std::string> &password, const boost::optional<std::string> &language): m_filename(filename), m_password(password), m_language(language), m_autosave_current(false) { }
monero_create_open_wallet_params(const boost::optional<std::string>& filename, const boost::optional<std::string> &password, const boost::optional<std::string> &seed, const boost::optional<std::string> &seed_offset, const boost::optional<uint64_t> &restore_height, const boost::optional<std::string> &language, const boost::optional<bool> &autosave_current, const boost::optional<bool> &enable_multisig_experimental): m_filename(filename), m_password(password), m_seed(seed), m_seed_offset(seed_offset), m_restore_height(restore_height), m_language(language), m_autosave_current(autosave_current), m_enable_multisig_experimental(enable_multisig_experimental) { }
monero_create_open_wallet_params(const boost::optional<std::string>& filename, const boost::optional<std::string> &password, const boost::optional<std::string> &address, const boost::optional<std::string> &view_key, const boost::optional<std::string> &spend_key, const boost::optional<uint64_t> &restore_height, const boost::optional<bool> &autosave_current): m_filename(filename), m_password(password), m_address(address), m_view_key(view_key), m_spend_key(spend_key), m_restore_height(restore_height), m_autosave_current(autosave_current) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_reserve_proof_params : public serializable_struct {
boost::optional<bool> m_all;
boost::optional<std::string> m_message;
boost::optional<std::string> m_tx_hash;
boost::optional<uint32_t> m_account_index;
boost::optional<uint64_t> m_amount;
boost::optional<std::string> m_address;
boost::optional<std::string> m_signature;
monero_reserve_proof_params(const std::string &message, bool all = true): m_all(all), m_message(message) { }
monero_reserve_proof_params(const std::string &address, const std::string &message, const std::string &signature): m_address(address), m_message(message), m_signature(signature) { }
monero_reserve_proof_params(const std::string &tx_hash, const std::string &address, const std::string &message, const std::string &signature): m_tx_hash(tx_hash), m_address(address), m_message(message), m_signature(signature) { }
monero_reserve_proof_params(const std::string &tx_hash, const std::string &message): m_tx_hash(tx_hash), m_message(message) { }
monero_reserve_proof_params(uint32_t account_index, uint64_t amount, const std::string &message): m_account_index(account_index), m_amount(amount), m_message(message) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_wallet_refresh_params : public serializable_struct {
boost::optional<bool> m_enable;
boost::optional<uint64_t> m_period;
boost::optional<uint64_t> m_start_height;
monero_wallet_refresh_params() { }
monero_wallet_refresh_params(bool enable): m_enable(enable) { }
monero_wallet_refresh_params(bool enable, uint64_t period): m_enable(enable), m_period(period) { }
monero_wallet_refresh_params(uint64_t start_height): m_start_height(start_height) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_transfer_params : public serializable_struct {
std::vector<uint32_t> m_subtract_fee_from_outputs;
boost::optional<uint32_t> m_account_index;
std::vector<uint32_t> m_subaddress_indices;
boost::optional<std::string> m_payment_id;
boost::optional<bool> m_do_not_relay;
boost::optional<int> m_priority;
boost::optional<bool> m_get_tx_hex;
boost::optional<bool> m_get_tx_metadata;
boost::optional<bool> m_get_tx_keys;
boost::optional<bool> m_get_tx_key;
std::vector<std::shared_ptr<monero_destination>> m_destinations;
monero_transfer_params(const monero_tx_config &config);
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_get_transfers_params : public serializable_struct {
boost::optional<bool> m_in;
boost::optional<bool> m_out;
boost::optional<bool> m_pool;
boost::optional<bool> m_pending;
boost::optional<bool> m_failed;
boost::optional<uint64_t> m_min_height;
boost::optional<uint64_t> m_max_height;
boost::optional<bool> m_all_accounts;
boost::optional<uint32_t> m_account_index;
std::vector<uint32_t> m_subaddr_indices;
bool filter_by_height() const { return m_min_height != boost::none || m_max_height != boost::none; }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
struct monero_get_incoming_transfers_params : public serializable_struct {
boost::optional<std::string> m_transfer_type;
boost::optional<bool> m_verbose;
boost::optional<uint32_t> m_account_index;
std::vector<uint32_t> m_subaddr_indices;
monero_get_incoming_transfers_params(const std::string& transfer_type, bool verbose = true): m_transfer_type(transfer_type), m_verbose(verbose) { }
rapidjson::Value to_rapidjson_val(rapidjson::Document::AllocatorType& allocator) const override;
};
// ------------------------------ JSON-RPC Response ---------------------------------
struct monero_multisig_response {
boost::optional<int> m_num_outputs;
boost::optional<std::string> m_multisig_info;
std::vector<std::string> m_tx_hashes;
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_multisig_response>& response);
};
struct monero_get_balance_response {
boost::optional<uint64_t> m_balance;
boost::optional<uint64_t> m_unlocked_balance;
boost::optional<bool> m_multisig_import_needed;
boost::optional<uint64_t> m_time_to_unlock;
boost::optional<uint64_t> m_blocks_to_unlock;
std::vector<std::shared_ptr<monero_subaddress>> m_per_subaddress;
monero_get_balance_response(): m_balance(0), m_unlocked_balance(0) { }
static void from_property_tree(const boost::property_tree::ptree& node, const std::shared_ptr<monero_get_balance_response>& response);
};
// ------------------------------ RPC Deserialization ---------------------------------
void deserialize_subaddress(const boost::property_tree::ptree& node, const std::shared_ptr<monero_subaddress>& subaddress);
void deserialize_subaddresses(const boost::property_tree::ptree& node, std::vector<std::shared_ptr<monero_subaddress>>& subaddresses);
void deserialize_account(const boost::property_tree::ptree& node, monero_account& account);
void deserialize_accounts(const boost::property_tree::ptree& node, std::vector<monero_account>& accounts);
void init_sent_tx(const monero_tx_config &config, std::shared_ptr<monero_tx_wallet> &tx, bool copy_destinations);
void deserialize_tx_with_transfer_and_merge(const boost::property_tree::ptree& node, std::map<std::string, std::shared_ptr<monero_tx_wallet>>& tx_map, std::map<uint64_t, std::shared_ptr<monero_block>>& block_map);
void deserialize_tx_with_output_and_merge(const boost::property_tree::ptree& node, std::map<std::string, std::shared_ptr<monero_tx_wallet>>& tx_map, std::map<uint64_t, std::shared_ptr<monero_block>>& block_map);
void deserialize_tx_set(const boost::property_tree::ptree& node, const std::shared_ptr<monero_tx_set>& set);
void deserialize_tx_set(const boost::property_tree::ptree& node, const std::shared_ptr<monero_tx_set>& set, const std::shared_ptr<monero_tx_wallet> &tx, bool is_outgoing, const monero_tx_config &config);
void deserialize_sent_tx_set(const boost::property_tree::ptree& node, const std::shared_ptr<monero_tx_set>& set);
void deserialize_sent_tx_set(const boost::property_tree::ptree& node, const std::shared_ptr<monero_tx_set>& set, std::vector<std::shared_ptr<monero_tx_wallet>> &txs, const boost::optional<monero_tx_config> &conf);
void deserialize_described_tx_set(const boost::property_tree::ptree& node, const std::shared_ptr<monero_tx_set>& set);
void deserialize_submitted_tx_hashes(const boost::property_tree::ptree& node, std::vector<std::string>& tx_hashes);
void deserialize_relayed_tx_hash(const boost::property_tree::ptree& node, std::vector<std::string>& tx_hashes);
int deserialize_num_created_txs(const boost::property_tree::ptree& node, bool can_split);
std::string deserialize_tx_key(const boost::property_tree::ptree& node);
void deserialize_tx_notes(const boost::property_tree::ptree& node, std::vector<std::string>& tx_notes);
void deserialize_integrated_address(const boost::property_tree::ptree& node, monero_integrated_address& subaddress);
void deserialize_key_image_export_result(const boost::property_tree::ptree& node, const std::shared_ptr<monero_key_image_export_result>& result);
void deserialize_key_image_import_result(const boost::property_tree::ptree& node, const std::shared_ptr<monero_key_image_import_result>& result);
void deserialize_message_signature_result(const boost::property_tree::ptree& node, monero_message_signature_result& result);
void deserialize_check_tx(const boost::property_tree::ptree& node, const std::shared_ptr<monero_check_tx>& check);
void deserialize_check_reserve(const boost::property_tree::ptree& node, const std::shared_ptr<monero_check_reserve>& check);
void deserialize_multisig_info(const boost::property_tree::ptree& node, monero_multisig_info& info);
void deserialize_multisig_init_result(const boost::property_tree::ptree& node, monero_multisig_init_result& res);
void deserialize_multisig_sign_result(const boost::property_tree::ptree& node, monero_multisig_sign_result& res);
void deserialize_address_book_entries(const boost::property_tree::ptree& node, std::vector<monero_address_book_entry>& entries);
void deserialize_seed_languages(const boost::property_tree::ptree& node, std::vector<std::string>& languages);
uint64_t deserialize_address_book_index(const boost::property_tree::ptree& node);
void deserialize_account_tags(const boost::property_tree::ptree& node, std::vector<std::shared_ptr<monero_account_tag>>& account_tags);
std::string deserialize_signature(const boost::property_tree::ptree& node);
std::string deserialize_exported_outputs(const boost::property_tree::ptree& node);
uint64_t deserialize_block_height(const boost::property_tree::ptree& node);
int deserialize_num_imported_outputs(const boost::property_tree::ptree& node);
bool deserialize_frozen_output_info(const boost::property_tree::ptree& node);
monero_tx_priority deserialize_tx_priority(const boost::property_tree::ptree& node);
std::string deserialize_payment_uri(const boost::property_tree::ptree& node);
void deserialize_payment_uri(const boost::property_tree::ptree& node, const std::shared_ptr<monero_tx_config>& tx_config);
void deserialize_sync_result(const boost::property_tree::ptree& node, monero_sync_result& sync_result);
}
Binary file not shown.
Binary file not shown.
+385
View File
@@ -0,0 +1,385 @@
import Foundation
import os
/// On-device verification against vectors generated from the reference
/// implementation (xrpl.js + ripple-keypairs + ripple-binary-codec). Runs only
/// when the app is launched with `-koinsSelfTest`.
enum SelfTest {
private static let log = OSLog(subsystem: "com.koins.app", category: "selftest")
/// Writes to both stdout and the unified log so results are visible however
/// the app was launched.
private static var report: [String] = []
private static func emit(_ line: String) {
print(line)
os_log("%{public}@", log: log, type: .default, line)
report.append(line)
}
/// Writes the collected lines into the app container so the result can be
/// read back off the device (console output is not always reachable).
private static func flushReport() {
guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
let url = documents.appendingPathComponent("koins-selftest.txt")
try? report.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8)
}
static func runIfRequested() {
if ProcessInfo.processInfo.arguments.contains("-koinsSelfTest") {
emit("KOINS_SELFTEST begin")
run()
emit("KOINS_SELFTEST end")
}
if ProcessInfo.processInfo.arguments.contains("-koinsSelfTestNetwork") {
runNetwork()
}
if ProcessInfo.processInfo.arguments.contains("-koinsSelfTestSwap") {
runSwapCheckIfRequested()
}
}
/// End-to-end check of the live network paths (XRP RPC + native Monero
/// wallet). Triggered with `-koinsSelfTestNetwork`; cleans up after itself.
static func runNetwork() {
// Detached so the test does not inherit the main actor: the UI thread must
// stay free, otherwise the launch-time call would deadlock.
Task.detached(priority: .userInitiated) {
emit("KOINS_NET begin")
// The test creates temporary wallets, so snapshot the real ones and put
// them back afterwards instead of wiping them.
let backup = (xrp: WalletStore.wallet(.xrp),
xmr: WalletStore.wallet(.xmr),
xrpSecret: WalletStore.secret(.xrp),
xmrSecret: WalletStore.secret(.xmr))
func restoreWallets() {
if let xrp = backup.xrp { WalletStore.saveWallet(xrp, chain: .xrp) } else { WalletStore.removeWallet(.xrp) }
if let secret = backup.xrpSecret { WalletStore.saveSecret(secret, chain: .xrp) }
if let xmr = backup.xmr { WalletStore.saveWallet(xmr, chain: .xmr) } else { WalletStore.removeWallet(.xmr) }
if let secret = backup.xmrSecret { WalletStore.saveSecret(secret, chain: .xmr) }
emit("KOINS_NET restored saved wallets")
}
do {
let (record, phrase) = try XRPWalletService.shared.createWallet(name: "koins-net-selftest")
emit("KOINS_NET xrp address \(record.address) phraseWords \(phrase.split(separator: " ").count)")
let balance = try await XRPWalletService.shared.balance(address: record.address)
emit("KOINS_NET xrp funded=\(balance.funded) balance=\(balance.total)")
let history = try await XRPWalletService.shared.transactions(address: record.address)
emit("KOINS_NET xrp transactions=\(history.count)")
// A known funded account, to exercise the funded-balance path.
for reference in ["rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", "rHR6xtDW7BxeQN8VB8R9HzXzyMeS42n7C"] {
let reference = try await XRPWalletService.shared.balance(address: reference)
emit("KOINS_NET xrp reference funded=\(reference.funded) balance=\(reference.total)")
let referenceHistory = try await XRPWalletService.shared.transactions(address: "rHR6xtDW7BxeQN8VB8R9HzXzyMeS42n7C")
emit("KOINS_NET xrp reference transactions=\(referenceHistory.count)")
}
} catch {
emit("KOINS_NET xrp FAIL \(error)")
}
do {
let created = try await MoneroService.shared.createWallet(name: "koins-net-selftest")
let words = created.mnemonic.split(separator: " ").count
emit("KOINS_NET xmr address \(created.address) mnemonicWords \(words)")
let started = try await MoneroService.shared.startSync()
emit("KOINS_NET xmr startedSync \(started)")
for attempt in 1...6 {
try? await Task.sleep(nanoseconds: 5_000_000_000)
let status = try await MoneroService.shared.syncStatus()
emit("KOINS_NET xmr status \(attempt) height=\(status.height) daemon=\(status.daemonHeight) connected=\(status.connected) syncing=\(status.syncing)")
}
let balance = try await MoneroService.shared.balance()
emit("KOINS_NET xmr balance \(balance.total) unlocked \(balance.unlocked)")
let accounts = try await MoneroService.shared.accounts()
emit("KOINS_NET xmr accounts \(accounts.count) subaddresses \(accounts.first?.subaddresses.count ?? 0)")
let account = try await MoneroService.shared.createAccount(label: "Savings")
emit("KOINS_NET xmr new account index \(account.index) address \(account.address.prefix(12))")
let subaddress = try await MoneroService.shared.createSubaddress(accountIndex: 0, label: "From Alice")
emit("KOINS_NET xmr new subaddress index \(subaddress.index) address \(subaddress.address.prefix(12))")
let reloaded = try await MoneroService.shared.accounts()
emit("KOINS_NET xmr accounts after \(reloaded.count) subaddresses \(reloaded.first?.subaddresses.count ?? 0)")
await MoneroService.shared.stopSync()
await MoneroService.shared.removeWallet(name: "koins-net-selftest")
} catch {
emit("KOINS_NET xmr FAIL \(error)")
}
restoreWallets()
emit("KOINS_NET end")
flushReport()
}
}
/// Temporary: exercise the kyc.rip estimate path with real values.
static func runSwapCheckIfRequested() {
guard ProcessInfo.processInfo.arguments.contains("-koinsSelfTestSwap") else { return }
Task.detached {
do {
let estimate = try await SwapService.shared.estimate(from: "xmr", to: "xrp", amount: 1)
emit("KOINS_SWAP amountTo \(estimate.amountTo)")
for route in estimate.routes.prefix(3) {
emit("KOINS_SWAP route \(route.provider) (\(route.engine)) -> \(route.amountTo) eta \(route.eta ?? -1)")
}
} catch {
emit("KOINS_SWAP FAIL \(error)")
}
flushReport()
}
}
static func run() {
var failures = 0
func expect(_ name: String, _ actual: String, _ wanted: String) {
let ok = actual.lowercased() == wanted.lowercased()
if !ok { failures += 1 }
emit("KOINS_SELFTEST \(name): \(ok ? "PASS" : "FAIL") \(actual)")
}
func expectTrue(_ name: String, _ value: Bool) {
if !value { failures += 1 }
emit("KOINS_SELFTEST \(name): \(value ? "PASS" : "FAIL")")
}
expect("ripemd160", Hex.encode(RIPEMD160.hash(Array("abc".utf8))),
"8eb208f7e05d987a9b044a8e98c6b087f15a0bfc")
// ed25519 vector
let edSeed = "sEdSPx8CLbFUna3DJdvJ74aQPLdemqu"
let edAddress = "rEhh6f9rj5UUBhFzGGaxS5zYU2CCqKFXBC"
let edPublicKey = "EDF9096653463E3A0B79A67E69D9E66AB83D93D4E867335ADE5DAFE8467F4F1734"
if let keypair = try? XRPKey.keypair(fromSeed: edSeed) {
expect("ed25519 pubkey", Hex.encode(keypair.publicKey), edPublicKey)
expect("ed25519 address", keypair.address, edAddress)
} else {
failures += 1
emit("KOINS_SELFTEST ed25519: FAIL keypair threw")
}
// secp256k1 vector: exercises the OpenSSL-backed derivation on device.
let secpSeed = "spFbtYoPd7Hg7AD9XZn7CBwUKC5Qe"
let secpPublicKey = "0322DC9BB14903F71186BC46B0107D55A069D23CD97BBD205491A992EC68FA0ABE"
let secpAddress = "rpsRYc8DbXzfVN32w3hZjUtyuF1K89hu47"
do {
let keypair = try XRPKey.keypair(fromSeed: secpSeed)
expect("secp256k1 algorithm", keypair.algorithm.rawValue, "secp256k1")
expect("secp256k1 pubkey", Hex.encode(keypair.publicKey), secpPublicKey)
expect("secp256k1 address", keypair.address, secpAddress)
} catch {
failures += 1
emit("KOINS_SELFTEST secp256k1: FAIL \(error)")
}
// Serialization + hashing reference vector.
let signingHex = "53545800120000220000000024000030392E00000309201B05E69F3B61400000000016E36068400000000000000C7321EDF9096653463E3A0B79A67E69D9E66AB83D93D4E867335ADE5DAFE8467F4F173481149A26172134BFDA9708A73305AF2776A26DB7B4F5831492891F44F454D156327AA24F4C8BB72540E31BF1"
let referenceSignature = "F560672EA3103D2E43179302CD66ACF563A4EDFBA90ADE24E378B743135FD989EE132FA4B703021C2DD1979E770A6CEBC99556EA330AF9E6E7186673F837CB0D"
let expectedBlob = "120000220000000024000030392E00000309201B05E69F3B61400000000016E36068400000000000000C7321EDF9096653463E3A0B79A67E69D9E66AB83D93D4E867335ADE5DAFE8467F4F17347440F560672EA3103D2E43179302CD66ACF563A4EDFBA90ADE24E378B743135FD989EE132FA4B703021C2DD1979E770A6CEBC99556EA330AF9E6E7186673F837CB0D81149A26172134BFDA9708A73305AF2776A26DB7B4F5831492891F44F454D156327AA24F4C8BB72540E31BF1"
let expectedHash = "F853B5391CD6317EA370830EEA3C047AC8D21E9FA00E4116D94C48E360B0434F"
var payment = XRPPayment(account: edAddress,
destination: "rNMovRR3WPbFLVaSbETCCR71XsqyxhJ9P6",
amountDrops: 1_500_000,
feeDrops: 12,
sequence: 12345,
lastLedgerSequence: 99000123,
destinationTag: 777,
signingPublicKey: Hex.decode(edPublicKey) ?? [],
signature: Hex.decode(referenceSignature))
if let payload = try? payment.signingPayload() {
expect("signing payload", Hex.encode(payload), signingHex)
} else {
failures += 1
}
if let blob = try? payment.txBlob() {
expect("tx blob", Hex.encode(blob), expectedBlob)
if let hash = try? payment.transactionHash() {
expect("tx hash", Hex.encode(hash), expectedHash)
} else {
failures += 1
}
} else {
failures += 1
}
// BIP-39 / BIP-32 (m/44'/144'/0'/0/0): the recovery phrase used for new XRP
// wallets. Vectors come from @scure/bip39 + @scure/bip32 + ripple-keypairs.
let zeroMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
expect("bip39 encode", BIP39.mnemonic(from: [UInt8](repeating: 0, count: 16)), zeroMnemonic)
expect("bip39 entropy", Hex.encode(BIP39.entropy(from: zeroMnemonic) ?? []), String(repeating: "00", count: 16))
expect("bip39 rejects bad checksum",
BIP39.entropy(from: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon") == nil ? "rejected" : "accepted",
"rejected")
// BIP-32 intermediate values, to isolate derivation problems.
if let master = BIP32.masterKey(fromSeed: BIP39.seed(from: zeroMnemonic)) {
expect("bip32 master key", Hex.encode(master.privateKey),
"1837c1be8e2995ec11cda2b066151be2cfb48adf9e47b151d46adab3a21cdf67")
expect("bip32 master chain code", Hex.encode(master.chainCode),
"7923408dadd3c7b56eed15567707ae5e5dca089de972e07f3b860450e2a3b70e")
let H = BIP32.hardenedBit
for (level, path, expected) in [
(1, [H | 44], "cd46049fb82a4fccbd967c5234e61152f4df87d01d1ef833420814c67ee4b7ea"),
(2, [H | 44, H | 144], "a12250f1f38fb41b62f0f1a4ec9d3555d24788843cb30e6d98a777fe540adc1d"),
(3, [H | 44, H | 144, H | 0], "54bac451906ecd52648a49126a2606c9177761f14d31dda079c0f768ac3501fd"),
(4, [H | 44, H | 144, H | 0, 0], "e582ce1724e93eebe3f38f1d42c01620b66d136946fec450dfe71b62e65de54f"),
(5, BIP32.xrpPath, "90802a50aa84efb6cdb225f17c27616ea94048c179142fecf03f4712a07ea7a4"),
] {
if let child = BIP32.derive(path, from: master) {
expect("bip32 level \(level)", Hex.encode(child.privateKey), expected)
} else {
failures += 1
emit("KOINS_SELFTEST bip32 level \(level): FAIL derivation returned nil")
}
}
} else {
failures += 1
emit("KOINS_SELFTEST bip32 master: FAIL")
}
let mnemonicCases: [(String, String, String)] = [
(zeroMnemonic,
"031d68bc1a142e6766b2bdfb006ccfe135ef2e0e2e94abb5cf5c9ab6104776fbae",
"rHsMGQEkVNJmpGWs8XUBoTBiAAbwxZN5v3"),
("legal winner thank year wave sausage worth useful legal winner thank yellow",
"0283e0926eb0ab7bb8f5c0b5637d9e2526bdc398763af79cd904f013178961692d",
"rpaLPza5oxhJ7V8nFDwPm9F7kmZ16uc8do"),
]
for (position, vector) in mnemonicCases.enumerated() {
do {
let keypair = try XRPKey.keypair(fromMnemonic: vector.0)
expect("bip44 pubkey #\(position)", Hex.encode(keypair.publicKey), vector.1)
expect("bip44 address #\(position)", keypair.address, vector.2)
} catch {
failures += 1
emit("KOINS_SELFTEST bip44 #\(position): FAIL \(error)")
}
}
// QR round trip: generate an image, read it back, parse the payment URI.
let xmrAddress = "46cRPeU77sBVGSVXPyCjvJUdC9AjqvETWb6giUGskdnWN8ZifindmrH7mcztKU52iKgvWXY4dyPujg9LYUY1Q7dvDiwHNCE"
let xrpAddress = "rHR6xtDW7BxeQN8VB8R9HzXzyMeS42n7C"
func roundTrip(_ name: String, _ text: String, checks: [(String, String)]) {
guard let image = QRCodeImage.make(text) else {
failures += 1
emit("KOINS_SELFTEST \(name): FAIL could not render QR image")
return
}
guard let decoded = QRCodeReader.decodeText(in: image) else {
failures += 1
emit("KOINS_SELFTEST \(name): FAIL vision could not read the QR image (\(Int(image.size.width))px)")
return
}
guard let payload = QRCodeReader.parse(decoded) else {
failures += 1
emit("KOINS_SELFTEST \(name): FAIL parse of '\(decoded)'")
return
}
for (label, expected) in checks {
let actual: String
switch label {
case "address": actual = payload.address
case "amount": actual = payload.amount ?? ""
case "tag": actual = payload.tag.map(String.init) ?? ""
case "paymentId": actual = payload.paymentId ?? ""
case "memo": actual = payload.memo ?? ""
default: actual = ""
}
expect("\(name) \(label)", actual, expected)
}
}
roundTrip("qr monero uri",
"monero:\(xmrAddress)?tx_amount=1.5&tx_payment_id=abc123&tx_description=Rent",
checks: [("address", xmrAddress), ("amount", "1.5"), ("paymentId", "abc123"), ("memo", "Rent")])
roundTrip("qr monero plain", xmrAddress, checks: [("address", xmrAddress)])
roundTrip("qr xrp uri",
"ripple:\(xrpAddress)?amount=12.5&dt=99",
checks: [("address", xrpAddress), ("amount", "12.5"), ("tag", "99")])
roundTrip("qr xrp plain", xrpAddress, checks: [("address", xrpAddress)])
expectTrue("qr monero detected", AddressFormat.isMonero(xmrAddress))
expectTrue("qr xrp detected", AddressFormat.isXRP(xrpAddress))
expectTrue("qr rejects junk", QRCodeReader.parse("hello world")?.address == "hello world")
expectTrue("qr not monero", !AddressFormat.isMonero(xrpAddress))
expectTrue("qr not xrp", !AddressFormat.isXRP(xmrAddress))
emit("biometrics available=\(Biometrics.isAvailable) label=\(Biometrics.label)")
// secp256k1 signing must produce a low-S DER signature.
if let keypair = try? XRPKey.keypair(fromSeed: secpSeed),
let message = Hex.decode(signingHex),
let signature = try? XRPKey.sign(message: message, keypair: keypair) {
let isDer = signature.count > 8 && signature[0] == 0x30
expect("secp256k1 signature is DER", isDer ? "yes" : "no", "yes")
if let order = BigNumber.fromHex("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141"),
let half = order.dividedByTwo(),
let s = DER.signatureScalar(signature) {
expect("secp256k1 signature is low-S", s.lessThanOrEqual(half) ? "yes" : "no", "yes")
}
emit("KOINS_SELFTEST secp256k1 signature len: \(signature.count)")
} else {
failures += 1
emit("KOINS_SELFTEST secp256k1 sign: FAIL")
}
emit("KOINS_SELFTEST summary: \(failures == 0 ? "ALL PASS" : "\(failures) FAILURE(S)")")
flushReport()
}
}
/// Minimal big-endian integer helper used only for the low-S check.
struct BigNumber {
let bytes: [UInt8]
static func fromHex(_ hex: String) -> BigNumber? {
Hex.decode(hex).map { BigNumber(bytes: $0) }
}
func dividedByTwo() -> BigNumber? {
var result: [UInt8] = []
var carry: UInt16 = 0
for byte in bytes {
let value = carry * 256 + UInt16(byte)
result.append(UInt8(value / 2))
carry = value % 2
}
return BigNumber(bytes: result)
}
func lessThanOrEqual(_ other: BigNumber) -> Bool {
let left = trimmed()
let right = other.trimmed()
if left.count != right.count { return left.count < right.count }
for (a, b) in zip(left, right) where a != b { return a < b }
return true
}
private func trimmed() -> [UInt8] {
var value = bytes
while value.first == 0 && value.count > 1 { value.removeFirst() }
return value
}
}
enum DER {
/// Extracts the `s` component from a DER-encoded ECDSA signature.
static func signatureScalar(_ der: [UInt8]) -> BigNumber? {
guard der.count > 8, der[0] == 0x30 else { return nil }
var index = 2
guard der[index] == 0x02 else { return nil }
let rLength = Int(der[index + 1])
index += 2 + rLength
guard index + 2 <= der.count, der[index] == 0x02 else { return nil }
let sLength = Int(der[index + 1])
let start = index + 2
guard start + sLength <= der.count else { return nil }
return BigNumber(bytes: Array(der[start..<(start + sLength)]))
}
}
+92
View File
@@ -0,0 +1,92 @@
import CryptoKit
import Foundation
/// BIP-32 key derivation for the XRP account path `m/44'/144'/0'/0/0`. The
/// first three steps are hardened, the last two are not, so both cases are
/// supported (hardened steps sign with the parent private key, normal steps
/// with the parent public key).
enum BIP32 {
static let hardenedBit: UInt32 = 0x8000_0000
/// Standard XRP derivation path: 44' / 144' / 0' / 0 / 0.
static let xrpPath: [UInt32] = [
hardenedBit | 44,
hardenedBit | 144,
hardenedBit | 0,
0,
0,
]
struct Key {
var privateKey: [UInt8]
var chainCode: [UInt8]
var publicKey: [UInt8]
}
static func masterKey(fromSeed seed: [UInt8]) -> Key? {
let derived = hmac(key: Array("Bitcoin seed".utf8), data: seed)
guard derived.count == 64 else { return nil }
let privateKey = Array(derived[0..<32])
guard isValidScalar(privateKey),
let publicKey = Secp256k1Backend.publicKey?(Data(privateKey)) else { return nil }
return Key(privateKey: privateKey,
chainCode: Array(derived[32..<64]),
publicKey: Array(publicKey))
}
static func derive(_ path: [UInt32], from key: Key) -> Key? {
var current = key
for index in path {
guard let next = deriveChild(current, index: index) else { return nil }
current = next
}
return current
}
private static func deriveChild(_ parent: Key, index: UInt32) -> Key? {
let isHardened = index & hardenedBit != 0
var data: [UInt8] = []
if isHardened {
data.append(0x00)
data += parent.privateKey
} else {
data += parent.publicKey
}
data += [
UInt8((index >> 24) & 0xff),
UInt8((index >> 16) & 0xff),
UInt8((index >> 8) & 0xff),
UInt8(index & 0xff),
]
let derived = hmac(key: parent.chainCode, data: data)
guard derived.count == 64 else { return nil }
let left = Array(derived[0..<32])
guard isValidScalar(left),
let sum = Secp256k1Backend.addScalar?(Data(left), Data(parent.privateKey)) else { return nil }
let privateKey = Array(sum)
// The child public key is the same whether it is computed from the child
// private key or by point addition, so derive it directly.
guard isValidScalar(privateKey),
let publicKey = Secp256k1Backend.publicKey?(Data(privateKey)) else { return nil }
return Key(privateKey: privateKey,
chainCode: Array(derived[32..<64]),
publicKey: Array(publicKey))
}
/// A key is usable when 0 < key < the secp256k1 group order.
private static func isValidScalar(_ bytes: [UInt8]) -> Bool {
guard bytes.count == 32 else { return false }
let orderHex = "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"
guard let order = Hex.decode(orderHex) else { return false }
if bytes.allSatisfy({ $0 == 0 }) { return false }
for (left, right) in zip(bytes, order) where left != right { return left < right }
return false
}
private static func hmac(key: [UInt8], data: [UInt8]) -> [UInt8] {
Array(HMAC<SHA512>.authenticationCode(for: Data(data), using: SymmetricKey(data: Data(key))))
}
}
+94
View File
@@ -0,0 +1,94 @@
import CryptoKit
import Foundation
/// BIP-39 mnemonics. The XRP wallet uses a 12 word phrase as its recovery
/// secret, matching what hardware and mobile wallets expect.
enum BIP39 {
static let wordCount = 12
static let entropyBytes = 16
private static let checksumBits = entropyBytes / 4
private static let index: [String: Int] = {
var map: [String: Int] = [:]
for (position, word) in BIP39Wordlist.words.enumerated() { map[word] = position }
return map
}()
static func generateMnemonic() -> String {
var entropy = [UInt8](repeating: 0, count: entropyBytes)
for position in entropy.indices { entropy[position] = UInt8.random(in: 0...255) }
return mnemonic(from: entropy)
}
/// 16 bytes of entropy plus its 4 bit SHA-256 checksum, as 12 words.
static func mnemonic(from entropy: [UInt8]) -> String {
precondition(entropy.count == entropyBytes, "BIP-39 entropy must be 16 bytes")
var bits = entropy.flatMap { byte in (0..<8).map { (byte >> (7 - $0)) & 1 } }
let checksum = XRPHash.sha256(entropy)
bits += (0..<checksumBits).map { (checksum[0] >> (7 - $0)) & 1 }
var words: [String] = []
for start in stride(from: 0, to: bits.count, by: 11) {
var value = 0
for offset in 0..<11 { value = (value << 1) | Int(bits[start + offset]) }
words.append(BIP39Wordlist.words[value])
}
return words.joined(separator: " ")
}
static func entropy(from mnemonic: String) -> [UInt8]? {
let words = normalize(mnemonic).split(separator: " ").map(String.init)
guard words.count == wordCount else { return nil }
var bits: [Int] = []
for word in words {
guard let position = index[word] else { return nil }
bits += (0..<11).map { (position >> (10 - $0)) & 1 }
}
var entropy: [UInt8] = []
for start in stride(from: 0, to: entropyBytes * 8, by: 8) {
var value: UInt8 = 0
for offset in 0..<8 { value = (value << 1) | UInt8(bits[start + offset]) }
entropy.append(value)
}
let checksum = XRPHash.sha256(entropy)
for offset in 0..<checksumBits {
let expected = (checksum[0] >> (7 - offset)) & 1
guard bits[entropyBytes * 8 + offset] == expected else { return nil }
}
return entropy
}
/// 64 byte BIP-39 seed (PBKDF2-HMAC-SHA512, empty passphrase).
static func seed(from mnemonic: String) -> [UInt8] {
let password = Array(normalize(mnemonic).utf8)
let salt = Array("mnemonic".utf8)
// Derived length equals the hash length, so a single PBKDF2 block is needed.
let block = salt + [0, 0, 0, 1]
var previous = hmacSHA512(key: password, data: block)
var result = previous
for _ in 1..<2_048 {
previous = hmacSHA512(key: password, data: previous)
for position in result.indices { result[position] ^= previous[position] }
}
return result
}
static func isMnemonic(_ secret: String) -> Bool {
normalize(secret).split(separator: " ").count == wordCount
}
private static func normalize(_ text: String) -> String {
text.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
.precomposedStringWithCompatibilityMapping
}
private static func hmacSHA512(key: [UInt8], data: [UInt8]) -> [UInt8] {
let authentication = HMAC<SHA512>.authenticationCode(for: Data(data),
using: SymmetricKey(data: Data(key)))
return Array(authentication)
}
}
+263
View File
@@ -0,0 +1,263 @@
import Foundation
/// BIP-39 English word list (2048 words), in the canonical order.
enum BIP39Wordlist {
static let words: [String] = [
"abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract",
"absurd", "abuse", "access", "accident", "account", "accuse", "achieve", "acid",
"acoustic", "acquire", "across", "act", "action", "actor", "actress", "actual",
"adapt", "add", "addict", "address", "adjust", "admit", "adult", "advance",
"advice", "aerobic", "affair", "afford", "afraid", "again", "age", "agent",
"agree", "ahead", "aim", "air", "airport", "aisle", "alarm", "album",
"alcohol", "alert", "alien", "all", "alley", "allow", "almost", "alone",
"alpha", "already", "also", "alter", "always", "amateur", "amazing", "among",
"amount", "amused", "analyst", "anchor", "ancient", "anger", "angle", "angry",
"animal", "ankle", "announce", "annual", "another", "answer", "antenna", "antique",
"anxiety", "any", "apart", "apology", "appear", "apple", "approve", "april",
"arch", "arctic", "area", "arena", "argue", "arm", "armed", "armor",
"army", "around", "arrange", "arrest", "arrive", "arrow", "art", "artefact",
"artist", "artwork", "ask", "aspect", "assault", "asset", "assist", "assume",
"asthma", "athlete", "atom", "attack", "attend", "attitude", "attract", "auction",
"audit", "august", "aunt", "author", "auto", "autumn", "average", "avocado",
"avoid", "awake", "aware", "away", "awesome", "awful", "awkward", "axis",
"baby", "bachelor", "bacon", "badge", "bag", "balance", "balcony", "ball",
"bamboo", "banana", "banner", "bar", "barely", "bargain", "barrel", "base",
"basic", "basket", "battle", "beach", "bean", "beauty", "because", "become",
"beef", "before", "begin", "behave", "behind", "believe", "below", "belt",
"bench", "benefit", "best", "betray", "better", "between", "beyond", "bicycle",
"bid", "bike", "bind", "biology", "bird", "birth", "bitter", "black",
"blade", "blame", "blanket", "blast", "bleak", "bless", "blind", "blood",
"blossom", "blouse", "blue", "blur", "blush", "board", "boat", "body",
"boil", "bomb", "bone", "bonus", "book", "boost", "border", "boring",
"borrow", "boss", "bottom", "bounce", "box", "boy", "bracket", "brain",
"brand", "brass", "brave", "bread", "breeze", "brick", "bridge", "brief",
"bright", "bring", "brisk", "broccoli", "broken", "bronze", "broom", "brother",
"brown", "brush", "bubble", "buddy", "budget", "buffalo", "build", "bulb",
"bulk", "bullet", "bundle", "bunker", "burden", "burger", "burst", "bus",
"business", "busy", "butter", "buyer", "buzz", "cabbage", "cabin", "cable",
"cactus", "cage", "cake", "call", "calm", "camera", "camp", "can",
"canal", "cancel", "candy", "cannon", "canoe", "canvas", "canyon", "capable",
"capital", "captain", "car", "carbon", "card", "cargo", "carpet", "carry",
"cart", "case", "cash", "casino", "castle", "casual", "cat", "catalog",
"catch", "category", "cattle", "caught", "cause", "caution", "cave", "ceiling",
"celery", "cement", "census", "century", "cereal", "certain", "chair", "chalk",
"champion", "change", "chaos", "chapter", "charge", "chase", "chat", "cheap",
"check", "cheese", "chef", "cherry", "chest", "chicken", "chief", "child",
"chimney", "choice", "choose", "chronic", "chuckle", "chunk", "churn", "cigar",
"cinnamon", "circle", "citizen", "city", "civil", "claim", "clap", "clarify",
"claw", "clay", "clean", "clerk", "clever", "click", "client", "cliff",
"climb", "clinic", "clip", "clock", "clog", "close", "cloth", "cloud",
"clown", "club", "clump", "cluster", "clutch", "coach", "coast", "coconut",
"code", "coffee", "coil", "coin", "collect", "color", "column", "combine",
"come", "comfort", "comic", "common", "company", "concert", "conduct", "confirm",
"congress", "connect", "consider", "control", "convince", "cook", "cool", "copper",
"copy", "coral", "core", "corn", "correct", "cost", "cotton", "couch",
"country", "couple", "course", "cousin", "cover", "coyote", "crack", "cradle",
"craft", "cram", "crane", "crash", "crater", "crawl", "crazy", "cream",
"credit", "creek", "crew", "cricket", "crime", "crisp", "critic", "crop",
"cross", "crouch", "crowd", "crucial", "cruel", "cruise", "crumble", "crunch",
"crush", "cry", "crystal", "cube", "culture", "cup", "cupboard", "curious",
"current", "curtain", "curve", "cushion", "custom", "cute", "cycle", "dad",
"damage", "damp", "dance", "danger", "daring", "dash", "daughter", "dawn",
"day", "deal", "debate", "debris", "decade", "december", "decide", "decline",
"decorate", "decrease", "deer", "defense", "define", "defy", "degree", "delay",
"deliver", "demand", "demise", "denial", "dentist", "deny", "depart", "depend",
"deposit", "depth", "deputy", "derive", "describe", "desert", "design", "desk",
"despair", "destroy", "detail", "detect", "develop", "device", "devote", "diagram",
"dial", "diamond", "diary", "dice", "diesel", "diet", "differ", "digital",
"dignity", "dilemma", "dinner", "dinosaur", "direct", "dirt", "disagree", "discover",
"disease", "dish", "dismiss", "disorder", "display", "distance", "divert", "divide",
"divorce", "dizzy", "doctor", "document", "dog", "doll", "dolphin", "domain",
"donate", "donkey", "donor", "door", "dose", "double", "dove", "draft",
"dragon", "drama", "drastic", "draw", "dream", "dress", "drift", "drill",
"drink", "drip", "drive", "drop", "drum", "dry", "duck", "dumb",
"dune", "during", "dust", "dutch", "duty", "dwarf", "dynamic", "eager",
"eagle", "early", "earn", "earth", "easily", "east", "easy", "echo",
"ecology", "economy", "edge", "edit", "educate", "effort", "egg", "eight",
"either", "elbow", "elder", "electric", "elegant", "element", "elephant", "elevator",
"elite", "else", "embark", "embody", "embrace", "emerge", "emotion", "employ",
"empower", "empty", "enable", "enact", "end", "endless", "endorse", "enemy",
"energy", "enforce", "engage", "engine", "enhance", "enjoy", "enlist", "enough",
"enrich", "enroll", "ensure", "enter", "entire", "entry", "envelope", "episode",
"equal", "equip", "era", "erase", "erode", "erosion", "error", "erupt",
"escape", "essay", "essence", "estate", "eternal", "ethics", "evidence", "evil",
"evoke", "evolve", "exact", "example", "excess", "exchange", "excite", "exclude",
"excuse", "execute", "exercise", "exhaust", "exhibit", "exile", "exist", "exit",
"exotic", "expand", "expect", "expire", "explain", "expose", "express", "extend",
"extra", "eye", "eyebrow", "fabric", "face", "faculty", "fade", "faint",
"faith", "fall", "false", "fame", "family", "famous", "fan", "fancy",
"fantasy", "farm", "fashion", "fat", "fatal", "father", "fatigue", "fault",
"favorite", "feature", "february", "federal", "fee", "feed", "feel", "female",
"fence", "festival", "fetch", "fever", "few", "fiber", "fiction", "field",
"figure", "file", "film", "filter", "final", "find", "fine", "finger",
"finish", "fire", "firm", "first", "fiscal", "fish", "fit", "fitness",
"fix", "flag", "flame", "flash", "flat", "flavor", "flee", "flight",
"flip", "float", "flock", "floor", "flower", "fluid", "flush", "fly",
"foam", "focus", "fog", "foil", "fold", "follow", "food", "foot",
"force", "forest", "forget", "fork", "fortune", "forum", "forward", "fossil",
"foster", "found", "fox", "fragile", "frame", "frequent", "fresh", "friend",
"fringe", "frog", "front", "frost", "frown", "frozen", "fruit", "fuel",
"fun", "funny", "furnace", "fury", "future", "gadget", "gain", "galaxy",
"gallery", "game", "gap", "garage", "garbage", "garden", "garlic", "garment",
"gas", "gasp", "gate", "gather", "gauge", "gaze", "general", "genius",
"genre", "gentle", "genuine", "gesture", "ghost", "giant", "gift", "giggle",
"ginger", "giraffe", "girl", "give", "glad", "glance", "glare", "glass",
"glide", "glimpse", "globe", "gloom", "glory", "glove", "glow", "glue",
"goat", "goddess", "gold", "good", "goose", "gorilla", "gospel", "gossip",
"govern", "gown", "grab", "grace", "grain", "grant", "grape", "grass",
"gravity", "great", "green", "grid", "grief", "grit", "grocery", "group",
"grow", "grunt", "guard", "guess", "guide", "guilt", "guitar", "gun",
"gym", "habit", "hair", "half", "hammer", "hamster", "hand", "happy",
"harbor", "hard", "harsh", "harvest", "hat", "have", "hawk", "hazard",
"head", "health", "heart", "heavy", "hedgehog", "height", "hello", "helmet",
"help", "hen", "hero", "hidden", "high", "hill", "hint", "hip",
"hire", "history", "hobby", "hockey", "hold", "hole", "holiday", "hollow",
"home", "honey", "hood", "hope", "horn", "horror", "horse", "hospital",
"host", "hotel", "hour", "hover", "hub", "huge", "human", "humble",
"humor", "hundred", "hungry", "hunt", "hurdle", "hurry", "hurt", "husband",
"hybrid", "ice", "icon", "idea", "identify", "idle", "ignore", "ill",
"illegal", "illness", "image", "imitate", "immense", "immune", "impact", "impose",
"improve", "impulse", "inch", "include", "income", "increase", "index", "indicate",
"indoor", "industry", "infant", "inflict", "inform", "inhale", "inherit", "initial",
"inject", "injury", "inmate", "inner", "innocent", "input", "inquiry", "insane",
"insect", "inside", "inspire", "install", "intact", "interest", "into", "invest",
"invite", "involve", "iron", "island", "isolate", "issue", "item", "ivory",
"jacket", "jaguar", "jar", "jazz", "jealous", "jeans", "jelly", "jewel",
"job", "join", "joke", "journey", "joy", "judge", "juice", "jump",
"jungle", "junior", "junk", "just", "kangaroo", "keen", "keep", "ketchup",
"key", "kick", "kid", "kidney", "kind", "kingdom", "kiss", "kit",
"kitchen", "kite", "kitten", "kiwi", "knee", "knife", "knock", "know",
"lab", "label", "labor", "ladder", "lady", "lake", "lamp", "language",
"laptop", "large", "later", "latin", "laugh", "laundry", "lava", "law",
"lawn", "lawsuit", "layer", "lazy", "leader", "leaf", "learn", "leave",
"lecture", "left", "leg", "legal", "legend", "leisure", "lemon", "lend",
"length", "lens", "leopard", "lesson", "letter", "level", "liar", "liberty",
"library", "license", "life", "lift", "light", "like", "limb", "limit",
"link", "lion", "liquid", "list", "little", "live", "lizard", "load",
"loan", "lobster", "local", "lock", "logic", "lonely", "long", "loop",
"lottery", "loud", "lounge", "love", "loyal", "lucky", "luggage", "lumber",
"lunar", "lunch", "luxury", "lyrics", "machine", "mad", "magic", "magnet",
"maid", "mail", "main", "major", "make", "mammal", "man", "manage",
"mandate", "mango", "mansion", "manual", "maple", "marble", "march", "margin",
"marine", "market", "marriage", "mask", "mass", "master", "match", "material",
"math", "matrix", "matter", "maximum", "maze", "meadow", "mean", "measure",
"meat", "mechanic", "medal", "media", "melody", "melt", "member", "memory",
"mention", "menu", "mercy", "merge", "merit", "merry", "mesh", "message",
"metal", "method", "middle", "midnight", "milk", "million", "mimic", "mind",
"minimum", "minor", "minute", "miracle", "mirror", "misery", "miss", "mistake",
"mix", "mixed", "mixture", "mobile", "model", "modify", "mom", "moment",
"monitor", "monkey", "monster", "month", "moon", "moral", "more", "morning",
"mosquito", "mother", "motion", "motor", "mountain", "mouse", "move", "movie",
"much", "muffin", "mule", "multiply", "muscle", "museum", "mushroom", "music",
"must", "mutual", "myself", "mystery", "myth", "naive", "name", "napkin",
"narrow", "nasty", "nation", "nature", "near", "neck", "need", "negative",
"neglect", "neither", "nephew", "nerve", "nest", "net", "network", "neutral",
"never", "news", "next", "nice", "night", "noble", "noise", "nominee",
"noodle", "normal", "north", "nose", "notable", "note", "nothing", "notice",
"novel", "now", "nuclear", "number", "nurse", "nut", "oak", "obey",
"object", "oblige", "obscure", "observe", "obtain", "obvious", "occur", "ocean",
"october", "odor", "off", "offer", "office", "often", "oil", "okay",
"old", "olive", "olympic", "omit", "once", "one", "onion", "online",
"only", "open", "opera", "opinion", "oppose", "option", "orange", "orbit",
"orchard", "order", "ordinary", "organ", "orient", "original", "orphan", "ostrich",
"other", "outdoor", "outer", "output", "outside", "oval", "oven", "over",
"own", "owner", "oxygen", "oyster", "ozone", "pact", "paddle", "page",
"pair", "palace", "palm", "panda", "panel", "panic", "panther", "paper",
"parade", "parent", "park", "parrot", "party", "pass", "patch", "path",
"patient", "patrol", "pattern", "pause", "pave", "payment", "peace", "peanut",
"pear", "peasant", "pelican", "pen", "penalty", "pencil", "people", "pepper",
"perfect", "permit", "person", "pet", "phone", "photo", "phrase", "physical",
"piano", "picnic", "picture", "piece", "pig", "pigeon", "pill", "pilot",
"pink", "pioneer", "pipe", "pistol", "pitch", "pizza", "place", "planet",
"plastic", "plate", "play", "please", "pledge", "pluck", "plug", "plunge",
"poem", "poet", "point", "polar", "pole", "police", "pond", "pony",
"pool", "popular", "portion", "position", "possible", "post", "potato", "pottery",
"poverty", "powder", "power", "practice", "praise", "predict", "prefer", "prepare",
"present", "pretty", "prevent", "price", "pride", "primary", "print", "priority",
"prison", "private", "prize", "problem", "process", "produce", "profit", "program",
"project", "promote", "proof", "property", "prosper", "protect", "proud", "provide",
"public", "pudding", "pull", "pulp", "pulse", "pumpkin", "punch", "pupil",
"puppy", "purchase", "purity", "purpose", "purse", "push", "put", "puzzle",
"pyramid", "quality", "quantum", "quarter", "question", "quick", "quit", "quiz",
"quote", "rabbit", "raccoon", "race", "rack", "radar", "radio", "rail",
"rain", "raise", "rally", "ramp", "ranch", "random", "range", "rapid",
"rare", "rate", "rather", "raven", "raw", "razor", "ready", "real",
"reason", "rebel", "rebuild", "recall", "receive", "recipe", "record", "recycle",
"reduce", "reflect", "reform", "refuse", "region", "regret", "regular", "reject",
"relax", "release", "relief", "rely", "remain", "remember", "remind", "remove",
"render", "renew", "rent", "reopen", "repair", "repeat", "replace", "report",
"require", "rescue", "resemble", "resist", "resource", "response", "result", "retire",
"retreat", "return", "reunion", "reveal", "review", "reward", "rhythm", "rib",
"ribbon", "rice", "rich", "ride", "ridge", "rifle", "right", "rigid",
"ring", "riot", "ripple", "risk", "ritual", "rival", "river", "road",
"roast", "robot", "robust", "rocket", "romance", "roof", "rookie", "room",
"rose", "rotate", "rough", "round", "route", "royal", "rubber", "rude",
"rug", "rule", "run", "runway", "rural", "sad", "saddle", "sadness",
"safe", "sail", "salad", "salmon", "salon", "salt", "salute", "same",
"sample", "sand", "satisfy", "satoshi", "sauce", "sausage", "save", "say",
"scale", "scan", "scare", "scatter", "scene", "scheme", "school", "science",
"scissors", "scorpion", "scout", "scrap", "screen", "script", "scrub", "sea",
"search", "season", "seat", "second", "secret", "section", "security", "seed",
"seek", "segment", "select", "sell", "seminar", "senior", "sense", "sentence",
"series", "service", "session", "settle", "setup", "seven", "shadow", "shaft",
"shallow", "share", "shed", "shell", "sheriff", "shield", "shift", "shine",
"ship", "shiver", "shock", "shoe", "shoot", "shop", "short", "shoulder",
"shove", "shrimp", "shrug", "shuffle", "shy", "sibling", "sick", "side",
"siege", "sight", "sign", "silent", "silk", "silly", "silver", "similar",
"simple", "since", "sing", "siren", "sister", "situate", "six", "size",
"skate", "sketch", "ski", "skill", "skin", "skirt", "skull", "slab",
"slam", "sleep", "slender", "slice", "slide", "slight", "slim", "slogan",
"slot", "slow", "slush", "small", "smart", "smile", "smoke", "smooth",
"snack", "snake", "snap", "sniff", "snow", "soap", "soccer", "social",
"sock", "soda", "soft", "solar", "soldier", "solid", "solution", "solve",
"someone", "song", "soon", "sorry", "sort", "soul", "sound", "soup",
"source", "south", "space", "spare", "spatial", "spawn", "speak", "special",
"speed", "spell", "spend", "sphere", "spice", "spider", "spike", "spin",
"spirit", "split", "spoil", "sponsor", "spoon", "sport", "spot", "spray",
"spread", "spring", "spy", "square", "squeeze", "squirrel", "stable", "stadium",
"staff", "stage", "stairs", "stamp", "stand", "start", "state", "stay",
"steak", "steel", "stem", "step", "stereo", "stick", "still", "sting",
"stock", "stomach", "stone", "stool", "story", "stove", "strategy", "street",
"strike", "strong", "struggle", "student", "stuff", "stumble", "style", "subject",
"submit", "subway", "success", "such", "sudden", "suffer", "sugar", "suggest",
"suit", "summer", "sun", "sunny", "sunset", "super", "supply", "supreme",
"sure", "surface", "surge", "surprise", "surround", "survey", "suspect", "sustain",
"swallow", "swamp", "swap", "swarm", "swear", "sweet", "swift", "swim",
"swing", "switch", "sword", "symbol", "symptom", "syrup", "system", "table",
"tackle", "tag", "tail", "talent", "talk", "tank", "tape", "target",
"task", "taste", "tattoo", "taxi", "teach", "team", "tell", "ten",
"tenant", "tennis", "tent", "term", "test", "text", "thank", "that",
"theme", "then", "theory", "there", "they", "thing", "this", "thought",
"three", "thrive", "throw", "thumb", "thunder", "ticket", "tide", "tiger",
"tilt", "timber", "time", "tiny", "tip", "tired", "tissue", "title",
"toast", "tobacco", "today", "toddler", "toe", "together", "toilet", "token",
"tomato", "tomorrow", "tone", "tongue", "tonight", "tool", "tooth", "top",
"topic", "topple", "torch", "tornado", "tortoise", "toss", "total", "tourist",
"toward", "tower", "town", "toy", "track", "trade", "traffic", "tragic",
"train", "transfer", "trap", "trash", "travel", "tray", "treat", "tree",
"trend", "trial", "tribe", "trick", "trigger", "trim", "trip", "trophy",
"trouble", "truck", "true", "truly", "trumpet", "trust", "truth", "try",
"tube", "tuition", "tumble", "tuna", "tunnel", "turkey", "turn", "turtle",
"twelve", "twenty", "twice", "twin", "twist", "two", "type", "typical",
"ugly", "umbrella", "unable", "unaware", "uncle", "uncover", "under", "undo",
"unfair", "unfold", "unhappy", "uniform", "unique", "unit", "universe", "unknown",
"unlock", "until", "unusual", "unveil", "update", "upgrade", "uphold", "upon",
"upper", "upset", "urban", "urge", "usage", "use", "used", "useful",
"useless", "usual", "utility", "vacant", "vacuum", "vague", "valid", "valley",
"valve", "van", "vanish", "vapor", "various", "vast", "vault", "vehicle",
"velvet", "vendor", "venture", "venue", "verb", "verify", "version", "very",
"vessel", "veteran", "viable", "vibrant", "vicious", "victory", "video", "view",
"village", "vintage", "violin", "virtual", "virus", "visa", "visit", "visual",
"vital", "vivid", "vocal", "voice", "void", "volcano", "volume", "vote",
"voyage", "wage", "wagon", "wait", "walk", "wall", "walnut", "want",
"warfare", "warm", "warrior", "wash", "wasp", "waste", "water", "wave",
"way", "wealth", "weapon", "wear", "weasel", "weather", "web", "wedding",
"weekend", "weird", "welcome", "west", "wet", "whale", "what", "wheat",
"wheel", "when", "where", "whip", "whisper", "wide", "width", "wife",
"wild", "will", "win", "window", "wine", "wing", "wink", "winner",
"winter", "wire", "wisdom", "wise", "wish", "witness", "wolf", "woman",
"wonder", "wood", "wool", "word", "work", "world", "worry", "worth",
"wrap", "wreck", "wrestle", "wrist", "write", "wrong", "yard", "year",
"yellow", "you", "young", "youth", "zebra", "zero", "zone", "zoo",
]
}
+76
View File
@@ -0,0 +1,76 @@
import Foundation
/// Base58 with the XRP Ledger alphabet and Base58Check helpers.
enum XRPBase58 {
static let alphabet: [Character] = Array("rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz")
private static let valueOf: [Character: Int] = {
var map: [Character: Int] = [:]
for (index, character) in alphabet.enumerated() { map[character] = index }
return map
}()
static func encode(_ bytes: [UInt8]) -> String {
var zeros = 0
for byte in bytes {
if byte == 0 { zeros += 1 } else { break }
}
var digits: [Int] = []
for byte in bytes[zeros...] {
var carry = Int(byte)
for index in digits.indices {
carry += digits[index] << 8
digits[index] = carry % 58
carry /= 58
}
while carry > 0 {
digits.append(carry % 58)
carry /= 58
}
}
var output = String(repeating: String(alphabet[0]), count: zeros)
for digit in digits.reversed() { output.append(alphabet[digit]) }
return output
}
static func decode(_ string: String) throws -> [UInt8] {
let characters = Array(string)
var zeros = 0
for character in characters {
if character == alphabet[0] { zeros += 1 } else { break }
}
var bytes: [Int] = []
for character in characters[zeros...] {
guard let value = valueOf[character] else { throw XRPError.invalidBase58 }
var carry = value
for index in bytes.indices {
carry += bytes[index] * 58
bytes[index] = carry & 0xff
carry >>= 8
}
while carry > 0 {
bytes.append(carry & 0xff)
carry >>= 8
}
}
return [UInt8](repeating: 0, count: zeros) + bytes.reversed().map { UInt8($0) }
}
static func checkEncode(version: [UInt8], payload: [UInt8]) -> String {
let body = version + payload
return encode(body + XRPHash.checksum(body))
}
static func checkDecode(_ string: String, versionLength: Int) throws -> [UInt8] {
let decoded = try decode(string)
guard decoded.count > versionLength + 4 else { throw XRPError.invalidAddress }
let body = Array(decoded[0..<(decoded.count - 4)])
let checksum = Array(decoded[(decoded.count - 4)...])
guard XRPHash.checksum(body) == checksum else { throw XRPError.invalidAddress }
return Array(body[versionLength...])
}
}
+25
View File
@@ -0,0 +1,25 @@
import Foundation
import LocalAuthentication
/// Face ID / Touch ID gating for the app lock and for sensitive actions.
enum Biometrics {
static var isAvailable: Bool {
LAContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
}
static var label: String {
let context = LAContext()
_ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
return context.biometryType == .faceID ? "Face ID" : "Touch ID"
}
/// Biometrics with the system passcode fallback (the alert offers "Use
/// Passcode" after a failed face scan).
static func authenticate(reason: String) async -> Bool {
guard isAvailable else { return false }
let context = LAContext()
context.localizedCancelTitle = "Cancel"
return (try? await context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
localizedReason: reason)) ?? false
}
}
+10
View File
@@ -0,0 +1,10 @@
#include <libkern/OSCacheControl.h>
#include <stddef.h>
// randomx (pulled in by monero's proof-of-work code) calls the compiler-rt
// builtin __clear_cache from its AArch64 JIT. libSystem exports it on macOS and
// the simulator, but not on iOS devices, so provide the same implementation
// compiler-rt uses on Darwin.
void __clear_cache(void *start, void *end) {
sys_icache_invalidate(start, (size_t)((char *)end - (char *)start));
}
+68
View File
@@ -0,0 +1,68 @@
import CryptoKit
import Foundation
enum XRPError: LocalizedError {
case invalidBase58
case invalidAddress
case invalidSeed
case unsupportedSeed
case rpc(String)
case network(String)
var errorDescription: String? {
switch self {
case .invalidBase58: return "Invalid Base58 text"
case .invalidAddress: return "Invalid XRP address"
case .invalidSeed: return "Check the recovery phrase or secret"
case .unsupportedSeed: return "Unsupported XRP secret"
case .rpc(let message): return message
case .network(let message): return message
}
}
}
enum XRPHash {
static func sha256(_ bytes: [UInt8]) -> [UInt8] {
Array(SHA256.hash(data: Data(bytes)))
}
static func sha512(_ bytes: [UInt8]) -> [UInt8] {
Array(SHA512.hash(data: Data(bytes)))
}
/// First 32 bytes of SHA-512, used by the XRP Ledger everywhere.
static func sha512Half(_ bytes: [UInt8]) -> [UInt8] {
Array(sha512(bytes).prefix(32))
}
static func ripemd160(_ bytes: [UInt8]) -> [UInt8] {
RIPEMD160.hash(bytes)
}
/// Base58Check double-SHA-256 checksum.
static func checksum(_ bytes: [UInt8]) -> [UInt8] {
Array(sha256(sha256(bytes)).prefix(4))
}
}
enum Hex {
static func encode(_ bytes: [UInt8]) -> String {
bytes.map { String(format: "%02x", $0) }.joined()
}
static func decode(_ string: String) -> [UInt8]? {
var text = string.trimmingCharacters(in: .whitespacesAndNewlines)
if text.hasPrefix("0x") || text.hasPrefix("0X") { text = String(text.dropFirst(2)) }
guard text.count % 2 == 0 else { return nil }
var bytes: [UInt8] = []
bytes.reserveCapacity(text.count / 2)
var index = text.startIndex
while index < text.endIndex {
let next = text.index(index, offsetBy: 2)
guard let byte = UInt8(text[index..<next], radix: 16) else { return nil }
bytes.append(byte)
index = next
}
return bytes
}
}
+106
View File
@@ -0,0 +1,106 @@
import Foundation
/// Fiat prices for the two tickers the wallet supports, fetched from a public
/// price API. Cached briefly in memory and in UserDefaults so screens do not
/// refetch on every refresh, and so the currency conversion shows up instantly
/// on a cold start.
final class PriceService {
static let shared = PriceService()
struct Prices: Codable {
var xrp: Double?
var xmr: Double?
var currency: String
var fetchedAt: Date?
}
private static let cacheLifetime: TimeInterval = 60
private static let defaultsKey = "koins.prices"
private var memory: Prices?
private var inFlight: Task<Prices, Never>?
private init() {
if let data = UserDefaults.standard.data(forKey: Self.defaultsKey),
let saved = try? JSONDecoder().decode(Prices.self, from: data) {
memory = saved
}
}
func prices(currency: String) async -> Prices {
if let memory,
memory.currency == currency,
let fetchedAt = memory.fetchedAt,
Date().timeIntervalSince(fetchedAt) < Self.cacheLifetime {
return memory
}
if let inFlight { return await inFlight.value }
let task = Task<Prices, Never> { [weak self] in
guard let self else { return Prices(currency: currency, fetchedAt: nil) }
let fetched = await self.fetch(currency: currency)
self.memory = fetched
if let data = try? JSONEncoder().encode(fetched) {
UserDefaults.standard.set(data, forKey: Self.defaultsKey)
}
return fetched
}
inFlight = task
let result = await task.value
inFlight = nil
return result
}
private func fetch(currency: String) async -> Prices {
if let prices = try? await fromCoinGecko(currency: currency) { return prices }
return (try? await fromKraken(currency: currency)) ?? Prices(currency: currency, fetchedAt: nil)
}
private func fromCoinGecko(currency: String) async throws -> Prices {
let code = currency.lowercased()
guard let url = URL(string: "https://api.coingecko.com/api/v3/simple/price?ids=monero,ripple&vs_currencies=\(code)") else {
throw XRPError.network("Invalid price endpoint")
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw XRPError.network("CoinGecko request failed")
}
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
return Prices(
xrp: (json["ripple"] as? [String: Any]).flatMap { ($0[code] as? NSNumber)?.doubleValue },
xmr: (json["monero"] as? [String: Any]).flatMap { ($0[code] as? NSNumber)?.doubleValue },
currency: currency,
fetchedAt: Date())
}
/// Kraken only lists USD and EUR for both assets; anything else just yields
/// no price rather than a wrong one.
private func fromKraken(currency: String) async throws -> Prices {
let supported: [String: (xrp: String, xmr: String)] = [
"USD": ("XXRPZUSD", "XXMRZUSD"),
"EUR": ("XXRPZEUR", "XXMRZEUR"),
]
guard let pairs = supported[currency] else {
throw XRPError.network("Kraken has no \(currency) pairs")
}
guard let url = URL(string: "https://api.kraken.com/0/public/Ticker?pair=\(pairs.xrp),\(pairs.xmr)") else {
throw XRPError.network("Invalid price endpoint")
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw XRPError.network("Kraken request failed")
}
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
let result = json["result"] as? [String: Any] ?? [:]
func lastTrade(_ key: String) -> Double? {
(result[key] as? [String: Any])
.flatMap { entry in (entry["c"] as? [Any])?.first }
.flatMap { ($0 as? String).flatMap(Double.init) }
}
return Prices(xrp: lastTrade(pairs.xrp),
xmr: lastTrade(pairs.xmr),
currency: currency,
fetchedAt: Date())
}
}
+135
View File
@@ -0,0 +1,135 @@
import CoreImage
import CoreImage.CIFilterBuiltins
import Foundation
import UIKit
import Vision
/// Renders a QR image (used by the UI and by the self-test, which round-trips
/// an image through the reader).
enum QRCodeImage {
static func make(_ text: String, scale: CGFloat = 10) -> UIImage? {
guard !text.isEmpty else { return nil }
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(text.utf8)
filter.correctionLevel = "M"
guard let output = filter.outputImage else { return nil }
let scaled = output.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
let context = CIContext()
guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { return nil }
return UIImage(cgImage: cgImage)
}
}
/// A payment request decoded from a QR code: the address plus whatever else the
/// code carried.
struct QRPayload {
var address: String
var amount: String?
var tag: UInt32?
var paymentId: String?
var memo: String?
}
enum QRCodeReader {
/// Reads the first QR code found in an image (used for photo-library import).
static func decodeText(in data: Data) -> String? {
guard let image = UIImage(data: data), let cgImage = image.cgImage else { return nil }
return decodeText(in: cgImage)
}
static func decodeText(in image: UIImage) -> String? {
guard let cgImage = image.cgImage else { return nil }
return decodeText(in: cgImage)
}
private static func decodeText(in cgImage: CGImage) -> String? {
if let text = decodeWithVision(cgImage) { return text }
// Vision's barcode detector does not work on the iOS simulator, so fall
// back to CoreImage, which does (and adds redundancy on device).
return decodeWithCoreImage(cgImage)
}
private static func decodeWithVision(_ cgImage: CGImage) -> String? {
let request = VNDetectBarcodesRequest()
request.symbologies = [.qr]
let handler = VNImageRequestHandler(cgImage: cgImage, orientation: .up)
try? handler.perform([request])
for observation in request.results ?? [] {
if let payload = observation.payloadStringValue, !payload.isEmpty {
return payload
}
}
return nil
}
private static func decodeWithCoreImage(_ cgImage: CGImage) -> String? {
let detector = CIDetector(ofType: CIDetectorTypeQRCode,
context: nil,
options: [CIDetectorAccuracy: CIDetectorAccuracyHigh])
let features = detector?.features(in: CIImage(cgImage: cgImage)) ?? []
for feature in features {
if let code = feature as? CIQRCodeFeature, let message = code.messageString, !message.isEmpty {
return message
}
}
return nil
}
/// Understands plain addresses as well as the payment URIs wallets emit:
/// `monero:` (tx_amount, tx_payment_id, tx_description) and `ripple:`
/// (amount, dt).
static func parse(_ raw: String) -> QRPayload? {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
var body = text
var query: [String: String] = [:]
if let separator = text.firstIndex(of: "?") {
body = String(text[text.startIndex..<separator])
let queryText = String(text[text.index(after: separator)...])
for pair in queryText.split(separator: "&") {
let parts = pair.split(separator: "=", maxSplits: 1)
guard let key = parts.first else { continue }
let value = parts.count > 1 ? String(parts[1]) : ""
query[key.lowercased()] = value.removingPercentEncoding ?? value
}
}
let lowered = body.lowercased()
if let range = lowered.range(of: "monero:") {
body = String(body[range.upperBound...])
} else if let range = lowered.range(of: "ripple:") {
body = String(body[range.upperBound...])
}
while body.hasPrefix("/") { body.removeFirst() }
let address = body.trimmingCharacters(in: .whitespacesAndNewlines)
guard !address.isEmpty else { return nil }
var payload = QRPayload(address: address)
payload.amount = query["tx_amount"] ?? query["amount"]
payload.memo = query["tx_description"] ?? query["name"] ?? query["message"]
payload.paymentId = query["tx_payment_id"] ?? query["payment_id"] ?? query["pid"]
if let tag = query["dt"] ?? query["destination_tag"] ?? query["destinationtag"] {
payload.tag = UInt32(tag)
}
return payload
}
}
enum AddressFormat {
private static let moneroAlphabet = Set("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz")
/// Monero addresses are 95 characters (primary/subaddress) or 106
/// (integrated), all base58.
static func isMonero(_ address: String) -> Bool {
let text = address.trimmingCharacters(in: .whitespacesAndNewlines)
guard text.count == 95 || text.count == 106 else { return false }
return text.allSatisfy { moneroAlphabet.contains($0) }
}
static func isXRP(_ address: String) -> Bool {
XRPKey.isValidAddress(address.trimmingCharacters(in: .whitespacesAndNewlines))
}
}
+114
View File
@@ -0,0 +1,114 @@
import Foundation
/// RIPEMD-160, needed for XRP account id derivation (there is no CryptoKit /
/// CommonCrypto implementation of this digest on iOS).
enum RIPEMD160 {
private static let roundLeft: [Int] = [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
7, 4, 13, 1, 10, 6, 15, 3, 12, 0, 9, 5, 2, 14, 11, 8,
3, 10, 14, 4, 9, 15, 8, 1, 2, 7, 0, 6, 13, 11, 5, 12,
1, 9, 11, 10, 0, 8, 12, 4, 13, 3, 7, 15, 14, 5, 6, 2,
4, 0, 5, 9, 7, 12, 2, 10, 14, 1, 3, 8, 11, 6, 15, 13,
]
private static let roundRight: [Int] = [
5, 14, 7, 0, 9, 2, 11, 4, 13, 6, 15, 8, 1, 10, 3, 12,
6, 11, 3, 7, 0, 13, 5, 10, 14, 15, 8, 12, 4, 9, 1, 2,
15, 5, 1, 3, 7, 14, 6, 9, 11, 8, 12, 2, 10, 0, 4, 13,
8, 6, 4, 1, 3, 11, 15, 0, 5, 12, 2, 13, 9, 7, 10, 14,
12, 15, 10, 4, 1, 5, 8, 7, 6, 2, 13, 14, 0, 3, 9, 11,
]
private static let shiftsLeft: [UInt32] = [
11, 14, 15, 12, 5, 8, 7, 9, 11, 13, 14, 15, 6, 7, 9, 8,
7, 6, 8, 13, 11, 9, 7, 15, 7, 12, 15, 9, 11, 7, 13, 12,
11, 13, 6, 7, 14, 9, 13, 15, 14, 8, 13, 6, 5, 12, 7, 5,
11, 12, 14, 15, 14, 15, 9, 8, 9, 14, 5, 6, 8, 6, 5, 12,
9, 15, 5, 11, 6, 8, 13, 12, 5, 12, 13, 14, 11, 8, 5, 6,
]
private static let shiftsRight: [UInt32] = [
8, 9, 9, 11, 13, 15, 15, 5, 7, 7, 8, 11, 14, 14, 12, 6,
9, 13, 15, 7, 12, 8, 9, 11, 7, 7, 12, 7, 6, 15, 13, 11,
9, 7, 15, 11, 8, 6, 6, 14, 12, 13, 5, 14, 13, 13, 7, 5,
15, 5, 8, 11, 14, 14, 6, 14, 6, 9, 12, 9, 12, 5, 15, 8,
8, 5, 12, 9, 12, 5, 14, 6, 8, 13, 6, 5, 15, 13, 11, 11,
]
private static let constantsLeft: [UInt32] = [0x0000_0000, 0x5A82_7999, 0x6ED9_EBA1, 0x8F1B_BCDC, 0xA953_FD4E]
private static let constantsRight: [UInt32] = [0x50A2_8BE6, 0x5C4D_D124, 0x6D70_3EF3, 0x7A6D_76E9, 0x0000_0000]
static func hash(_ message: [UInt8]) -> [UInt8] {
var padded = message
let bitLength = UInt64(message.count) * 8
padded.append(0x80)
while padded.count % 64 != 56 { padded.append(0) }
for shift in stride(from: 0, through: 56, by: 8) {
padded.append(UInt8((bitLength >> UInt64(shift)) & 0xff))
}
var h0: UInt32 = 0x6745_2301
var h1: UInt32 = 0xEFCD_AB89
var h2: UInt32 = 0x98BA_DCFE
var h3: UInt32 = 0x1032_5476
var h4: UInt32 = 0xC3D2_E1F0
var offset = 0
while offset < padded.count {
var words = [UInt32](repeating: 0, count: 16)
for index in 0..<16 {
let base = offset + index * 4
words[index] = UInt32(padded[base])
| (UInt32(padded[base + 1]) << 8)
| (UInt32(padded[base + 2]) << 16)
| (UInt32(padded[base + 3]) << 24)
}
var al = h0, bl = h1, cl = h2, dl = h3, el = h4
var ar = h0, br = h1, cr = h2, dr = h3, er = h4
for step in 0..<80 {
let round = step / 16
var leftTemp = rotate(al &+ f(round, bl, cl, dl) &+ words[roundLeft[step]] &+ constantsLeft[round], by: shiftsLeft[step]) &+ el
al = el; el = dl; dl = rotate(cl, by: 10); cl = bl; bl = leftTemp
let rightRound = 4 - round
leftTemp = rotate(ar &+ f(rightRound, br, cr, dr) &+ words[roundRight[step]] &+ constantsRight[round], by: shiftsRight[step]) &+ er
ar = er; er = dr; dr = rotate(cr, by: 10); cr = br; br = leftTemp
}
let temp = h1 &+ cl &+ dr
h1 = h2 &+ dl &+ er
h2 = h3 &+ el &+ ar
h3 = h4 &+ al &+ br
h4 = h0 &+ bl &+ cr
h0 = temp
offset += 64
}
var digest: [UInt8] = []
for word in [h0, h1, h2, h3, h4] {
digest.append(UInt8(word & 0xff))
digest.append(UInt8((word >> 8) & 0xff))
digest.append(UInt8((word >> 16) & 0xff))
digest.append(UInt8((word >> 24) & 0xff))
}
return digest
}
private static func f(_ round: Int, _ x: UInt32, _ y: UInt32, _ z: UInt32) -> UInt32 {
switch round {
case 0: return x ^ y ^ z
case 1: return (x & y) | (~x & z)
case 2: return (x | ~y) ^ z
case 3: return (x & z) | (y & ~z)
default: return x ^ (y | ~z)
}
}
private static func rotate(_ value: UInt32, by amount: UInt32) -> UInt32 {
(value << amount) | (value >> (32 - amount))
}
}
+116
View File
@@ -0,0 +1,116 @@
import Foundation
import Security
/// Small Keychain wrapper for wallet secrets. Secrets never touch UserDefaults.
enum Keychain {
private static let service = "com.koins.app"
static func set(_ value: String, for key: String) {
guard let data = value.data(using: .utf8) else { return }
remove(key)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
SecItemAdd(query as CFDictionary, nil)
}
static func get(_ key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data,
let value = String(data: data, encoding: .utf8) else {
return nil
}
return value
}
static func remove(_ key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
SecItemDelete(query as CFDictionary)
}
}
enum WalletStore {
private static let defaults = UserDefaults.standard
private static func walletKey(_ chain: Chain) -> String { "koins.wallet.\(chain.rawValue)" }
private static func secretKey(_ chain: Chain) -> String { "koins.secret.\(chain.rawValue)" }
static func wallet(_ chain: Chain) -> WalletRecord? {
guard let data = defaults.data(forKey: walletKey(chain)) else { return nil }
return try? JSONDecoder().decode(WalletRecord.self, from: data)
}
static func saveWallet(_ wallet: WalletRecord, chain: Chain) {
guard let data = try? JSONEncoder().encode(wallet) else { return }
defaults.set(data, forKey: walletKey(chain))
}
static func removeWallet(_ chain: Chain) {
defaults.removeObject(forKey: walletKey(chain))
defaults.removeObject(forKey: cacheKey(chain))
Keychain.remove(secretKey(chain))
}
// MARK: - Cached state (balance, transactions, accounts)
private static func cacheKey(_ chain: Chain) -> String { "koins.cache.\(chain.rawValue)" }
static func cachedState(_ chain: Chain) -> ChainCache? {
guard let data = defaults.data(forKey: cacheKey(chain)) else { return nil }
return try? JSONDecoder().decode(ChainCache.self, from: data)
}
static func saveCachedState(_ state: ChainCache, chain: Chain) {
guard let data = try? JSONEncoder().encode(state) else { return }
defaults.set(data, forKey: cacheKey(chain))
}
// MARK: - Swap orders (kyc.rip)
private static let swapOrdersKey = "koins.swapOrders"
static func swapOrders() -> [SwapOrder] {
guard let data = defaults.data(forKey: swapOrdersKey),
let orders = try? JSONDecoder().decode([SwapOrder].self, from: data) else { return [] }
return orders
}
static func saveSwapOrder(_ order: SwapOrder) {
var orders = swapOrders()
orders.removeAll { $0.id == order.id }
orders.insert(order, at: 0)
guard let data = try? JSONEncoder().encode(orders) else { return }
defaults.set(data, forKey: swapOrdersKey)
}
static func removeSwapOrder(id: String) {
var orders = swapOrders()
orders.removeAll { $0.id == id }
guard let data = try? JSONEncoder().encode(orders) else { return }
defaults.set(data, forKey: swapOrdersKey)
}
static func secret(_ chain: Chain) -> String? {
Keychain.get(secretKey(chain))
}
static func saveSecret(_ secret: String, chain: Chain) {
Keychain.set(secret, for: secretKey(chain))
}
}
+115
View File
@@ -0,0 +1,115 @@
import SwiftUI
/// A stack of account cards, Cake Wallet style: the front card is full size and
/// interactive, the rest peek from behind (scaled down and offset upwards).
/// Drag the front card sideways or down to send it to the back of the deck.
struct AccountStack: View {
var accounts: [MoneroAccount]
/// Converts an account's XMR amount to the chosen fiat currency, if a price
/// is available.
var fiat: (String) -> String?
var onQR: (MoneroAccount) -> Void
@State private var frontIndex = 0
@State private var dragOffset = CGSize.zero
private let maxDepth = 3
var body: some View {
ZStack(alignment: .top) {
ForEach(Array(accounts.enumerated()), id: \.element.id) { index, account in
let depth = depth(of: index)
accountCard(account, depth: depth)
.offset(x: depth == 0 ? dragOffset.width : 0,
y: depth == 0 ? dragOffset.height : 0)
.gesture(depth == 0 ? frontDrag : nil)
.allowsHitTesting(depth == 0)
.zIndex(Double(accounts.count - depth))
}
}
.frame(height: 190 + CGFloat(min(accounts.count - 1, maxDepth)) * 12)
.accessibilityHint("Drag the front card to cycle accounts")
}
// MARK: - Layout
/// How far behind the front an account sits (0 = front).
private func depth(of index: Int) -> Int {
(index - frontIndex + accounts.count) % accounts.count
}
private func accountCard(_ account: MoneroAccount, depth: Int) -> some View {
Card {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text(account.title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
Spacer()
Text("\(depth + 1) of \(accounts.count)")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
VStack(alignment: .leading, spacing: 3) {
Text("\(account.balance) XMR")
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundStyle(Theme.text)
if let fiat = fiat(account.balance) {
Text(fiat)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
}
if account.unlocked != account.balance {
Text("\(account.unlocked) XMR unlocked")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
HStack(spacing: 8) {
Text(account.address)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Theme.muted)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 6)
Button {
onQR(account)
} label: {
Image(systemName: "qrcode")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.accent)
.frame(width: 34, height: 34)
.background(Theme.surfaceAlt)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
}
}
// Cards behind the front one peek from the top, slightly smaller.
.scaleEffect(1 - 0.045 * CGFloat(min(depth, maxDepth)))
.offset(y: -CGFloat(min(depth, maxDepth)) * 12)
.opacity(depth > maxDepth ? 0 : 1)
}
// MARK: - Interaction
private var frontDrag: some Gesture {
DragGesture()
.onChanged { value in
dragOffset = value.translation
}
.onEnded { value in
let threshold: CGFloat = 90
let shouldAdvance = abs(value.translation.width) > threshold
|| value.translation.height > threshold
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
dragOffset = .zero
if shouldAdvance {
frontIndex = (frontIndex + 1) % accounts.count
}
}
}
}
}
+151
View File
@@ -0,0 +1,151 @@
import SwiftUI
struct AccountsView: View {
@EnvironmentObject private var model: AppModel
@EnvironmentObject private var drawers: DrawerCoordinator
@Binding var route: Route
@State private var error: String?
@State private var busy = false
@State private var showAccountPrompt = false
@State private var showSubaddressPrompt = false
@State private var pendingLabel = ""
@State private var subaddressAccount = 0
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: "Accounts & addresses",
subtitle: "Each address gets its own QR code",
onBack: { route = .home })
PrimaryButton(title: busy ? "Working…" : "New account", disabled: busy) {
showAccountPrompt = true
}
if let error {
MessageBanner(message: error, isError: true)
}
ForEach(model.moneroAccounts) { account in
accountCard(account)
}
if model.moneroAccounts.isEmpty {
Text("No accounts yet. Accounts and addresses appear after the wallet opens.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
}
.task(id: model.moneroReady) { await model.loadAccounts() }
.alert("Account name", isPresented: $showAccountPrompt) {
TextField("e.g. Savings", text: $pendingLabel)
Button("Create") { createAccount() }
Button("Cancel", role: .cancel) { pendingLabel = "" }
}
.alert("Address label", isPresented: $showSubaddressPrompt) {
TextField("e.g. From Alice", text: $pendingLabel)
Button("Create") { createSubaddress() }
Button("Cancel", role: .cancel) { pendingLabel = "" }
}
}
private func accountCard(_ account: MoneroAccount) -> some View {
Card {
VStack(alignment: .leading, spacing: 10) {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(account.title)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.text)
Text("\(account.balance) XMR")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
Spacer()
}
addressRow(title: "Primary address",
address: account.address,
isUsed: true)
if !account.subaddresses.isEmpty {
Divider().overlay(Theme.border)
ForEach(account.subaddresses.filter { $0.index != 0 }) { subaddress in
addressRow(title: subaddress.label.isEmpty ? "Address \(subaddress.index)" : subaddress.label,
address: subaddress.address,
isUsed: subaddress.isUsed,
balance: subaddress.balance)
}
}
SecondaryButton(title: "New address in this account") {
subaddressAccount = account.index
pendingLabel = ""
showSubaddressPrompt = true
}
}
}
}
private func addressRow(title: String, address: String, isUsed: Bool, balance: String? = nil) -> some View {
Button {
drawers.qr = AddressQR(title: title, address: address)
} label: {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Text(address.isEmpty ? "—" : address)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Theme.text)
.lineLimit(1)
.truncationMode(.middle)
if let balance {
Text("\(balance) XMR\(isUsed ? "" : " · unused")")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
Spacer(minLength: 6)
Image(systemName: "qrcode")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Theme.accent)
.frame(width: 32, height: 32)
.background(Theme.surfaceAlt)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(address.isEmpty)
}
private func createAccount() {
busy = true
error = nil
Task {
defer { busy = false }
do {
try await model.addAccount(label: pendingLabel)
pendingLabel = ""
} catch {
self.error = error.localizedDescription
}
}
}
private func createSubaddress() {
busy = true
error = nil
Task {
defer { busy = false }
do {
try await model.addSubaddress(accountIndex: subaddressAccount, label: pendingLabel)
pendingLabel = ""
} catch {
self.error = error.localizedDescription
}
}
}
}
+46
View File
@@ -0,0 +1,46 @@
import SwiftUI
struct AddressQR: Identifiable {
let id = UUID()
let title: String
let address: String
}
/// One address as a scannable QR, shown inside the shared bottom drawer.
struct AddressQRSheet: View {
var title: String
var address: String
var onDone: () -> Void
@State private var copied = false
var body: some View {
VStack(spacing: 16) {
Text(title)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Theme.text)
.frame(maxWidth: .infinity, alignment: .leading)
QRCodeView(text: address)
.frame(maxWidth: 220)
Text(address)
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(Theme.muted)
.multilineTextAlignment(.center)
.lineLimit(4)
.truncationMode(.middle)
.textSelection(.enabled)
SecondaryButton(title: copied ? "Copied" : "Copy address") {
UIPasteboard.general.string = address
copied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false }
}
PrimaryButton(title: "Done", action: onDone)
}
.padding(.horizontal, 22)
.preferredColorScheme(.dark)
}
}
+101
View File
@@ -0,0 +1,101 @@
import SwiftUI
import UIKit
extension View {
/// Presents a bottom-anchored drawer: it fills to the screen's bottom edge
/// (no gap), shows a dimmed backdrop, and can be dragged down to dismiss.
func bottomDrawer<Content: View>(isPresented: Binding<Bool>,
@ViewBuilder content: @escaping () -> Content) -> some View {
BottomDrawerHost(host: self, isPresented: isPresented, content: content)
}
}
/// One place for the drawers shown above every screen. Attaching them at the
/// root keeps them anchored to the window instead of to scrolled content.
@MainActor
final class DrawerCoordinator: ObservableObject {
@Published var transaction: TransactionRecord?
@Published var qr: AddressQR?
@Published var settings = false
}
private struct BottomDrawerHost<Host: View, Content: View>: View {
let host: Host
@Binding var isPresented: Bool
@ViewBuilder var content: () -> Content
@State private var dragOffset: CGFloat = 0
private var bottomInset: CGFloat {
for scene in UIApplication.shared.connectedScenes {
guard let windowScene = scene as? UIWindowScene else { continue }
if let window = windowScene.keyWindow {
return window.safeAreaInsets.bottom
}
}
return 0
}
private var maxHeight: CGFloat {
for scene in UIApplication.shared.connectedScenes {
guard let windowScene = scene as? UIWindowScene else { continue }
if let window = windowScene.keyWindow {
return window.bounds.height * 0.92
}
}
return 800
}
var body: some View {
ZStack(alignment: .bottom) {
host
if isPresented {
Color.black.opacity(0.5)
.ignoresSafeArea()
.onTapGesture { dismiss() }
.transition(.opacity)
VStack(spacing: 0) {
Capsule()
.fill(Theme.muted.opacity(0.55))
.frame(width: 36, height: 5)
.padding(.top, 8)
content()
.padding(.top, 14)
.padding(.bottom, 16)
}
.frame(maxWidth: .infinity, alignment: .top)
.frame(maxHeight: maxHeight)
.background(Theme.surface)
.clipShape(UnevenRoundedRectangle(topLeadingRadius: 20, topTrailingRadius: 20, style: .continuous))
// Push the panel's background down to the physical screen edge without
// taking the host content out of its safe area.
.offset(y: dragOffset + bottomInset)
.gesture(dismissDrag)
.transition(.move(edge: .bottom))
}
}
.animation(.spring(response: 0.32, dampingFraction: 0.86), value: isPresented)
}
private func dismiss() {
isPresented = false
dragOffset = 0
}
private var dismissDrag: some Gesture {
DragGesture()
.onChanged { value in
dragOffset = max(0, value.translation.height)
}
.onEnded { value in
if value.translation.height > 120 || value.predictedEndTranslation.height > 200 {
dismiss()
} else {
dragOffset = 0
}
}
}
}
+378
View File
@@ -0,0 +1,378 @@
import CoreImage.CIFilterBuiltins
import SwiftUI
enum Theme {
static let background = Color(red: 0.035, green: 0.035, blue: 0.05)
static let surface = Color(red: 0.09, green: 0.09, blue: 0.12)
static let surfaceAlt = Color(red: 0.13, green: 0.13, blue: 0.17)
static let border = Color.white.opacity(0.08)
static let accent = Color(red: 1.0, green: 0.56, blue: 0.16)
static let accentSoft = Color(red: 1.0, green: 0.56, blue: 0.16).opacity(0.15)
static let text = Color.white
static let muted = Color.white.opacity(0.55)
static let danger = Color(red: 1.0, green: 0.36, blue: 0.36)
static let success = Color(red: 0.35, green: 0.85, blue: 0.55)
}
struct PrimaryButton: View {
var title: String
var disabled: Bool = false
var action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.system(size: 16, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 15)
.background(disabled ? Theme.surfaceAlt : Theme.accent)
.foregroundStyle(disabled ? Theme.muted : Color.black)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.disabled(disabled)
}
}
struct SecondaryButton: View {
var title: String
var action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.system(size: 15, weight: .medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Theme.surface)
.foregroundStyle(Theme.text)
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
}
/// A full-width button that opens a web page, styled like SecondaryButton.
struct LinkButton: View {
var title: String
var systemImage: String = "safari"
var url: URL
var body: some View {
Link(destination: url) {
Label(title, systemImage: systemImage)
.font(.system(size: 15, weight: .medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Theme.surface)
.foregroundStyle(Theme.text)
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
}
struct Card<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
content
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Theme.surface)
.overlay(RoundedRectangle(cornerRadius: 20, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
}
}
struct FieldLabel: View {
var text: String
var body: some View {
Text(text)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
.padding(.leading, 4)
}
}
struct KoinsTextField: View {
var placeholder: String
@Binding var text: String
var multiline = false
var keyboard: UIKeyboardType = .default
var autocapitalization: TextInputAutocapitalization = .never
var body: some View {
Group {
if multiline {
TextEditor(text: $text)
.scrollContentBackground(.hidden)
.frame(minHeight: 96)
.padding(8)
} else {
TextField(placeholder, text: $text)
.keyboardType(keyboard)
.textInputAutocapitalization(autocapitalization)
.autocorrectionDisabled()
.padding(.horizontal, 14)
.padding(.vertical, 13)
}
}
.font(.system(size: 15))
.foregroundStyle(Theme.text)
.background(Theme.surface)
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
struct MessageBanner: View {
var message: String
var isError: Bool
var body: some View {
Text(message)
.font(.system(size: 13))
.foregroundStyle(isError ? Theme.danger : Theme.success)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background((isError ? Theme.danger : Theme.success).opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
struct ChainPicker: View {
@Binding var selection: Chain
var body: some View {
HStack(spacing: 6) {
ForEach(Chain.allCases, id: \.self) { chain in
Button {
selection = chain
} label: {
Text(chain.title)
.font(.system(size: 13, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(selection == chain ? Theme.accent : Color.clear)
.foregroundStyle(selection == chain ? Color.black : Theme.muted)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
}
.padding(4)
.background(Theme.surface)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
/// Picks which Monero account a payment leaves from. Each account has its own
/// balance, so "Max" and the wallet's spend checks need the matching index.
struct MoneroAccountPicker: View {
var accounts: [MoneroAccount]
@Binding var selection: Int
var onSelect: (() -> Void)?
private var selected: MoneroAccount? {
accounts.first(where: { $0.index == selection }) ?? accounts.first
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: "Send from")
Menu {
ForEach(accounts) { account in
Button {
selection = account.index
onSelect?()
} label: {
Text("\(account.title) · \(account.balance) XMR")
}
}
} label: {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text(selected?.title ?? "Primary account")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(Theme.text)
Text("\(selected?.unlocked ?? "0") XMR available")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
Spacer()
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.muted)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(Theme.surface)
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
}
}
struct QRCodeView: View {
var text: String
var body: some View {
if let image = QRCodeView.makeImage(from: text) {
Image(uiImage: image)
.interpolation(.none)
.resizable()
.scaledToFit()
.padding(12)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
} else {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Theme.surfaceAlt)
.aspectRatio(1, contentMode: .fit)
}
}
private static func makeImage(from text: String) -> UIImage? {
QRCodeImage.make(text)
}
}
struct TransactionRow: View {
var record: TransactionRecord
var symbol: String
private var subtitle: String {
if let timestamp = record.timestamp {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
return record.confirmed ? formatter.string(from: timestamp) : "Pending confirmation"
}
// Monero transfers carry no timestamp; the block height is the honest label.
if let height = record.height, height > 0 {
return record.confirmed ? "Block \(height)" : "In the transaction pool"
}
return record.confirmed ? "Confirmed" : "Pending"
}
var body: some View {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(record.direction == .incoming ? Theme.success.opacity(0.15) : Theme.accentSoft)
.frame(width: 38, height: 38)
Image(systemName: record.direction == .incoming ? "arrow.down.left" : "arrow.up.right")
.font(.system(size: 15, weight: .bold))
.foregroundStyle(record.direction == .incoming ? Theme.success : Theme.accent)
}
VStack(alignment: .leading, spacing: 3) {
Text(record.direction == .incoming ? "Received" : "Sent")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Theme.text)
Text(subtitle)
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
Spacer()
VStack(alignment: .trailing, spacing: 3) {
Text("\(record.direction == .incoming ? "+" : "-")\(record.amount) \(symbol)")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(record.direction == .incoming ? Theme.success : Theme.text)
if let fee = record.fee, !fee.isEmpty, record.direction == .outgoing {
Text("fee \(fee)")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
}
.padding(.vertical, 6)
// Buttons only respond where the label has content; the middle of the row
// is a Spacer, so without this the gap does not accept taps.
.contentShape(Rectangle())
}
}
struct ScreenHeader: View {
var title: String
var subtitle: String?
var onBack: (() -> Void)?
var body: some View {
HStack(alignment: .center, spacing: 12) {
if let onBack {
Button(action: onBack) {
Image(systemName: "chevron.left")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Theme.text)
.frame(width: 34, height: 34)
.background(Theme.surface)
.clipShape(Circle())
}
}
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.system(size: 22, weight: .bold))
.foregroundStyle(Theme.text)
if let subtitle {
Text(subtitle)
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
}
Spacer()
}
}
}
/// Monero's chain sync as its own section, rather than buried in the wallet card.
struct SyncCard: View {
@EnvironmentObject private var model: AppModel
var body: some View {
Card {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Sync")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
Spacer()
if let status = model.syncStatus { SyncBadge(status: status) }
}
if let status = model.syncStatus {
SyncProgress(status: status, eta: model.syncEta)
} else {
Text("Sync status is not available yet.")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
SecondaryButton(title: model.syncStatus?.syncing == true ? "Syncing…" : "Sync now") {
Task { await model.startMoneroSync() }
}
}
}
}
}
/// A small circular icon button for utility actions (refresh, delete, …).
struct IconButton: View {
var systemName: String
var destructive = false
var action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(destructive ? Theme.danger : Theme.muted)
.frame(width: 42, height: 42)
.background(Theme.surface)
.overlay(Circle().stroke(Theme.border))
.clipShape(Circle())
}
}
}
+337
View File
@@ -0,0 +1,337 @@
import PhotosUI
import SwiftUI
struct DashboardView: View {
@EnvironmentObject private var model: AppModel
@EnvironmentObject private var drawers: DrawerCoordinator
@Binding var route: Route
@State private var showSeed = false
@State private var revealedSeed: String?
@State private var seedError: String?
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: model.wallet?.name ?? "Wallet",
subtitle: model.chain.title,
onBack: { route = .home })
Card {
VStack(alignment: .leading, spacing: 10) {
Text("Balance")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Theme.muted)
Text("\(model.balance.total) \(model.symbol)")
.font(.system(size: 32, weight: .bold, design: .rounded))
.foregroundStyle(Theme.text)
if let fiat = model.fiatBalance {
Text("\(fiat)")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Theme.muted)
}
if model.chain == .xmr, model.balance.unlocked != model.balance.total {
Text("\(model.balance.unlocked) \(model.symbol) spendable now")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
AddressRow(address: model.displayAddress)
}
}
if model.chain == .xmr {
Card {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Sync")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.text)
Spacer()
if let status = model.syncStatus { SyncBadge(status: status) }
}
if let status = model.syncStatus {
SyncProgress(status: status, eta: model.syncEta)
} else {
Text("Sync status is not available yet.")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
}
}
HStack(spacing: 10) {
PrimaryButton(title: "Send") { route = .send }
SecondaryButton(title: "Receive") { route = .receive }
}
if !model.transactions.isEmpty {
Card {
VStack(alignment: .leading, spacing: 10) {
Text("Activity")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
ForEach(Array(model.transactions.enumerated()), id: \.offset) { index, record in
Button {
drawers.transaction = record
} label: {
TransactionRow(record: record, symbol: model.symbol)
}
.buttonStyle(.plain)
if index < model.transactions.count - 1 {
Divider().overlay(Theme.border)
}
}
}
}
}
if let message = model.errorMessage {
MessageBanner(message: message, isError: true)
}
if let message = model.statusMessage {
MessageBanner(message: message, isError: false)
}
SecondaryButton(title: "Reveal recovery phrase") {
Task {
seedError = nil
do {
revealedSeed = try await model.revealSeed()
showSeed = true
} catch {
seedError = error.localizedDescription
}
}
}
if let seedError {
MessageBanner(message: seedError, isError: true)
}
}
.sheet(isPresented: $showSeed) {
if let revealedSeed {
SeedRevealView(seed: revealedSeed, symbol: model.symbol) { showSeed = false }
}
}
}
}
struct SendView: View {
@EnvironmentObject private var model: AppModel
@Binding var route: Route
@State private var destination = ""
@State private var amount = ""
@State private var tag = ""
@State private var priority: TransactionPriority = .defaultPriority
@State private var accountIndex = 0
@State private var accountPicked = false
@State private var localError: String?
@State private var status: String?
@State private var showScanner = false
@State private var photoItem: PhotosPickerItem?
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: "Send \(model.symbol)",
subtitle: "Balance \(model.balance.total) \(model.symbol)",
onBack: { route = .home })
HStack(spacing: 10) {
SecondaryButton(title: "Scan QR") { showScanner = true }
PhotosPicker(selection: $photoItem, matching: .images, photoLibrary: .shared()) {
Text("Photo")
.font(.system(size: 15, weight: .medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Theme.surface)
.foregroundStyle(Theme.text)
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
if model.chain == .xmr, model.moneroAccounts.count > 1 {
MoneroAccountPicker(accounts: model.moneroAccounts,
selection: $accountIndex,
onSelect: { accountPicked = true })
}
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: model.chain == .xrp ? "Destination address" : "Recipient address")
KoinsTextField(placeholder: model.chain == .xrp ? "r…" : "4…", text: $destination)
}
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: "Amount (\(model.symbol))")
KoinsTextField(placeholder: "0.0", text: $amount, keyboard: .decimalPad)
}
if model.chain == .xrp {
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: "Destination tag (optional)")
KoinsTextField(placeholder: "e.g. 12345", text: $tag, keyboard: .numberPad)
}
}
if model.chain == .xmr {
VStack(alignment: .leading, spacing: 8) {
FieldLabel(text: "Priority")
Picker("Priority", selection: $priority) {
ForEach([TransactionPriority.low, .medium, .high, .defaultPriority], id: \.rawValue) { item in
Text(item.label).tag(item)
}
}
.pickerStyle(.segmented)
}
}
if let message = localError ?? model.errorMessage {
MessageBanner(message: message, isError: true)
}
if let message = status {
MessageBanner(message: message, isError: false)
}
if let message = model.statusMessage {
MessageBanner(message: message, isError: false)
}
PrimaryButton(title: model.isBusy ? "Sending…" : "Send", disabled: model.isBusy) {
Task {
localError = nil
model.clearMessages()
do {
try await model.send(to: destination,
amount: amount,
tag: tag,
priority: priority,
accountIndex: accountIndex)
amount = ""
tag = ""
route = .dashboard
} catch {
localError = error.localizedDescription
}
}
}
Spacer(minLength: 0)
}
.sheet(isPresented: $showScanner) {
QRScannerView(onScan: { value in
showScanner = false
applyScanned(value)
}, onCancel: {
showScanner = false
})
.ignoresSafeArea()
}
.onChange(of: photoItem) { item in
guard let item else { return }
Task {
if let data = try? await item.loadTransferable(type: Data.self) {
if let value = QRCodeReader.decodeText(in: data) {
applyScanned(value)
} else {
localError = "No QR code found in that image"
}
} else {
localError = "That image could not be read"
}
photoItem = nil
}
}
.onAppear {
if !accountPicked { accountIndex = model.defaultMoneroAccountIndex }
}
.onChange(of: model.moneroAccounts.count) { _ in
if !accountPicked { accountIndex = model.defaultMoneroAccountIndex }
}
}
/// Fills the form from a scanned code, accepting both plain addresses and
/// wallet payment URIs (`monero:…?tx_amount=`, `ripple:…?amount=&dt=`).
private func applyScanned(_ value: String) {
localError = nil
status = nil
guard let payload = QRCodeReader.parse(value) else {
localError = "That QR code is not a payment request"
return
}
let isForThisChain = model.chain == .xrp
? AddressFormat.isXRP(payload.address)
: AddressFormat.isMonero(payload.address)
guard isForThisChain else {
localError = model.chain == .xrp
? "That looks like a Monero address. Switch to Monero to use it."
: "That looks like an XRP address. Switch to XRP to use it."
return
}
destination = payload.address
if let scannedAmount = payload.amount, !scannedAmount.isEmpty {
amount = scannedAmount
}
if model.chain == .xrp, let scannedTag = payload.tag {
tag = String(scannedTag)
}
var filled: [String] = ["address"]
if payload.amount?.isEmpty == false { filled.append("amount") }
if payload.tag != nil { filled.append("tag") }
if payload.paymentId?.isEmpty == false { filled.append("payment ID") }
status = "Filled \(filled.joined(separator: ", ")) from the QR code"
}
}
struct ReceiveView: View {
@EnvironmentObject private var model: AppModel
@Binding var route: Route
private var address: String {
model.displayAddress
}
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: "Receive \(model.symbol)",
subtitle: "Share this address to get paid",
onBack: { route = .home })
Card {
VStack(spacing: 16) {
QRCodeView(text: address)
.frame(maxWidth: 240)
Text(address)
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(Theme.muted)
.multilineTextAlignment(.center)
.lineLimit(3)
.truncationMode(.middle)
}
.frame(maxWidth: .infinity)
}
SecondaryButton(title: "Copy address") {
UIPasteboard.general.string = address
}
if model.chain == .xrp {
Card {
Text("XRP accounts must hold at least 1 XRP as a reserve to be active. Only send XRP on the XRP Ledger to this address.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
} else {
Card {
Text("Send only Monero (XMR) to this address. Transfers need confirmations before the balance becomes spendable.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
}
Spacer(minLength: 0)
}
}
}
+30
View File
@@ -0,0 +1,30 @@
import SwiftUI
/// Full-screen biometric lock, overlaid on the app whenever `model.locked`.
struct LockScreen: View {
var unlock: () -> Void
var body: some View {
ZStack {
Theme.background.ignoresSafeArea()
VStack(spacing: 16) {
Spacer()
Image(systemName: "lock.shield")
.font(.system(size: 44, weight: .light))
.foregroundStyle(Theme.accent)
Text("Koins is locked")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(Theme.text)
Text("Authenticate to continue")
.font(.system(size: 13))
.foregroundStyle(Theme.muted)
Spacer()
PrimaryButton(title: "Unlock with \(Biometrics.label)", action: unlock)
Spacer()
}
.padding(24)
}
.preferredColorScheme(.dark)
.task { unlock() }
}
}
+229
View File
@@ -0,0 +1,229 @@
import AVFoundation
import SwiftUI
import UIKit
/// Camera QR scanner, presented as a sheet.
///
/// Notes on the shape of this code, since a black preview is easy to ship:
/// the preview layer is only created once the camera is authorised, which can
/// happen *after* the last layout pass — so its frame is applied at creation
/// time as well as in `viewDidLayoutSubviews`, otherwise it stays zero-sized
/// and renders as a black rectangle.
struct QRScannerView: UIViewControllerRepresentable {
var onScan: (String) -> Void
var onCancel: () -> Void
func makeUIViewController(context: Context) -> ScannerViewController {
let controller = ScannerViewController()
controller.onScan = onScan
controller.onCancel = onCancel
return controller
}
func updateUIViewController(_ controller: ScannerViewController, context: Context) {}
}
final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
var onScan: ((String) -> Void)?
var onCancel: (() -> Void)?
private let session = AVCaptureSession()
private let sessionQueue = DispatchQueue(label: "com.koins.qr.session")
private var previewLayer: AVCaptureVideoPreviewLayer?
private var isConfigured = false
private var hasScanned = false
private let statusLabel = UILabel()
private let settingsButton = UIButton(type: .system)
private let viewfinder = UIView()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
let cancel = UIButton(type: .system)
cancel.setTitle("Cancel", for: .normal)
cancel.setTitleColor(.white, for: .normal)
cancel.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
cancel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(cancel)
viewfinder.layer.borderColor = UIColor.white.withAlphaComponent(0.6).cgColor
viewfinder.layer.borderWidth = 2
viewfinder.layer.cornerRadius = 18
viewfinder.isUserInteractionEnabled = false
viewfinder.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(viewfinder)
statusLabel.numberOfLines = 0
statusLabel.textAlignment = .center
statusLabel.textColor = .white
statusLabel.font = .systemFont(ofSize: 14)
statusLabel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(statusLabel)
settingsButton.setTitle("Open Settings", for: .normal)
settingsButton.setTitleColor(.systemOrange, for: .normal)
settingsButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold)
settingsButton.addTarget(self, action: #selector(openSettings), for: .touchUpInside)
settingsButton.isHidden = true
settingsButton.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(settingsButton)
NSLayoutConstraint.activate([
cancel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
cancel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
viewfinder.centerXAnchor.constraint(equalTo: view.centerXAnchor),
viewfinder.centerYAnchor.constraint(equalTo: view.centerYAnchor),
viewfinder.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.68),
viewfinder.heightAnchor.constraint(equalTo: viewfinder.widthAnchor),
statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32),
statusLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32),
settingsButton.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 16),
settingsButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
])
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
begin()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// Keeps the preview sized even if it was created after the last layout pass.
previewLayer?.frame = view.layer.bounds
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
sessionQueue.async { [session] in
if session.isRunning { session.stopRunning() }
}
}
@objc private func cancelTapped() {
onCancel?()
}
@objc private func openSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
// MARK: - Session
private func begin() {
guard AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) != nil else {
showHelp("This device has no camera. Use “Photo” to read a QR from your library.")
return
}
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
start()
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
DispatchQueue.main.async {
guard let self else { return }
if granted {
self.start()
} else {
self.showHelp("Camera access is off. Turn it on in Settings, or use “Photo”.")
self.settingsButton.isHidden = false
}
}
}
default:
showHelp("Camera access is off. Turn it on in Settings, or use “Photo”.")
settingsButton.isHidden = false
}
}
private func start() {
statusLabel.isHidden = true
settingsButton.isHidden = true
if !isConfigured { configure() }
guard isConfigured else { return }
sessionQueue.async { [session] in
if !session.isRunning { session.startRunning() }
}
}
private func configure() {
session.beginConfiguration()
session.sessionPreset = .high
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else {
session.commitConfiguration()
showHelp("The camera could not be started. Use “Photo” instead.")
return
}
session.addInput(input)
let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else {
session.commitConfiguration()
showHelp("The camera could not be started. Use “Photo” instead.")
return
}
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main)
// Reading a type that is not available raises an Objective-C exception, so
// check first (it is only populated once the session has an input).
if output.availableMetadataObjectTypes.contains(.qr) {
output.metadataObjectTypes = [.qr]
} else {
session.commitConfiguration()
showHelp("This camera cannot read QR codes.")
return
}
session.commitConfiguration()
let layer = AVCaptureVideoPreviewLayer(session: session)
layer.videoGravity = .resizeAspectFill
layer.frame = view.layer.bounds
view.layer.insertSublayer(layer, at: 0)
previewLayer = layer
isConfigured = true
view.setNeedsLayout()
NotificationCenter.default.addObserver(self,
selector: #selector(sessionFailed),
name: .AVCaptureSessionRuntimeError,
object: session)
}
@objc private func sessionFailed(_ note: Notification) {
showHelp("The camera stopped unexpectedly. Close and try again.")
}
private func showHelp(_ text: String) {
statusLabel.text = text
statusLabel.isHidden = false
viewfinder.isHidden = true
}
// MARK: - Detection
func metadataOutput(_ output: AVCaptureMetadataOutput,
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection) {
guard !hasScanned else { return }
guard let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
let value = object.stringValue, !value.isEmpty else { return }
hasScanned = true
sessionQueue.async { [session] in
if session.isRunning { session.stopRunning() }
}
UINotificationFeedbackGenerator().notificationOccurred(.success)
onScan?(value)
}
}
+453
View File
@@ -0,0 +1,453 @@
import SwiftUI
enum Route: Equatable {
case home
case create
case importWallet
case dashboard
case send
case receive
case accounts
case swap
}
struct SeedBox: Identifiable {
let id = UUID()
let seed: String
let symbol: String
}
struct RootView: View {
@EnvironmentObject private var model: AppModel
@Environment(\.scenePhase) private var scenePhase
@State private var route: Route = RootView.initialRoute
private static var initialRoute: Route {
let arguments = ProcessInfo.processInfo.arguments
if arguments.contains("-koinsScreenAccounts") { return .accounts }
if arguments.contains("-koinsScreenSend") { return .send }
if arguments.contains("-koinsScreenReceive") { return .receive }
if arguments.contains("-koinsScreenSwap") { return .swap }
return .home
}
@State private var seedBox: SeedBox?
@StateObject private var drawers = DrawerCoordinator()
var body: some View {
ZStack {
Theme.background.ignoresSafeArea()
ScrollView {
VStack(spacing: 18) {
switch route {
case .home:
HomeView(route: $route)
case .create:
CreateWalletView(route: $route, seedBox: $seedBox)
case .importWallet:
ImportWalletView(route: $route)
case .dashboard:
DashboardView(route: $route)
case .send:
SendView(route: $route)
case .receive:
ReceiveView(route: $route)
case .accounts:
AccountsView(route: $route)
case .swap:
SwapView(route: $route)
}
}
.padding(.horizontal, 20)
.padding(.top, 12)
.padding(.bottom, 40)
}
.scrollDismissesKeyboard(.interactively)
}
.preferredColorScheme(.dark)
.task { await model.bootstrap() }
.sheet(item: $seedBox) { box in
SeedRevealView(seed: box.seed, symbol: box.symbol) { seedBox = nil }
}
.bottomDrawer(isPresented: Binding(
get: { drawers.transaction != nil },
set: { if !$0 { drawers.transaction = nil } }
)) {
if let record = drawers.transaction {
TransactionDetailView(record: record,
symbol: model.symbol,
chain: model.chain) { drawers.transaction = nil }
}
}
.bottomDrawer(isPresented: Binding(
get: { drawers.qr != nil },
set: { if !$0 { drawers.qr = nil } }
)) {
if let item = drawers.qr {
AddressQRSheet(title: item.title, address: item.address) { drawers.qr = nil }
}
}
.bottomDrawer(isPresented: $drawers.settings) {
SettingsView()
}
.environmentObject(drawers)
// Above the drawers so a lock always hides whatever was on screen.
.overlay {
if model.locked, model.faceIDEnabled {
LockScreen {
Task { await model.authenticate() }
}
}
}
.onChange(of: model.chain) { _ in
if route == .dashboard { route = .home }
}
.onChange(of: scenePhase) { phase in
if phase == .background, model.faceIDEnabled, Biometrics.isAvailable {
model.locked = true
}
if phase != .active {
Task { await model.persistWallet() }
}
}
}
}
struct HomeView: View {
@EnvironmentObject private var model: AppModel
@EnvironmentObject private var drawers: DrawerCoordinator
@Binding var route: Route
var body: some View {
VStack(spacing: 18) {
header
if model.hasWallet, model.chain == .xrp || model.moneroReady {
walletCard
} else if model.hasWallet {
moneroUnavailableCard
} else {
emptyState
}
if let message = model.errorMessage {
MessageBanner(message: message, isError: true)
}
if let message = model.statusMessage {
MessageBanner(message: message, isError: false)
}
}
}
private var header: some View {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text("Koins")
.font(.system(size: 30, weight: .heavy))
.foregroundStyle(Theme.text)
Text("Monero and XRP wallet")
.font(.system(size: 13))
.foregroundStyle(Theme.muted)
}
Spacer()
Button {
drawers.settings = true
} label: {
Image(systemName: "gearshape")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(Theme.muted)
.frame(width: 36, height: 36)
.background(Theme.surface)
.clipShape(Circle())
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var walletCard: some View {
VStack(spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(model.wallet?.name ?? "Wallet")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Theme.text)
Text(model.chain.title)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Theme.muted)
}
Spacer()
}
VStack(alignment: .leading, spacing: 4) {
Text("Balance")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Theme.muted)
Text("\(model.balance.total) \(model.symbol)")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(Theme.text)
if let fiat = model.fiatBalance {
Text("\(fiat)")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Theme.muted)
}
if model.chain == .xmr, model.balance.unlocked != model.balance.total {
Text("\(model.balance.unlocked) \(model.symbol) unlocked")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
if model.chain == .xrp {
AddressRow(address: model.displayAddress)
}
}
}
if model.chain == .xmr, !model.moneroAccounts.isEmpty {
// Cake Wallet style: a deck of account cards as its own section, drag
// the front card away to bring the next one up.
AccountStack(accounts: model.moneroAccounts,
fiat: { model.fiatValue(of: $0, chain: .xmr) },
onQR: { drawers.qr = AddressQR(title: $0.title, address: $0.address) })
}
if model.chain == .xmr {
SyncCard()
}
HStack(spacing: 10) {
PrimaryButton(title: "Send") { route = .send }
SecondaryButton(title: "Receive") { route = .receive }
SecondaryButton(title: "Swap") { route = .swap }
}
HStack(spacing: 12) {
IconButton(systemName: "arrow.clockwise") { Task { await model.refresh(force: true) } }
if model.chain == .xmr {
IconButton(systemName: "person.2.fill") { route = .accounts }
}
Spacer()
}
if model.chain == .xrp, model.moneroReady, let status = model.syncStatus, status.syncing {
Card {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Circle()
.fill(status.connected ? Theme.success : Theme.danger)
.frame(width: 7, height: 7)
Text("Monero is syncing in the background")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.text)
Spacer()
Text("\(Int(status.progress * 100))%")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.muted)
}
SyncProgress(status: status, eta: model.syncEta)
}
}
}
if !model.transactions.isEmpty {
Card {
VStack(alignment: .leading, spacing: 10) {
Text("Activity")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
// A Monero transaction can produce several transfer entries with the
// same hash, so index-based identities keep the rows stable.
ForEach(Array(model.transactions.enumerated()), id: \.offset) { index, record in
Button {
drawers.transaction = record
} label: {
TransactionRow(record: record, symbol: model.symbol)
}
.buttonStyle(.plain)
if index < model.transactions.count - 1 {
Divider().overlay(Theme.border)
}
}
}
}
} else if model.chain == .xrp, model.balance.funded == false {
Text("This account is not funded yet. Send XRP to the address above to activate it.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
}
}
private var moneroUnavailableCard: some View {
VStack(spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 10) {
Text("Monero wallet")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Theme.text)
Text("The wallet is on this device but could not be opened. It is safe to retry; nothing was deleted.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
SecondaryButton(title: "Retry") {
Task { await model.select(.xmr) }
}
}
}
}
}
private var emptyState: some View {
VStack(spacing: 14) {
ChainPicker(selection: Binding(get: { model.chain },
set: { newValue in Task { await model.select(newValue) } }))
Card {
VStack(alignment: .leading, spacing: 8) {
Text("\(model.chain.title) wallet")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(Theme.text)
Text("Create a new wallet on this device, or import one with its recovery seed.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
}
VStack(spacing: 10) {
PrimaryButton(title: "Create wallet") { route = .create }
SecondaryButton(title: "Import wallet") { route = .importWallet }
}
}
}
}
struct SyncBadge: View {
var status: SyncStatus
private var label: String {
if status.syncing {
let percent = status.progress * 100
return percent < 10 ? String(format: "Syncing %.1f%%", percent) : "Syncing \(Int(percent))%"
}
return status.connected ? "Synced" : "Offline"
}
var body: some View {
HStack(spacing: 6) {
Circle()
.fill(status.connected ? Theme.success : Theme.danger)
.frame(width: 7, height: 7)
Text(label)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Theme.muted)
}
}
}
/// Live scan progress: how far the wallet has reached, and roughly how long the
/// remaining blocks will take at the observed rate.
struct SyncProgress: View {
var status: SyncStatus
var eta: String?
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ProgressView(value: status.progress)
.tint(Theme.accent)
HStack(spacing: 8) {
Text("Block \(status.height.formatted()) of \(status.daemonHeight.formatted())")
Spacer()
if status.syncing {
Text(eta.map { "about \($0) left" } ?? "estimating…")
} else if status.connected {
Text("up to date")
}
}
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
}
struct AddressRow: View {
var address: String
@State private var copied = false
var body: some View {
HStack(spacing: 8) {
Text(address.isEmpty ? "—" : address)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Theme.muted)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 6)
Button {
UIPasteboard.general.string = address
copied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false }
} label: {
Image(systemName: copied ? "checkmark" : "doc.on.doc")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.accent)
}
.disabled(address.isEmpty)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Theme.surfaceAlt)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
struct SeedRevealView: View {
var seed: String
var symbol: String
var onDone: () -> Void
@State private var acknowledged = false
var body: some View {
ZStack {
Theme.background.ignoresSafeArea()
VStack(alignment: .leading, spacing: 16) {
Text("Your recovery phrase")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(Theme.text)
Text("Write these words down in order and keep them offline. Anyone with this phrase can spend your \(symbol).")
.font(.system(size: 13))
.foregroundStyle(Theme.muted)
Text(seed)
.font(.system(size: 15, design: .monospaced))
.foregroundStyle(Theme.text)
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Theme.surface)
.overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
Button {
UIPasteboard.general.string = seed
} label: {
Label("Copy phrase", systemImage: "doc.on.doc")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Theme.accent)
}
Toggle(isOn: $acknowledged) {
Text("I have written down my phrase")
.font(.system(size: 13))
.foregroundStyle(Theme.muted)
}
.tint(Theme.accent)
Spacer()
PrimaryButton(title: "Continue", disabled: !acknowledged, action: onDone)
}
.padding(22)
}
}
}
+121
View File
@@ -0,0 +1,121 @@
import SwiftUI
struct SettingsView: View {
@EnvironmentObject private var model: AppModel
@EnvironmentObject private var drawers: DrawerCoordinator
@State private var showDeleteConfirm = false
private let currencies = ["USD", "MYR", "JPY"]
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: "Settings",
subtitle: "Security and device options",
onBack: { drawers.settings = false })
Card {
VStack(alignment: .leading, spacing: 10) {
Text("Wallet")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
ChainPicker(selection: Binding(get: { model.chain },
set: { newValue in Task { await model.select(newValue) } }))
}
}
Card {
VStack(alignment: .leading, spacing: 10) {
Text("Currency")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 64), spacing: 8)], spacing: 8) {
ForEach(currencies, id: \.self) { currency in
let selected = model.fiatCurrency == currency
Button {
model.fiatCurrency = currency
} label: {
Text(currency)
.font(.system(size: 13, weight: .medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(selected ? Theme.accent : Theme.surfaceAlt)
.foregroundStyle(selected ? Color.black : Theme.text)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
}
Text("Prices come from CoinGecko (with a Kraken fallback for USD and EUR) and refresh about once a minute.")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
.padding(.top, 4)
}
}
Card {
Toggle(isOn: Binding(get: { model.faceIDEnabled },
set: { model.faceIDEnabled = $0 })) {
VStack(alignment: .leading, spacing: 3) {
Text("Unlock with \(Biometrics.label)")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.text)
Text("Require \(Biometrics.label) to open the app, reveal the recovery phrase, and send payments.")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
.tint(Theme.accent)
.disabled(!Biometrics.isAvailable)
if !Biometrics.isAvailable {
Text("This device has no biometric sensor configured.")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
Card {
VStack(alignment: .leading, spacing: 10) {
Text("Danger zone")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
Button {
showDeleteConfirm = true
} label: {
Text("Delete this wallet")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.danger)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Theme.danger.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
Text("Removes the wallet from this device. Your recovery phrase is the only way to get it back.")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
Spacer(minLength: 0)
}
.padding(.horizontal, 20)
.padding(.top, 12)
}
.preferredColorScheme(.dark)
.confirmationDialog("Delete this wallet?",
isPresented: $showDeleteConfirm,
titleVisibility: .visible) {
Button("Delete wallet", role: .destructive) {
Task {
await model.deleteWallet()
drawers.settings = false
}
}
Button("Cancel", role: .cancel) {}
} message: {
Text("The keys stored on this device are removed. Make sure your recovery phrase is backed up.")
}
}
}
+765
View File
@@ -0,0 +1,765 @@
import SwiftUI
import UIKit
/// Swap between the two wallets via kyc.rip, the public no-KYC aggregator.
struct SwapView: View {
@EnvironmentObject private var model: AppModel
@EnvironmentObject private var drawers: DrawerCoordinator
@Binding var route: Route
@State private var from: Chain = .xrp
@State private var amount = ""
@State private var estimate: SwapEstimate?
@State private var selectedRouteID: String?
@State private var estimating = false
@State private var creating = false
@State private var sendingDeposit = false
@State private var createdOrder: SwapOrder?
@State private var localError: String?
@State private var xmrAccountIndex = 0
@State private var xmrAccountPicked = false
@State private var confirmRemoveOrder = false
private var to: Chain { from == .xrp ? .xmr : .xrp }
private var selectedXmrAccount: MoneroAccount? {
model.moneroAccounts.first(where: { $0.index == xmrAccountIndex }) ?? model.moneroAccounts.first
}
private var fromBalance: Balance {
if from == .xmr, let account = selectedXmrAccount {
return Balance(total: account.balance,
unlocked: account.unlocked,
funded: (Decimal(string: account.balance) ?? 0) > 0)
}
if from == model.chain { return model.balance }
return WalletStore.cachedState(from)?.balance ?? Balance(total: "0", unlocked: "", funded: false)
}
private var fromBalanceText: String {
let value = Decimal(string: fromBalance.total) ?? 0
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.usesGroupingSeparator = false
// Monero has 12 decimals; rounding a balance up here would make "Max"
// exceed what the account can spend.
formatter.maximumFractionDigits = from == .xmr ? 12 : 6
return formatter.string(from: value as NSDecimalNumber) ?? "0"
}
/// Quoted routes worth considering, best payout first.
private var routes: [SwapRoute] {
(estimate?.routes ?? []).filter { $0.amountTo > 0 }.sorted { $0.amountTo > $1.amountTo }
}
private var selectedRoute: SwapRoute? {
routes.first { $0.id == selectedRouteID } ?? routes.first
}
/// One-line summary of everything kyc.rip knows about a route.
private func routeFacts(_ route: SwapRoute) -> String {
var facts: [String] = []
if let eta = route.eta { facts.append("~\(eta) min") }
facts.append(route.fixedRate ? "fixed rate" : "floating rate")
if let spread = route.spread { facts.append(String(format: "spread %+.2f%%", spread)) }
if let kyc = route.kycRating { facts.append("KYC \(kyc)") }
if let log = route.logPolicy { facts.append("logs \(log)") }
if let insurance = route.insurance, insurance > 0 { facts.append("insured \(insurance)%") }
if let min = route.minDeposit { facts.append("min \(trim(min)) \(from.symbol)") }
if let max = route.maxDeposit { facts.append("max \(trim(max)) \(from.symbol)") }
return facts.joined(separator: " · ")
}
private func rowFacts(_ route: SwapRoute) -> String {
var facts: [String] = []
if let spread = route.spread { facts.append(String(format: "%+.2f%%", spread)) }
if let kyc = route.kycRating { facts.append("KYC \(kyc)") }
facts.append(route.fixedRate ? "fixed" : "float")
return facts.joined(separator: " · ")
}
private func trim(_ value: Double) -> String {
String(format: "%.6g", value)
}
// MARK: - Swap status
private func statusTitle(_ status: String) -> String {
switch status.uppercased() {
case "WAITING", "NEW": return "Waiting for your deposit"
case "CONFIRMING": return "Deposit detected"
case "EXCHANGING": return "Exchanging"
case "SENDING": return "Sending to your wallet"
case "FINISHED", "SUCCESS": return "Swap complete"
case "EXPIRED": return "Order expired"
case "FAILED": return "Swap failed"
case "REFUNDED": return "Refunded"
case "OVERDUE": return "Deposit overdue"
default: return "Swap status"
}
}
private func statusDetail(_ status: String, order: SwapOrder) -> String {
switch status.uppercased() {
case "WAITING", "NEW":
return "Send your \(order.fromTicker) deposit to the address below."
case "CONFIRMING":
return "Your \(order.fromTicker) arrived. Waiting for network confirmations."
case "EXCHANGING":
return "The provider is converting \(order.fromTicker) to \(order.toTicker)."
case "SENDING":
return "The provider is sending \(order.toTicker) to your wallet."
case "FINISHED", "SUCCESS":
return "\(order.toTicker) was sent to your wallet."
case "EXPIRED":
return "The deposit window closed. If you already sent funds, contact \(order.provider) with the order ID."
case "FAILED":
return "The provider could not complete this swap. Check the order with \(order.provider)."
case "REFUNDED":
return "The provider returned your funds to the refund address."
case "OVERDUE":
return "The deposit arrived after the window closed. Check the order with \(order.provider)."
default:
return "Last known status from \(order.provider)."
}
}
/// Position in the five-step lifecycle, or nil for failure states.
private func statusStep(_ status: String) -> Int? {
switch status.uppercased() {
case "WAITING", "NEW": return 0
case "CONFIRMING": return 1
case "EXCHANGING": return 2
case "SENDING": return 3
case "FINISHED", "SUCCESS": return 4
default: return nil
}
}
private func isTerminal(_ status: String?) -> Bool {
guard let status else { return false }
return ["FINISHED", "SUCCESS", "FAILED", "EXPIRED", "REFUNDED", "OVERDUE"]
.contains(status.uppercased())
}
private func needsDeposit(_ status: String) -> Bool {
["WAITING", "NEW"].contains(status.uppercased())
}
/// Refreshes an open order immediately, then every 20 seconds until it
/// reaches a final state. Cancelled automatically when the view goes away.
private func monitorOrder() async {
guard let order = createdOrder else { return }
if !isTerminal(order.status) {
createdOrder = await model.refreshSwapStatus(order)
}
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 20 * 1_000_000_000)
guard !Task.isCancelled, let current = createdOrder, !isTerminal(current.status) else { return }
createdOrder = await model.refreshSwapStatus(current)
}
}
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: "Swap",
subtitle: "XMR ↔ XRP, via kyc.rip — no account, no KYC",
onBack: { route = .home })
if let order = createdOrder {
orderCard(order)
} else {
swapForm
}
if !model.swapOrders.isEmpty {
recentSwaps
}
}
.task {
from = model.chain
if !xmrAccountPicked { xmrAccountIndex = model.defaultMoneroAccountIndex }
model.loadSwapOrders()
}
.task(id: createdOrder?.id) {
await monitorOrder()
}
.onChange(of: model.moneroAccounts.count) { _ in
if !xmrAccountPicked { xmrAccountIndex = model.defaultMoneroAccountIndex }
}
}
// MARK: - The form
private var swapForm: some View {
VStack(alignment: .leading, spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("From")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text("Balance: \(fromBalanceText) \(from.symbol)")
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
HStack(spacing: 10) {
KoinsTextField(placeholder: "Amount", text: $amount, keyboard: .decimalPad)
Button("Max") { amount = fromBalanceText }
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.accent)
}
HStack(spacing: 10) {
Text(from.title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Theme.text)
Spacer()
Button {
from = to
amount = ""
estimate = nil
} label: {
Image(systemName: "arrow.up.arrow.down")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Theme.accent)
.frame(width: 34, height: 34)
.background(Theme.surfaceAlt)
.clipShape(Circle())
}
.accessibilityLabel("Swap direction")
}
if from == .xmr, model.moneroAccounts.count > 1 {
MoneroAccountPicker(accounts: model.moneroAccounts,
selection: $xmrAccountIndex,
onSelect: { xmrAccountPicked = true })
}
Divider().overlay(Theme.border)
HStack {
Text("To")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text(to.title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Theme.text)
}
if let estimate {
VStack(alignment: .leading, spacing: 4) {
Text("≈ \(String(format: "%.6f", selectedRoute?.amountTo ?? estimate.amountTo)) \(to.symbol)")
.font(.system(size: 22, weight: .bold, design: .rounded))
.foregroundStyle(Theme.text)
if let route = selectedRoute {
Text(routeFacts(route))
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
}
if !routes.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Pick a provider")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.muted)
ForEach(routes.prefix(4)) { route in
routeRow(route)
}
}
.padding(.top, 6)
}
} else {
Text("Enter an amount and get a quote")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
}
}
if let localError {
MessageBanner(message: localError, isError: true)
}
if let status = model.statusMessage {
MessageBanner(message: status, isError: false)
}
if estimate == nil {
PrimaryButton(title: estimating ? "Getting quote…" : "Get quote", disabled: estimating) {
Task { await loadQuote() }
}
} else {
PrimaryButton(title: creating ? "Creating…" : "Swap", disabled: creating) {
Task { await create() }
}
SecondaryButton(title: "Change amount") { estimate = nil }
}
Spacer(minLength: 0)
}
}
private func routeRow(_ route: SwapRoute) -> some View {
let selected = route.id == selectedRoute?.id
return Button {
selectedRouteID = route.id
} label: {
HStack(spacing: 10) {
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(selected ? Theme.accent : Theme.muted)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(route.provider)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.text)
if route.recommended {
Text("Recommended")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(Theme.accent)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Theme.accentSoft)
.clipShape(Capsule())
}
}
Text(rowFacts(route))
.font(.system(size: 10))
.foregroundStyle(Theme.muted)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(String(format: "%.6f", route.amountTo))
.font(.system(size: 13, weight: .semibold, design: .rounded))
.foregroundStyle(Theme.text)
Text(route.eta.map { "~\($0) min" } ?? "")
.font(.system(size: 10))
.foregroundStyle(Theme.muted)
}
}
.padding(10)
.background(selected ? Theme.accentSoft : Theme.surface)
.overlay(RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(selected ? Theme.accent.opacity(0.6) : Theme.border))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
.buttonStyle(.plain)
}
// MARK: - The order
private func orderCard(_ order: SwapOrder) -> some View {
let status = order.status ?? "WAITING"
let alreadySent = order.depositSent == true
let canSend = needsDeposit(status) && !alreadySent
return VStack(alignment: .leading, spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text(statusTitle(status))
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.text)
Spacer()
SwapStatusBadge(status: status)
}
Text(alreadySent && needsDeposit(status)
? "Deposit sent — waiting for \(order.provider) to see it."
: statusDetail(status, order: order))
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
if let step = statusStep(status) {
HStack(spacing: 4) {
ForEach(0..<5, id: \.self) { index in
Capsule()
.fill(index <= step ? Theme.accent : Theme.surfaceAlt)
.frame(height: 4)
}
}
.padding(.vertical, 2)
}
if !isTerminal(status) {
Text("Updates automatically every 20 seconds.")
.font(.system(size: 10))
.foregroundStyle(Theme.muted)
}
if needsDeposit(status) {
Text(verbatim: alreadySent
? "Your \(order.depositAmount) \(order.fromTicker) deposit was sent to this address:"
: "Send exactly \(order.depositAmount) \(order.fromTicker) to this address:")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
.padding(.top, 4)
HStack(spacing: 8) {
Text(order.depositAddress)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Theme.text)
.lineLimit(1)
.truncationMode(.middle)
Button {
UIPasteboard.general.string = order.depositAddress
} label: {
Image(systemName: "doc.on.doc")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.accent)
.frame(width: 34, height: 34)
.background(Theme.surfaceAlt)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
.disabled(order.depositAddress.isEmpty)
Button {
drawers.qr = AddressQR(title: "Deposit \(order.fromTicker)", address: order.depositAddress)
} label: {
Image(systemName: "qrcode")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.accent)
.frame(width: 34, height: 34)
.background(Theme.surfaceAlt)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
.disabled(order.depositAddress.isEmpty)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Theme.surfaceAlt)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
if let memo = order.depositMemo, !memo.isEmpty {
HStack {
Text("Tag")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text(memo)
.font(.system(size: 13, design: .monospaced))
.foregroundStyle(Theme.text)
}
}
}
HStack {
Text("You get")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text("≈ \(order.toAmount, specifier: "%.6g") \(order.toTicker)")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.success)
}
if let eta = order.eta {
HStack {
Text("Usually takes")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text("~\(eta) min")
.font(.system(size: 13))
.foregroundStyle(Theme.text)
}
}
HStack {
Text("Provider")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text(order.provider)
.font(.system(size: 13))
.foregroundStyle(Theme.text)
}
if order.fromAmount > 0 {
HStack {
Text("Rate")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text("1 \(order.fromTicker) ≈ \(order.toAmount / order.fromAmount, specifier: "%.4f") \(order.toTicker)")
.font(.system(size: 13))
.foregroundStyle(Theme.text)
}
}
if let txIn = order.txIn, !txIn.isEmpty {
txHashRow("Deposit tx", hash: txIn, ticker: order.fromTicker)
}
if let txOut = order.txOut, !txOut.isEmpty {
txHashRow("Payout tx", hash: txOut, ticker: order.toTicker)
}
HStack {
Text("Opened")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text(order.createdAt, format: Date.FormatStyle(date: .abbreviated, time: .shortened))
.font(.system(size: 12))
.foregroundStyle(Theme.text)
}
HStack {
Text("Order ID")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text(order.id)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Theme.text)
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: 160, alignment: .trailing)
Button {
UIPasteboard.general.string = order.id
} label: {
Image(systemName: "doc.on.doc")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.accent)
}
}
if let supportUrl = order.supportUrl, let url = URL(string: supportUrl) {
HStack {
Text("Support")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Link("Contact \(order.provider)", destination: url)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.accent)
}
}
}
}
if canSend, order.fromTicker.uppercased() == "XMR", model.moneroAccounts.count > 1 {
MoneroAccountPicker(accounts: model.moneroAccounts,
selection: $xmrAccountIndex,
onSelect: { xmrAccountPicked = true })
}
if needsDeposit(status) {
if alreadySent {
// The deposit was already broadcast; re-sending would double-pay.
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Theme.success)
Text("Deposit sent")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Theme.text)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 15)
.background(Theme.surfaceAlt)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
} else {
PrimaryButton(title: sendingDeposit ? "Sending…" : "Send deposit", disabled: sendingDeposit) {
Task { await sendDeposit(order) }
}
}
}
SecondaryButton(title: "Check status now") {
Task { createdOrder = await model.refreshSwapStatus(order) }
}
if let orderPage = URL(string: "https://kyc.rip/swap?id=\(order.id)") {
LinkButton(title: "View on kyc.rip", systemImage: "globe", url: orderPage)
}
Button {
confirmRemoveOrder = true
} label: {
Text("Remove this order")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
.frame(maxWidth: .infinity)
}
if let localError {
MessageBanner(message: localError, isError: true)
}
Spacer(minLength: 0)
}
.alert("Remove this swap from history?", isPresented: $confirmRemoveOrder) {
Button("Remove", role: .destructive) {
model.removeSwapOrder(id: order.id)
createdOrder = nil
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Only the local record is removed. If you already sent the deposit, \(order.provider) still has the order.")
}
}
/// A truncated, copyable transaction hash row for the order card, with a
/// link to the right block explorer when the ticker is known.
private func txHashRow(_ label: String, hash: String, ticker: String) -> some View {
HStack {
Text(label)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.muted)
Spacer()
Text(hash)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Theme.text)
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: 160, alignment: .trailing)
Button {
UIPasteboard.general.string = hash
} label: {
Image(systemName: "doc.on.doc")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.accent)
}
if let explorer = BlockExplorer.transactionURL(ticker: ticker, hash: hash) {
Link(destination: explorer) {
Image(systemName: "safari")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.accent)
}
}
}
}
private var recentSwaps: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Recent swaps")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.muted)
ForEach(model.swapOrders.prefix(6)) { order in
Button {
createdOrder = order
} label: {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text("\(order.fromAmount, specifier: "%.6g") \(order.fromTicker) → \(order.toAmount, specifier: "%.6g") \(order.toTicker)")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Theme.text)
Text(order.createdAt, style: .date)
.font(.system(size: 11))
.foregroundStyle(Theme.muted)
}
Spacer()
SwapStatusBadge(status: order.status ?? "WAITING")
}
.padding(12)
.background(Theme.surface)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.buttonStyle(.plain)
.contentShape(Rectangle())
.contextMenu {
Button(role: .destructive) {
if createdOrder?.id == order.id { createdOrder = nil }
model.removeSwapOrder(id: order.id)
} label: {
Label("Remove from history", systemImage: "trash")
}
}
}
}
}
// MARK: - Actions
private func loadQuote() async {
localError = nil
estimating = true
defer { estimating = false }
do {
estimate = try await model.estimateSwap(amount: amount, from: from, to: to)
if routes.isEmpty {
localError = "No provider offers this swap right now"
estimate = nil
} else {
// Default to the best payout; kyc.rip's "recommended" flag is shown
// but not preselected because it can be rev-share driven.
selectedRouteID = routes.first?.id
}
} catch {
localError = error.localizedDescription
}
}
private func create() async {
guard let route = selectedRoute, let estimate else { return }
localError = nil
// Respect the provider's deposit window so the order can't be created
// with an amount it would refuse.
let value = Double(amount) ?? 0
if let min = route.minDeposit, value < min {
localError = "\(route.provider) needs at least \(trim(min)) \(from.symbol)"
return
}
if let max = route.maxDeposit, value > max {
localError = "\(route.provider) takes at most \(trim(max)) \(from.symbol)"
return
}
creating = true
defer { creating = false }
do {
createdOrder = try await model.createSwap(route: route,
estimateID: estimate.id,
amountFrom: amount,
from: from,
to: to)
} catch {
localError = error.localizedDescription
}
}
private func sendDeposit(_ order: SwapOrder) async {
localError = nil
sendingDeposit = true
defer { sendingDeposit = false }
do {
try await model.sendSwapDeposit(order, accountIndex: xmrAccountIndex)
// Record that the deposit left this device so the button can never be
// pressed twice for the same order.
var sent = order
sent.depositSent = true
model.saveSwapOrder(sent)
createdOrder = sent
await model.refresh(force: true)
createdOrder = await model.refreshSwapStatus(sent)
} catch {
localError = error.localizedDescription
}
}
}
struct SwapStatusBadge: View {
var status: String
private var colour: Color {
switch status.uppercased() {
case "FINISHED", "SUCCESS": return Theme.success
case "WAITING", "NEW", "CONFIRMING", "EXCHANGING", "SENDING": return Theme.accent
case "REFUNDED": return Theme.muted
case "FAILED", "EXPIRED", "OVERDUE": return Theme.danger
default: return Theme.muted
}
}
var body: some View {
Text(status.lowercased().capitalized)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(colour)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(colour.opacity(0.15))
.clipShape(Capsule())
}
}
@@ -0,0 +1,166 @@
import SwiftUI
/// Full detail for one transaction. Monero and XRP fill different fields, so
/// rows are included only when the wallet knows the value.
struct TransactionDetailView: View {
var record: TransactionRecord
var symbol: String
var chain: Chain
var onDone: () -> Void
@State private var resolvedDate: Date?
@State private var isResolvingDate = false
private var displayedDate: Date? { record.timestamp ?? resolvedDate }
private var dateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .medium
return formatter
}
/// A Monero incoming transfer only reveals the wallet's own receiving
/// address (the sender is hidden), so it is not labelled "From".
private var counterpartyLabel: String {
if chain == .xmr, record.direction == .incoming { return "Received at" }
return record.direction == .incoming ? "From" : "To"
}
private var statusText: String {
if record.direction == .incoming {
if let confirmations = record.confirmations, confirmations > 0 {
return "Confirmed · \(confirmations) confirmation\(confirmations == 1 ? "" : "s")"
}
return record.confirmed ? "Confirmed" : "Waiting for confirmation"
}
if let confirmations = record.confirmations, confirmations > 0 {
return "Confirmed · \(confirmations) confirmation\(confirmations == 1 ? "" : "s")"
}
return record.confirmed ? "Confirmed" : "Pending"
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: record.direction == .incoming ? "arrow.down.left" : "arrow.up.right")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(record.direction == .incoming ? Theme.success : Theme.accent)
Text(record.direction == .incoming ? "Received" : "Sent")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Theme.text)
}
Text("\(record.direction == .incoming ? "+" : "-")\(record.amount) \(symbol)")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(Theme.text)
Text(statusText)
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
Card {
VStack(alignment: .leading, spacing: 12) {
if let date = displayedDate {
DetailRow(label: "Date", value: dateFormatter.string(from: date))
} else if isResolvingDate {
DetailRow(label: "Date", value: "Looking up the block time…")
} else if let height = record.height, height > 0 {
DetailRow(label: "Block height", value: String(height))
}
if let fee = record.fee, !fee.isEmpty, record.direction == .outgoing {
DetailRow(label: "Network fee", value: "\(fee) \(symbol)")
}
if let counterparty = record.counterparty, !counterparty.isEmpty {
DetailRow(label: counterpartyLabel, value: counterparty, monospaced: true, copyable: true)
}
if let tag = record.tag {
DetailRow(label: "Destination tag", value: String(tag))
}
if let height = record.height ?? record.ledgerIndex {
DetailRow(label: chain == .xmr ? "Block height" : "Ledger index", value: String(height))
}
if let confirmations = record.confirmations {
DetailRow(label: "Confirmations", value: String(confirmations))
}
if let accountIndex = record.accountIndex {
DetailRow(label: "Account", value: accountIndex == 0 ? "Primary (0)" : "Account \(accountIndex + 1) (\(accountIndex))")
}
if let subaddressIndex = record.subaddressIndex {
DetailRow(label: "Address index", value: "\(subaddressIndex)")
}
if let sequence = record.sequence {
DetailRow(label: "Sequence", value: String(sequence))
}
if let paymentId = record.paymentId {
DetailRow(label: "Payment ID", value: paymentId, monospaced: true, copyable: true)
}
if let weight = record.weight {
DetailRow(label: "Weight", value: "\(weight) bytes")
}
if let note = record.note {
DetailRow(label: "Note", value: note)
}
DetailRow(label: chain == .xmr ? "Transaction ID" : "Ledger hash",
value: record.id,
monospaced: true,
copyable: true)
}
}
if let explorer = BlockExplorer.transactionURL(chain: chain, hash: record.id) {
LinkButton(title: "View on explorer", url: explorer)
}
PrimaryButton(title: "Done", action: onDone)
}
.padding(20)
}
.preferredColorScheme(.dark)
.task {
guard record.timestamp == nil,
chain == .xmr,
let height = record.height, height > 0,
resolvedDate == nil else { return }
isResolvingDate = true
resolvedDate = try? await MoneroService.shared.blockTimestamp(height: height)
isResolvingDate = false
}
}
}
struct DetailRow: View {
var label: String
var value: String
var monospaced = false
var copyable = false
@State private var copied = false
var body: some View {
HStack(alignment: .top, spacing: 12) {
Text(label)
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
.frame(width: 108, alignment: .leading)
Text(value)
.font(.system(size: 12, design: monospaced ? .monospaced : .default))
.foregroundStyle(Theme.text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
if copyable {
Button {
UIPasteboard.general.string = value
copied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false }
} label: {
Image(systemName: copied ? "checkmark" : "doc.on.doc")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.accent)
}
}
}
}
}
+142
View File
@@ -0,0 +1,142 @@
import SwiftUI
struct CreateWalletView: View {
@EnvironmentObject private var model: AppModel
@Binding var route: Route
@Binding var seedBox: SeedBox?
@State private var name = ""
@State private var localError: String?
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: "Create wallet",
subtitle: "A new \(model.chain.title) wallet on this device",
onBack: { route = .home })
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: "Wallet name")
KoinsTextField(placeholder: "My \(model.symbol) wallet", text: $name)
}
if model.chain == .xmr {
Card {
Text("The wallet file is created locally and encrypted on this device. Syncing starts right away and can take a while for a new Monero wallet.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
}
}
if let message = localError ?? model.errorMessage {
MessageBanner(message: message, isError: true)
}
PrimaryButton(title: model.isBusy ? "Generating…" : "Generate wallet",
disabled: model.isBusy) {
Task {
localError = nil
model.clearMessages()
do {
let seed = try await model.createWallet(name: name)
seedBox = SeedBox(seed: seed, symbol: model.symbol)
route = .dashboard
} catch {
localError = error.localizedDescription
}
}
}
Spacer(minLength: 0)
}
}
}
struct ImportWalletView: View {
@EnvironmentObject private var model: AppModel
@Binding var route: Route
@State private var name = ""
@State private var secret = ""
@State private var restoreHeight = ""
@State private var localError: String?
@State private var tipError: String?
@State private var isLoadingTip = false
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ScreenHeader(title: "Import wallet",
subtitle: "Restore a \(model.chain.title) wallet from its seed",
onBack: { route = .home })
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: "Wallet name")
KoinsTextField(placeholder: "My \(model.symbol) wallet", text: $name)
}
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: model.chain == .xrp ? "Recovery phrase" : "Mnemonic seed")
KoinsTextField(placeholder: model.chain == .xrp ? "12 words, or a family seed (sEd…)" : "25 word seed phrase",
text: $secret,
multiline: model.chain == .xmr)
}
if model.chain == .xmr {
VStack(alignment: .leading, spacing: 6) {
FieldLabel(text: "Restore height (optional)")
KoinsTextField(placeholder: "e.g. 3200000", text: $restoreHeight, keyboard: .numberPad)
}
Card {
VStack(alignment: .leading, spacing: 10) {
Text("Without a height the wallet scans the whole chain — millions of blocks, which takes hours on a phone. Set the block your wallet was first used, or start from the current tip if you only care about new funds.")
.font(.system(size: 12))
.foregroundStyle(Theme.muted)
Button {
Task {
tipError = nil
isLoadingTip = true
defer { isLoadingTip = false }
do {
let tip = try await MoneroService.shared.daemonHeight()
restoreHeight = String(tip)
} catch {
tipError = error.localizedDescription
}
}
} label: {
Text(isLoadingTip ? "Fetching chain tip…" : "Use the current chain tip")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Theme.accent)
}
.disabled(isLoadingTip)
if let tipError {
Text(tipError)
.font(.system(size: 11))
.foregroundStyle(Theme.danger)
}
}
}
}
if let message = localError ?? model.errorMessage {
MessageBanner(message: message, isError: true)
}
PrimaryButton(title: model.isBusy ? "Importing…" : "Import wallet",
disabled: model.isBusy) {
Task {
localError = nil
model.clearMessages()
do {
let height = UInt64(restoreHeight.trimmingCharacters(in: .whitespaces))
try await model.importWallet(name: name, secret: secret, restoreHeight: height)
route = .dashboard
} catch {
localError = error.localizedDescription
}
}
}
Spacer(minLength: 0)
}
}
}
+327
View File
@@ -0,0 +1,327 @@
import Foundation
/// Monero amounts are atomic units (1 XMR = 1e12 piconero).
enum MoneroAmount {
static let atomicPerXMR: UInt64 = 1_000_000_000_000
static func xmr(fromAtomic value: String) -> String {
guard let atomic = UInt64(value) else { return "0" }
return xmr(fromAtomic: atomic)
}
static func xmr(fromAtomic value: UInt64) -> String {
let whole = value / atomicPerXMR
let fraction = value % atomicPerXMR
if fraction == 0 { return String(whole) }
var text = String(format: "%012llu", fraction)
while text.hasSuffix("0") { text.removeLast() }
return "\(whole).\(text)"
}
static func atomic(fromXMR value: String) throws -> UInt64 {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, !text.hasPrefix("-") else { throw XRPError.rpc("Enter a valid amount") }
let parts = text.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count <= 2 else { throw XRPError.rpc("Enter a valid amount") }
guard let whole = UInt64(parts[0].isEmpty ? "0" : String(parts[0])) else {
throw XRPError.rpc("Enter a valid amount")
}
var fraction: UInt64 = 0
if parts.count == 2 {
let fractionText = String(parts[1])
guard fractionText.count <= 12, fractionText.allSatisfy({ $0.isNumber }) else {
throw XRPError.rpc("XMR supports at most 12 decimal places")
}
let padded = fractionText.padding(toLength: 12, withPad: "0", startingAt: 0)
guard let parsed = UInt64(padded) else { throw XRPError.rpc("Enter a valid amount") }
fraction = parsed
}
let (wholeAtomic, overflow) = whole.multipliedReportingOverflow(by: atomicPerXMR)
guard !overflow else { throw XRPError.rpc("Amount is too large") }
let (total, carry) = wholeAtomic.addingReportingOverflow(fraction)
guard !carry else { throw XRPError.rpc("Amount is too large") }
return total
}
}
/// Swift wrapper around the Objective-C++ monero-cpp bridge.
final class MoneroService {
static let shared = MoneroService()
/// Wallet files that exist on disk without a stored record, so a lost
/// preference file does not strand a wallet.
static func orphanedWalletNames() -> [String] {
let manager = FileManager.default
guard let documents = manager.urls(for: .documentDirectory, in: .userDomainMask).first,
let items = try? manager.contentsOfDirectory(at: documents,
includingPropertiesForKeys: nil) else {
return []
}
let keys = Set(items.filter { $0.pathExtension == "keys" }
.map { $0.deletingPathExtension().lastPathComponent })
return items
.filter { $0.pathExtension.isEmpty && !$0.lastPathComponent.hasPrefix(".") }
.map { $0.lastPathComponent }
.filter { keys.contains($0) }
.sorted()
}
/// The wallet file is encrypted with this password; the recovery seed lives
/// in the Keychain.
static let walletPassword = "koins"
/// Default daemon, kept in sync with the bridge. Used for the cheap
/// daemon-only queries that do not need an open wallet.
static let daemonHost = "xmr-node.cakewallet.com"
/// Current chain tip, so an import can start scanning from now instead of
/// from the genesis block.
func daemonHeight() async throws -> UInt64 {
guard let url = URL(string: "http://\(Self.daemonHost):18081/json_rpc") else {
throw XRPError.network("Invalid Monero daemon address")
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 15
request.httpBody = try JSONSerialization.data(withJSONObject: [
"jsonrpc": "2.0",
"id": "koins",
"method": "get_info",
])
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw XRPError.network("Monero daemon is unreachable")
}
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let result = json["result"] as? [String: Any],
let height = (result["height"] as? NSNumber)?.uint64Value else {
throw XRPError.network("Could not read the Monero chain tip")
}
return height
}
private func invoke(_ request: [String: Any]) async throws -> [String: Any] {
let json = try JSONSerialization.data(withJSONObject: request)
let requestString = String(data: json, encoding: .utf8) ?? "{}"
let responseString: String = await withCheckedContinuation { continuation in
KoinsMoneroWallet.invoke(requestString) { response in
continuation.resume(returning: response)
}
}
guard let data = responseString.data(using: .utf8),
let response = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw XRPError.rpc("Unexpected response from the Monero wallet")
}
if let error = response["error"] as? String, !error.isEmpty {
throw XRPError.rpc(error)
}
return response
}
// MARK: - Wallet lifecycle
func createWallet(name: String) async throws -> (address: String, mnemonic: String) {
let response = try await invoke(["op": "createWallet",
"name": name,
"password": Self.walletPassword,
"language": "English"])
return (response["address"] as? String ?? "", response["mnemonic"] as? String ?? "")
}
func openWallet(name: String) async throws -> String {
let response = try await invoke(["op": "openWallet",
"name": name,
"password": Self.walletPassword])
return response["address"] as? String ?? ""
}
func restoreWallet(name: String,
seed: String,
restoreHeight: UInt64) async throws -> String {
let response = try await invoke(["op": "restoreWallet",
"name": name,
"password": Self.walletPassword,
"seed": seed,
"restoreHeight": restoreHeight])
return response["address"] as? String ?? ""
}
func removeWallet(name: String) async {
_ = try? await invoke(["op": "removeWallet", "name": name])
}
func closeWallet() async {
_ = try? await invoke(["op": "closeWallet"])
}
/// Writes the wallet file, which is how scan progress survives a restart.
func save() async {
_ = try? await invoke(["op": "saveWallet"])
}
func seed() async throws -> String {
let response = try await invoke(["op": "getSeed"])
return response["mnemonic"] as? String ?? ""
}
/// Block time for a height. wallet2 keeps no timestamp for transfers, so the
/// only way to date a Monero transaction is to ask the daemon for the block
/// it landed in.
func blockTimestamp(height: UInt64) async throws -> Date? {
guard let url = URL(string: "http://\(Self.daemonHost):18081/json_rpc") else { return nil }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 15
request.httpBody = try JSONSerialization.data(withJSONObject: [
"jsonrpc": "2.0",
"id": "koins",
"method": "get_block_header_by_height",
"params": ["height": height],
])
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode),
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let result = json["result"] as? [String: Any],
let header = result["block_header"] as? [String: Any],
let timestamp = (header["timestamp"] as? NSNumber)?.doubleValue, timestamp > 0 else {
return nil
}
return Date(timeIntervalSince1970: timestamp)
}
// MARK: - Reads
func balance() async throws -> Balance {
let response = try await invoke(["op": "getBalance"])
let total = response["balance"] as? String ?? "0"
let unlocked = response["unlocked"] as? String ?? total
return Balance(total: MoneroAmount.xmr(fromAtomic: total),
unlocked: MoneroAmount.xmr(fromAtomic: unlocked),
funded: (UInt64(total) ?? 0) > 0)
}
func transactions() async throws -> [TransactionRecord] {
let response = try await invoke(["op": "getTransactions"])
let entries = response["transactions"] as? [[String: Any]] ?? []
return entries.map { entry in
let timestamp = (entry["timestamp"] as? NSNumber)?.doubleValue ?? 0
return TransactionRecord(
id: entry["hash"] as? String ?? UUID().uuidString,
amount: MoneroAmount.xmr(fromAtomic: entry["amount"] as? String ?? "0"),
timestamp: timestamp > 0 ? Date(timeIntervalSince1970: timestamp) : nil,
direction: (entry["direction"] as? String) == "in" ? .incoming : .outgoing,
confirmed: (entry["confirmed"] as? Bool) ?? false,
fee: MoneroAmount.xmr(fromAtomic: entry["fee"] as? String ?? "0"),
tag: nil,
counterparty: entry["counterparty"] as? String,
confirmations: (entry["confirmations"] as? NSNumber)?.uint64Value,
height: (entry["height"] as? NSNumber)?.uint64Value,
paymentId: (entry["paymentId"] as? String).flatMap { $0.isEmpty ? nil : $0 },
accountIndex: (entry["accountIndex"] as? NSNumber)?.intValue,
subaddressIndex: (entry["subaddressIndex"] as? NSNumber)?.intValue,
note: (entry["note"] as? String).flatMap { $0.isEmpty ? nil : $0 },
weight: (entry["weight"] as? NSNumber)?.uint64Value)
}
.sorted { newestFirst($0, $1) }
}
/// Newest first. wallet2 gives transfers no timestamp, but the block height is
/// chronological and always present, so that is the ordering key; anything
/// still in the pool (height 0) belongs at the top.
private func newestFirst(_ left: TransactionRecord, _ right: TransactionRecord) -> Bool {
func key(_ record: TransactionRecord) -> UInt64 {
guard let height = record.height, height > 0 else { return .max }
return height
}
let leftKey = key(left)
let rightKey = key(right)
if leftKey != rightKey { return leftKey > rightKey }
return (left.timestamp ?? .distantFuture) > (right.timestamp ?? .distantFuture)
}
// MARK: - Accounts
func accounts() async throws -> [MoneroAccount] {
let response = try await invoke(["op": "getAccounts"])
let entries = response["accounts"] as? [[String: Any]] ?? []
return entries.map { entry in
let subEntries = entry["subaddresses"] as? [[String: Any]] ?? []
return MoneroAccount(
index: (entry["index"] as? NSNumber)?.intValue ?? 0,
address: entry["address"] as? String ?? "",
balance: MoneroAmount.xmr(fromAtomic: entry["balance"] as? String ?? "0"),
unlocked: MoneroAmount.xmr(fromAtomic: entry["unlocked"] as? String ?? "0"),
subaddresses: subEntries.map { sub in
MoneroSubaddress(
accountIndex: (sub["accountIndex"] as? NSNumber)?.intValue ?? 0,
index: (sub["index"] as? NSNumber)?.intValue ?? 0,
address: sub["address"] as? String ?? "",
label: sub["label"] as? String ?? "",
balance: MoneroAmount.xmr(fromAtomic: sub["balance"] as? String ?? "0"),
unlocked: MoneroAmount.xmr(fromAtomic: sub["unlocked"] as? String ?? "0"),
isUsed: (sub["isUsed"] as? Bool) ?? false)
})
}
}
func createAccount(label: String) async throws -> MoneroAccount {
let response = try await invoke(["op": "createAccount", "label": label])
return MoneroAccount(index: (response["index"] as? NSNumber)?.intValue ?? 0,
address: response["address"] as? String ?? "",
balance: MoneroAmount.xmr(fromAtomic: response["balance"] as? String ?? "0"),
unlocked: MoneroAmount.xmr(fromAtomic: response["unlocked"] as? String ?? "0"),
subaddresses: [])
}
func createSubaddress(accountIndex: Int, label: String) async throws -> MoneroSubaddress {
let response = try await invoke(["op": "createSubaddress",
"accountIndex": accountIndex,
"label": label])
return MoneroSubaddress(accountIndex: (response["accountIndex"] as? NSNumber)?.intValue ?? accountIndex,
index: (response["index"] as? NSNumber)?.intValue ?? 0,
address: response["address"] as? String ?? "",
label: response["label"] as? String ?? label,
balance: "0",
unlocked: "0",
isUsed: false)
}
// MARK: - Sync
func startSync() async throws -> Bool {
let response = try await invoke(["op": "startSync"])
return (response["syncing"] as? Bool) ?? false
}
func stopSync() async {
_ = try? await invoke(["op": "stopSync"])
}
func syncStatus() async throws -> SyncStatus {
let response = try await invoke(["op": "getSyncStatus"])
return SyncStatus(height: (response["height"] as? NSNumber)?.uint64Value ?? 0,
daemonHeight: (response["daemonHeight"] as? NSNumber)?.uint64Value ?? 0,
syncing: (response["syncing"] as? Bool) ?? false,
connected: (response["connected"] as? Bool) ?? false)
}
// MARK: - Sends
func send(address: String, amountXMR: String, priority: TransactionPriority,
accountIndex: Int = 0) async throws -> (hash: String, fee: String) {
let atomic = try MoneroAmount.atomic(fromXMR: amountXMR)
let response = try await invoke(["op": "transferTo",
"address": address.trimmingCharacters(in: .whitespacesAndNewlines),
"amount": String(atomic),
"priority": priority.rawValue,
"accountIndex": accountIndex])
return (response["txHash"] as? String ?? "",
MoneroAmount.xmr(fromAtomic: response["fee"] as? String ?? "0"))
}
}
+232
View File
@@ -0,0 +1,232 @@
import Foundation
/// A route offered by one of kyc.rip's underlying exchange engines.
struct SwapRoute: Identifiable {
var id: String { "\(provider).\(engine).\(amountTo)" }
var provider: String
var engine: String
var amountFrom: Double
var amountTo: Double
var eta: Int? // minutes
var fixedRate: Bool
var spread: Double? // percent above/below the market rate
var kycRating: String? // A/B/C privacy grade from the aggregator
var logPolicy: String? // the provider's logging policy grade
var insurance: Int? // percent of the swap insured by the provider
var minDeposit: Double?
var maxDeposit: Double?
var recommended: Bool
}
struct SwapEstimate {
var id: String
var amountTo: Double
var routes: [SwapRoute]
}
/// A created swap order, persisted so it can be tracked after the app restarts.
struct SwapOrder: Codable, Identifiable {
var id: String
var engine: String
var provider: String
var depositAddress: String
var depositAmount: Double
var depositMemo: String?
var fromTicker: String
var fromAmount: Double
var toTicker: String
var toAmount: Double
var addressTo: String
var createdAt: Date
var eta: Int?
/// Latest-known lifecycle state (refreshed from the API).
var status: String?
/// Transaction hashes reported by the provider once the swap progresses.
var txIn: String?
var txOut: String?
/// Provider support link, reported by the status endpoint when available.
var supportUrl: String?
/// Set once the deposit has been broadcast from this device, so the
/// "Send deposit" button can never be pressed twice for the same order.
var depositSent: Bool?
}
/// Latest-known state of an order, as reported by the status endpoint.
struct SwapOrderUpdate {
var status: String
var txIn: String?
var txOut: String?
var supportUrl: String?
}
enum SwapError: LocalizedError {
case http(Int)
case api(String)
case noRoute
var errorDescription: String? {
switch self {
case .http(let code): return "Swap service unavailable (\(code))"
case .api(let message): return message
case .noRoute: return "No provider offers this swap right now"
}
}
}
/// kyc.rip (https://api.kyc.rip), a public no-KYC swap aggregator. No account,
/// no API key; the response is used directly.
final class SwapService {
static let shared = SwapService()
private let base = "https://api.kyc.rip"
private let session: URLSession
private init() {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 25
session = URLSession(configuration: configuration)
}
// MARK: - Estimates
func estimate(from: String, to: String, amount: Double) async throws -> SwapEstimate {
var components = URLComponents(string: "\(base)/v2/exchange/estimate")
components?.queryItems = [
URLQueryItem(name: "from", value: from),
URLQueryItem(name: "from_net", value: "Mainnet"),
URLQueryItem(name: "to", value: to),
URLQueryItem(name: "to_net", value: "Mainnet"),
URLQueryItem(name: "amount", value: String(amount)),
]
guard let url = components?.url else { throw SwapError.noRoute }
let (data, response) = try await session.data(from: url)
try check(response)
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw SwapError.noRoute
}
if let error = json["error"] as? String { throw SwapError.api(error) }
let amountTo = (json["amount_to"] as? NSNumber)?.doubleValue ?? 0
let routes = (json["routes"] as? [[String: Any]] ?? []).map { entry in
SwapRoute(
provider: entry["provider"] as? String ?? "",
engine: entry["engine"] as? String ?? "",
amountFrom: (entry["amount_from"] as? NSNumber)?.doubleValue ?? 0,
amountTo: (entry["amount_to"] as? NSNumber)?.doubleValue ?? 0,
eta: (entry["eta"] as? NSNumber)?.intValue,
fixedRate: (entry["fixed"] as? Bool) ?? false,
spread: (entry["spread"] as? NSNumber)?.doubleValue,
kycRating: entry["kyc"] as? String,
logPolicy: entry["log_policy"] as? String,
insurance: (entry["insurance"] as? NSNumber)?.intValue,
minDeposit: (entry["min_deposit"] as? NSNumber)?.doubleValue,
maxDeposit: (entry["max_deposit"] as? NSNumber)?.doubleValue,
recommended: (entry["recommended"] as? Bool) ?? false)
}
return SwapEstimate(id: json["id"] as? String ?? "", amountTo: amountTo, routes: routes)
}
// MARK: - Orders
func createOrder(route: SwapRoute,
estimateID: String,
from: String,
to: String,
addressTo: String,
amountFrom: Double) async throws -> SwapOrder {
guard let url = URL(string: "\(base)/v2/exchange/create") else { throw SwapError.noRoute }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
var body: [String: Any] = [
"provider": route.provider,
"engine": route.engine,
"from_currency": from,
"from_network": "Mainnet",
"to_currency": to,
"to_network": "Mainnet",
"address_to": addressTo,
"amount_from": amountFrom,
"amount_to": route.amountTo,
"fixed_rate": route.fixedRate,
"source": "swap",
"isPayment": false,
]
// The create endpoint expects the quote it was estimated against.
if !estimateID.isEmpty { body["trade_id"] = estimateID }
if let kyc = route.kycRating { body["kyc_rating"] = kyc }
if let log = route.logPolicy { body["log_policy"] = log }
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
try check(response)
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw SwapError.noRoute
}
if let error = json["error"] as? String { throw SwapError.api(error) }
if let code = json["code"] as? String { throw SwapError.api(code) }
guard let id = json["id"] as? String, !id.isEmpty else { throw SwapError.noRoute }
return SwapOrder(
id: id,
engine: json["engine"] as? String ?? route.engine,
provider: json["provider"] as? String ?? route.provider,
depositAddress: json["depositAddress"] as? String ?? "",
depositAmount: (json["depositAmount"] as? NSNumber)?.doubleValue ?? amountFrom,
depositMemo: json["depositMemo"] as? String,
fromTicker: (json["fromTicker"] as? String ?? from).uppercased(),
fromAmount: (json["fromAmount"] as? NSNumber)?.doubleValue ?? amountFrom,
toTicker: (json["toTicker"] as? String ?? to).uppercased(),
toAmount: (json["toAmount"] as? NSNumber)?.doubleValue ?? 0,
addressTo: json["address_user"] as? String ?? addressTo,
createdAt: Date(),
eta: (json["eta"] as? NSNumber)?.intValue,
status: json["status"] as? String,
supportUrl: nonEmpty(json["supportUrl"] as? String))
}
func orderStatus(_ order: SwapOrder) async throws -> SwapOrderUpdate {
var components = URLComponents(string: "\(base)/v2/exchange/status/\(order.id)")
components?.queryItems = [URLQueryItem(name: "engine", value: order.engine)]
guard let url = components?.url else { throw SwapError.noRoute }
let (data, response) = try await session.data(from: url)
try check(response)
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw SwapError.noRoute
}
if let error = json["error"] as? String { throw SwapError.api(error) }
let txIn = nonEmpty(json["txIn"] as? String)
?? (json["txIn"] as? [String: Any]).flatMap { nonEmpty($0["hash"] as? String) }
let txOut = nonEmpty(json["txOut"] as? String)
?? (json["txOut"] as? [String: Any]).flatMap { nonEmpty($0["hash"] as? String) }
return SwapOrderUpdate(status: json["status"] as? String ?? "UNKNOWN",
txIn: txIn,
txOut: txOut,
supportUrl: nonEmpty(json["supportUrl"] as? String))
}
/// The API sends blank strings while a hash is still unknown; treat those as
/// absent so the UI does not render empty rows.
private func nonEmpty(_ value: String?) -> String? {
guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
return value
}
private func check(_ response: URLResponse) throws {
guard let http = response as? HTTPURLResponse else { return }
if http.statusCode == 429 { throw SwapError.api("Rate limited — try again in a minute") }
guard (200..<300).contains(http.statusCode) else { throw SwapError.http(http.statusCode) }
}
}
+53
View File
@@ -0,0 +1,53 @@
import Foundation
/// Exact decimal <-> drops conversion (1 XRP = 1_000_000 drops) without
/// floating point rounding errors.
enum XRPAmount {
static let dropsPerXRP: UInt64 = 1_000_000
static let maxDrops: UInt64 = 100_000_000_000 * dropsPerXRP // 1e11 XRP
static func drops(fromXRP value: String) throws -> UInt64 {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, !text.hasPrefix("-"), !text.hasPrefix("+") else {
throw XRPError.rpc("Enter a valid amount")
}
let parts = text.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count <= 2 else { throw XRPError.rpc("Enter a valid amount") }
let wholeText = parts[0].isEmpty ? "0" : String(parts[0])
guard let whole = UInt64(wholeText) else { throw XRPError.rpc("Enter a valid amount") }
var fraction: UInt64 = 0
if parts.count == 2 {
let fractionText = String(parts[1])
guard fractionText.count <= 6, fractionText.allSatisfy({ $0.isNumber }) else {
throw XRPError.rpc("XRP supports at most 6 decimal places")
}
let padded = fractionText.padding(toLength: 6, withPad: "0", startingAt: 0)
guard let parsed = UInt64(padded) else { throw XRPError.rpc("Enter a valid amount") }
fraction = parsed
}
let (wholeDrops, overflow) = whole.multipliedReportingOverflow(by: dropsPerXRP)
guard !overflow else { throw XRPError.rpc("Amount is too large") }
let (total, carry) = wholeDrops.addingReportingOverflow(fraction)
guard !carry, total <= maxDrops else { throw XRPError.rpc("Amount is too large") }
return total
}
static func xrp(fromDrops drops: String) -> String {
guard let value = UInt64(drops.trimmingCharacters(in: .whitespacesAndNewlines)) else {
return "0"
}
return xrp(fromDrops: value)
}
static func xrp(fromDrops value: UInt64) -> String {
let whole = value / dropsPerXRP
let fraction = value % dropsPerXRP
if fraction == 0 { return String(whole) }
var fractionText = String(format: "%06llu", fraction)
while fractionText.hasSuffix("0") { fractionText.removeLast() }
return "\(whole).\(fractionText)"
}
}
+136
View File
@@ -0,0 +1,136 @@
import Foundation
/// Canonical XRP Ledger binary serialization for the Payment transactions this
/// wallet sends, plus the signing / transaction-id hashing around it.
struct XRPPayment {
var account: String
var destination: String
var amountDrops: UInt64
var feeDrops: UInt64
var sequence: UInt32
var lastLedgerSequence: UInt32?
var destinationTag: UInt32?
var signingPublicKey: [UInt8]
var signature: [UInt8]?
private static let typeUInt16: UInt8 = 1
private static let typeUInt32: UInt8 = 2
private static let typeAmount: UInt8 = 6
private static let typeBlob: UInt8 = 7
private static let typeAccountID: UInt8 = 8
func serialized(includingSignature: Bool) throws -> [UInt8] {
var output: [UInt8] = []
// Fields must appear in (type, field) order.
output += header(type: Self.typeUInt16, field: 2) // TransactionType
output += uint16(0) // ttPAYMENT
output += header(type: Self.typeUInt32, field: 2) // Flags
output += uint32(0)
output += header(type: Self.typeUInt32, field: 4) // Sequence
output += uint32(sequence)
if let tag = destinationTag {
output += header(type: Self.typeUInt32, field: 14) // DestinationTag
output += uint32(tag)
}
if let lastLedger = lastLedgerSequence {
output += header(type: Self.typeUInt32, field: 27) // LastLedgerSequence
output += uint32(lastLedger)
}
output += header(type: Self.typeAmount, field: 1) // Amount
output += try amount(amountDrops)
output += header(type: Self.typeAmount, field: 8) // Fee
output += try amount(feeDrops)
output += header(type: Self.typeBlob, field: 3) // SigningPubKey
output += variableLength(signingPublicKey)
if includingSignature, let signature {
output += header(type: Self.typeBlob, field: 4) // TxnSignature
output += variableLength(signature)
}
output += header(type: Self.typeAccountID, field: 1) // Account
output += variableLength(try XRPKey.accountId(fromAddress: account))
output += header(type: Self.typeAccountID, field: 3) // Destination
output += variableLength(try XRPKey.accountId(fromAddress: destination))
return output
}
/// Bytes the private key signs (HashPrefix.transactionSig + tx).
func signingPayload() throws -> [UInt8] {
[0x53, 0x54, 0x58, 0x00] + (try serialized(includingSignature: false))
}
/// The signed transaction blob submitted to the network (no hash prefix).
func txBlob() throws -> [UInt8] {
guard signature != nil else { throw XRPError.rpc("Transaction is not signed") }
return try serialized(includingSignature: true)
}
func transactionHash() throws -> [UInt8] {
guard signature != nil else { throw XRPError.rpc("Transaction is not signed") }
let blob = try serialized(includingSignature: true)
return XRPHash.sha512Half([0x54, 0x58, 0x4e, 0x00] + blob)
}
// MARK: - Encoding helpers
private func header(type: UInt8, field: UInt8) -> [UInt8] {
if field < 16 { return [(type << 4) | field] }
return [type << 4, field]
}
private func uint16(_ value: UInt16) -> [UInt8] {
[UInt8(value >> 8), UInt8(value & 0xff)]
}
private func uint32(_ value: UInt32) -> [UInt8] {
[
UInt8((value >> 24) & 0xff),
UInt8((value >> 16) & 0xff),
UInt8((value >> 8) & 0xff),
UInt8(value & 0xff),
]
}
/// Native (XRP) amount: 8 bytes big-endian drops with the XRP flag bit set.
private func amount(_ drops: UInt64) throws -> [UInt8] {
guard drops <= XRPAmount.maxDrops else { throw XRPError.rpc("Amount is too large") }
var bytes = uint64(drops)
bytes[0] |= 0x40
return bytes
}
private func uint64(_ value: UInt64) -> [UInt8] {
var bytes: [UInt8] = []
for shift in stride(from: 56, through: 0, by: -8) {
bytes.append(UInt8((value >> UInt64(shift)) & 0xff))
}
return bytes
}
private func variableLength(_ bytes: [UInt8]) -> [UInt8] {
var output: [UInt8] = []
let count = bytes.count
if count < 192 {
output.append(UInt8(count))
} else if count <= 12_480 {
output.append(UInt8((count >> 8) + 192))
output.append(UInt8(count & 0xff))
} else {
output.append(UInt8((count >> 16) + 240))
output.append(UInt8((count >> 8) & 0xff))
output.append(UInt8(count & 0xff))
}
return output + bytes
}
}
+72
View File
@@ -0,0 +1,72 @@
import Foundation
/// Minimal XRP Ledger JSON-RPC client. A couple of public endpoints are tried
/// in order so a single outage does not break the wallet.
final class XRPClient {
static let shared = XRPClient()
private let endpoints = [
"https://s1.ripple.com:51234/",
"https://s2.ripple.com:51234/",
"https://xrplcluster.com/",
]
private var preferredEndpoint = 0
private let session: URLSession
private init() {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 20
configuration.waitsForConnectivity = false
session = URLSession(configuration: configuration)
}
func rpc(_ method: String, _ params: [String: Any] = [:]) async throws -> [String: Any] {
var lastError: Error = XRPError.network("XRP network is unavailable")
for attempt in 0..<endpoints.count {
let index = (preferredEndpoint + attempt) % endpoints.count
guard let url = URL(string: endpoints[index]) else { continue }
do {
let result = try await send(method, params, to: url)
preferredEndpoint = index
return result
} catch {
lastError = error
}
}
throw lastError
}
private func send(_ method: String, _ params: [String: Any], to url: URL) async throws -> [String: Any] {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: Any] = ["method": method, "params": [params], "id": 1]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw XRPError.network("Ledger request failed (\(method))")
}
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw XRPError.network("Malformed ledger response")
}
let result = json["result"] as? [String: Any] ?? [:]
if let error = result["error"] as? String {
let message = result["error_message"] as? String ?? error
throw XRPError.rpc(message)
}
if let error = json["error"] as? String {
throw XRPError.rpc(json["error_message"] as? String ?? error)
}
return result
}
static func isAccountNotFound(_ error: Error) -> Bool {
if case XRPError.rpc(let message) = error {
return message == "actNotFound" || message.contains("Account not found")
}
return false
}
}
+148
View File
@@ -0,0 +1,148 @@
import CryptoKit
import Foundation
enum XRPAlgorithm: String {
case ed25519
case secp256k1
}
/// Set by the app target so this file stays compilable outside iOS.
enum Secp256k1Backend {
static var derivePrivateKey: ((Data) -> Data?)?
static var publicKey: ((Data) -> Data?)?
static var sign: ((Data, Data) -> Data?)?
static var addScalar: ((Data, Data) -> Data?)?
}
struct XRPKeypair {
let algorithm: XRPAlgorithm
/// ed25519: 32-byte seed. secp256k1: 32-byte scalar.
let privateKey: [UInt8]
/// ed25519: 0xED + 32-byte key. secp256k1: 33-byte compressed point.
let publicKey: [UInt8]
var address: String { XRPKey.address(fromPublicKey: publicKey) }
var signingPublicKeyHex: String { Hex.encode(publicKey) }
}
enum XRPKey {
static let ed25519SeedVersion: [UInt8] = [0x01, 0xe1, 0x4b]
static let familySeedVersion: [UInt8] = [0x21]
static let accountIdVersion: [UInt8] = [0x00]
static let ed25519Prefix: UInt8 = 0xED
// MARK: - Seeds
static func generateSeed(algorithm: XRPAlgorithm = .ed25519) -> String {
var entropy = [UInt8](repeating: 0, count: 16)
for index in entropy.indices { entropy[index] = UInt8.random(in: 0...255) }
let version = algorithm == .ed25519 ? ed25519SeedVersion : familySeedVersion
return XRPBase58.checkEncode(version: version, payload: entropy)
}
static func decodeSeed(_ seed: String) throws -> (entropy: [UInt8], algorithm: XRPAlgorithm) {
let decoded = try XRPBase58.decode(seed.trimmingCharacters(in: .whitespacesAndNewlines))
guard decoded.count > 4 else { throw XRPError.invalidSeed }
let body = Array(decoded[0..<(decoded.count - 4)])
let checksum = Array(decoded[(decoded.count - 4)...])
guard XRPHash.checksum(body) == checksum else { throw XRPError.invalidSeed }
if body.count == ed25519SeedVersion.count + 16, Array(body.prefix(3)) == ed25519SeedVersion {
return (Array(body.dropFirst(3)), .ed25519)
}
if body.count == familySeedVersion.count + 16, body[0] == familySeedVersion[0] {
return (Array(body.dropFirst(1)), .secp256k1)
}
throw XRPError.unsupportedSeed
}
// MARK: - Keys
/// Recovery phrase for a new wallet: 12 BIP-39 words.
static func generateMnemonic() -> String {
BIP39.generateMnemonic()
}
/// Accepts either a 12 word BIP-39 recovery phrase or a legacy XRP family
/// seed (`sEd…` / `s…`).
static func keypair(fromSecret secret: String) throws -> XRPKeypair {
let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines)
if BIP39.isMnemonic(trimmed) {
return try keypair(fromMnemonic: trimmed)
}
return try keypair(fromSeed: trimmed)
}
/// Derives the account at `m/44'/144'/0'/0/0`, the path hardware and mobile
/// wallets use for XRP.
static func keypair(fromMnemonic mnemonic: String) throws -> XRPKeypair {
guard BIP39.entropy(from: mnemonic) != nil else {
throw XRPError.invalidSeed
}
let seed = BIP39.seed(from: mnemonic)
guard let master = BIP32.masterKey(fromSeed: seed),
let derived = BIP32.derive(BIP32.xrpPath, from: master) else {
throw XRPError.unsupportedSeed
}
return XRPKeypair(algorithm: .secp256k1,
privateKey: derived.privateKey,
publicKey: derived.publicKey)
}
static func keypair(fromSeed seed: String) throws -> XRPKeypair {
let (entropy, algorithm) = try decodeSeed(seed)
switch algorithm {
case .ed25519:
let raw = XRPHash.sha512Half(entropy)
let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: Data(raw))
let publicKey = [ed25519Prefix] + Array(privateKey.publicKey.rawRepresentation)
return XRPKeypair(algorithm: .ed25519, privateKey: raw, publicKey: publicKey)
case .secp256k1:
guard let derive = Secp256k1Backend.derivePrivateKey,
let toPublic = Secp256k1Backend.publicKey,
let privateKey = derive(Data(entropy)),
let publicKey = toPublic(privateKey) else {
throw XRPError.unsupportedSeed
}
return XRPKeypair(algorithm: .secp256k1,
privateKey: Array(privateKey),
publicKey: Array(publicKey))
}
}
static func address(fromPublicKey publicKey: [UInt8]) -> String {
let accountId = XRPHash.ripemd160(XRPHash.sha256(publicKey))
return XRPBase58.checkEncode(version: accountIdVersion, payload: accountId)
}
static func isValidAddress(_ address: String) -> Bool {
(try? XRPBase58.checkDecode(address, versionLength: 1)) != nil
}
static func accountId(fromAddress address: String) throws -> [UInt8] {
let payload = try XRPBase58.checkDecode(address, versionLength: 1)
guard payload.count == 20 else { throw XRPError.invalidAddress }
return payload
}
// MARK: - Signing
/// ed25519 returns a 64-byte signature; secp256k1 a DER-encoded one, matching
/// what the XRP Ledger expects for each signing scheme.
static func sign(message: [UInt8], keypair: XRPKeypair) throws -> [UInt8] {
switch keypair.algorithm {
case .ed25519:
let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: Data(keypair.privateKey))
let signature = try privateKey.signature(for: Data(message))
return Array(signature)
case .secp256k1:
let digest = XRPHash.sha512Half(message)
guard let signFn = Secp256k1Backend.sign,
let signature = signFn(Data(digest), Data(keypair.privateKey)) else {
throw XRPError.unsupportedSeed
}
return Array(signature)
}
}
}
@@ -0,0 +1,161 @@
import Foundation
/// High level XRP operations: wallet creation/import, balances, history, sends.
final class XRPWalletService {
static let shared = XRPWalletService()
private let rippleEpoch: TimeInterval = 946_684_800
// MARK: - Wallet lifecycle
func createWallet(name: String) throws -> (WalletRecord, String) {
let mnemonic = XRPKey.generateMnemonic()
let keypair = try XRPKey.keypair(fromMnemonic: mnemonic)
let record = WalletRecord(id: "xrp_\(Int(Date().timeIntervalSince1970))",
name: name,
address: keypair.address,
createdAt: Date())
WalletStore.saveWallet(record, chain: .xrp)
WalletStore.saveSecret(mnemonic, chain: .xrp)
return (record, mnemonic)
}
func importWallet(name: String, secret: String) throws -> WalletRecord {
let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines)
let keypair = try XRPKey.keypair(fromSecret: trimmed)
let record = WalletRecord(id: "xrp_\(Int(Date().timeIntervalSince1970))",
name: name,
address: keypair.address,
createdAt: Date())
WalletStore.saveWallet(record, chain: .xrp)
WalletStore.saveSecret(trimmed, chain: .xrp)
return record
}
func address(forSecret secret: String) throws -> String {
try XRPKey.keypair(fromSecret: secret.trimmingCharacters(in: .whitespacesAndNewlines)).address
}
// MARK: - Reads
func balance(address: String) async throws -> Balance {
do {
let result = try await XRPClient.shared.rpc("account_info",
["account": address,
"ledger_index": "current"])
let accountData = result["account_data"] as? [String: Any] ?? [:]
let drops = accountData["Balance"] as? String ?? "0"
return Balance(total: XRPAmount.xrp(fromDrops: drops), unlocked: "", funded: true)
} catch {
if XRPClient.isAccountNotFound(error) {
return Balance(total: "0", unlocked: "", funded: false)
}
throw error
}
}
func transactions(address: String) async throws -> [TransactionRecord] {
let result = try await XRPClient.shared.rpc("account_tx",
["account": address,
"ledger_index_min": -1,
"ledger_index_max": -1,
"limit": 40])
let entries = result["transactions"] as? [[String: Any]] ?? []
var records: [TransactionRecord] = []
for entry in entries {
guard let tx = entry["tx_json"] as? [String: Any] ?? entry["tx"] as? [String: Any] else { continue }
guard (tx["TransactionType"] as? String) == "Payment" else { continue }
let meta = entry["meta"] as? [String: Any] ?? entry["metaData"] as? [String: Any] ?? [:]
if let txResult = meta["TransactionResult"] as? String, txResult != "tesSUCCESS" { continue }
// IOU / MPT deliveries carry an object; only native XRP is shown.
let delivered = (meta["delivered_amount"] as? String)
?? (meta["DeliveredAmount"] as? String)
?? (tx["Amount"] as? String)
guard let deliveredDrops = delivered else { continue }
let destination = tx["Destination"] as? String ?? ""
let isIncoming = destination == address
let date = (tx["date"] as? NSNumber).map { Date(timeIntervalSince1970: $0.doubleValue + rippleEpoch) }
records.append(TransactionRecord(
id: entry["hash"] as? String ?? tx["hash"] as? String ?? UUID().uuidString,
amount: XRPAmount.xrp(fromDrops: deliveredDrops),
timestamp: date,
direction: isIncoming ? .incoming : .outgoing,
confirmed: (entry["validated"] as? Bool) ?? false,
fee: (tx["Fee"] as? String).map { XRPAmount.xrp(fromDrops: $0) },
tag: (tx["DestinationTag"] as? NSNumber)?.intValue,
counterparty: isIncoming ? (tx["Account"] as? String) : destination,
height: (entry["ledger_index"] as? NSNumber)?.uint64Value,
ledgerIndex: (entry["ledger_index"] as? NSNumber)?.uint64Value,
sequence: (tx["Sequence"] as? NSNumber)?.uint64Value))
}
// Newest first, matching the Monero list.
return records.sorted { ($0.timestamp ?? .distantFuture) > ($1.timestamp ?? .distantFuture) }
}
// MARK: - Sends
func send(secret: String,
to: String,
amountXRP: String,
destinationTag: UInt32?) async throws -> (hash: String, fee: String) {
let destination = to.trimmingCharacters(in: .whitespacesAndNewlines)
guard XRPKey.isValidAddress(destination) else {
throw XRPError.rpc("Enter a valid XRP address")
}
let keypair = try XRPKey.keypair(fromSeed: secret)
let amountDrops = try XRPAmount.drops(fromXRP: amountXRP)
async let infoCall = XRPClient.shared.rpc("account_info",
["account": keypair.address,
"ledger_index": "current"])
async let feeCall = XRPClient.shared.rpc("fee", [:])
async let ledgerCall = XRPClient.shared.rpc("ledger_current", [:])
let info = try await infoCall
let accountData = info["account_data"] as? [String: Any] ?? [:]
guard let sequence = (accountData["Sequence"] as? NSNumber)?.uint32Value else {
throw XRPError.rpc("Could not read the account sequence")
}
let balanceDrops = UInt64((accountData["Balance"] as? String) ?? "0") ?? 0
let feeResult = try await feeCall
let feeDrops = (feeResult["drops"] as? [String: Any])
.flatMap { $0["open_ledger_fee"] as? String }
.flatMap { UInt64($0) } ?? 12
let ledger = try await ledgerCall
let ledgerIndex = (ledger["ledger_current_index"] as? NSNumber)?.uint32Value
guard amountDrops + feeDrops <= balanceDrops else {
throw XRPError.rpc("Insufficient balance to cover the amount and network fee")
}
var payment = XRPPayment(account: keypair.address,
destination: destination,
amountDrops: amountDrops,
feeDrops: feeDrops,
sequence: sequence,
lastLedgerSequence: ledgerIndex.map { $0 + 20 },
destinationTag: destinationTag,
signingPublicKey: keypair.publicKey,
signature: nil)
let signature = try XRPKey.sign(message: payment.signingPayload(), keypair: keypair)
payment.signature = signature
let blob = Hex.encode(try payment.txBlob())
let result = try await XRPClient.shared.rpc("submit", ["tx_blob": blob])
let engineResult = result["engine_result"] as? String ?? ""
guard engineResult.hasPrefix("tes") else {
let message = result["engine_result_message"] as? String ?? "Transaction failed"
throw XRPError.rpc("\(engineResult): \(message)")
}
return (Hex.encode(try payment.transactionHash()), XRPAmount.xrp(fromDrops: feeDrops))
}
}
+120
View File
@@ -0,0 +1,120 @@
# Monero native library for iOS
The wallet runs monero-project `wallet2` (plus monero-cpp as the C++ API) on
device. Everything is compiled into a single static archive that the app links.
## Artifacts
| File | Architecture | Platform |
|---|---|---|
| `libs/libkoins_monero_merged.a` | arm64 | iOS simulator |
| `libs/libkoins_monero_device.a` | arm64 | iOS device |
Both are needed: they are the same CPU architecture but different platforms, so
they cannot be merged into one fat archive. `project.yml` selects the right one
per SDK.
## Prerequisites
- Xcode with the iOS device and simulator SDKs
- CMake 3.2x (monero 0.18 is not tested with CMake 4)
- GNU make (the builds use the "Unix Makefiles" generator)
- Roughly 10 GB of disk and a long coffee break
## Sources
- monero-project v0.18.5.1
- monero-cpp (woodser), which keeps monero-project at `external/monero-project`
## Toolchain customisations
The files under `depends/` are the ones modified from upstream, kept here so the
archive can be regenerated:
| File | Change |
|---|---|
| `depends/hosts/ios.mk` | new host; `IOS_PLATFORM=SIMULATOR` (default) or `DEVICE` selects the SDK and the target triple (`aarch64-apple-ios[-simulator]`) |
| `depends/packages/packages.mk` | `ios_packages := boost openssl zeromq expat unbound sodium` (no libiconv) |
| `depends/packages/boost.mk` | boost for the `ios` toolset: darwin toolset, libtool archiver, c++11, no b2 python |
| `depends/packages/zeromq.mk` | ios config opts (`--host=aarch64-apple-darwin`), no libsodium, no curve |
| `depends/packages/unbound.mk` | drops the autoconf dependency |
| `depends/packages/sodium.mk` | drops the autoconf dependency |
| `depends/funcs.mk` | create the prefix `lib` directory before extracting dependency archives |
One source change is also required, in monero-project:
- `src/cryptonote_basic/miner.cpp` — wrap the IOKit includes and the related
code with `defined(TARGET_OS_OSX) && TARGET_OS_OSX`; IOKit is unavailable on
iOS. Two locations, around lines 51 and 986.
Two more steps are needed when targeting a device:
- Copy monero's patched boost serialisation headers into the dependency prefix,
because monero's build otherwise only finds them for the platform it was
installed for:
```sh
cp external/boost/archive/portable_binary_*.hpp \
contrib/depends/$HOST/include/boost/archive/
```
- randomx's AArch64 JIT references `__clear_cache`, which libSystem exports on
macOS and the simulator but not on iOS. The app supplies it in
`Koins/Support/ClearCache.c`.
## Build
Set `PLATFORM` to `SIMULATOR` or `DEVICE`; the only differences are the depends
host flag and the output archive name.
```sh
MONERO=/path/to/monero-project
PLATFORM=SIMULATOR # or DEVICE
HOST=aarch64-apple-ios
# 1. Cross-compiled dependencies (boost, openssl, zeromq, expat, unbound, sodium)
cd "$MONERO/contrib/depends"
make HOST=$HOST IOS_PLATFORM=$PLATFORM -j"$(sysctl -n hw.ncpu)"
# 2. monero-project, static wallet library
cd "$MONERO"
mkdir -p build/ios-$PLATFORM && cd build/ios-$PLATFORM
cmake -G "Unix Makefiles" \
-DCMAKE_TOOLCHAIN_FILE="$MONERO/contrib/depends/$HOST/share/toolchain.cmake" \
-DCMAKE_BUILD_TYPE=Release \
-DSTATIC=ON -DBUILD_SHARED_LIBS=OFF \
-DBUILD_TESTS=OFF -DBUILD_DOCUMENTATION=OFF -DUSE_DEVICE_TREZOR=OFF \
../..
make -j"$(sysctl -n hw.ncpu)" wallet # produces lib/libwallet.a
# 3. monero-cpp
cd /path/to/monero-cpp
mkdir -p build-ios-$PLATFORM && cd build-ios-$PLATFORM
cmake -G "Unix Makefiles" \
-DCMAKE_TOOLCHAIN_FILE="$MONERO/contrib/depends/$HOST/share/toolchain.cmake" \
../..
make -j"$(sysctl -n hw.ncpu)" # produces libmonero-cpp.a
# 4. Merge into one archive for the app
libtool -static -o libkoins_monero_merged.a \
libmonero-cpp.a \
"$MONERO/build/ios-$PLATFORM/lib/libwallet.a" \
"$MONERO/build/ios-$PLATFORM"/src/*/lib*.a \
"$MONERO/build/ios-$PLATFORM"/external/*/lib*.a \
"$MONERO/build/ios-$PLATFORM"/contrib/epee/src/libepee.a \
"$MONERO/contrib/depends/$HOST"/lib/lib*.a
```
Copy the result to `libs/`, using `libkoins_monero_merged.a` for the simulator
and `libkoins_monero_device.a` for the device.
## Notes
- Only the simulator archive is needed to run in the simulator; the device
archive is needed for a real iPhone, and Xcode requires the matching one at
link time.
- Building the device slice after a simulator build requires moving the
simulator depends prefix aside first, since depends installs into
`contrib/depends/<host>` and both platforms share the host name.
- The app also links the OpenSSL that is embedded in this archive, which is how
the XRP wallet gets secp256k1 without an extra dependency.
+270
View File
@@ -0,0 +1,270 @@
define int_vars
#Set defaults for vars which may be overridden per-package
$(1)_cc=$($($(1)_type)_CC)
$(1)_cxx=$($($(1)_type)_CXX)
$(1)_objc=$($($(1)_type)_OBJC)
$(1)_objcxx=$($($(1)_type)_OBJCXX)
$(1)_ar=$($($(1)_type)_AR)
$(1)_ranlib=$($($(1)_type)_RANLIB)
$(1)_libtool=$($($(1)_type)_LIBTOOL)
$(1)_nm=$($($(1)_type)_NM)
$(1)_cflags=$($($(1)_type)_CFLAGS) $($($(1)_type)_$(release_type)_CFLAGS)
$(1)_cxxflags=$($($(1)_type)_CXXFLAGS) $($($(1)_type)_$(release_type)_CXXFLAGS)
$(1)_arflags=$($($(1)_type)_ARFLAGS) $($($(1)_type)_$(release_type)_ARFLAGS)
$(1)_ldflags=$($($(1)_type)_LDFLAGS) $($($(1)_type)_$(release_type)_LDFLAGS) -L$($($(1)_type)_prefix)/lib
$(1)_cppflags=$($($(1)_type)_CPPFLAGS) $($($(1)_type)_$(release_type)_CPPFLAGS) -I$($($(1)_type)_prefix)/include
$(1)_recipe_hash:=
endef
define int_get_all_dependencies
$(sort $(foreach dep,$(2),$(2) $(call int_get_all_dependencies,$(1),$($(dep)_dependencies))))
endef
define fetch_file_inner
( mkdir -p $$($(1)_download_dir) && echo Fetching $(3) from $(2) && \
$(build_DOWNLOAD) "$$($(1)_download_dir)/$(4).temp" "$(2)/$(3)" && \
echo "$(5) $$($(1)_download_dir)/$(4).temp" > $$($(1)_download_dir)/.$(4).hash && \
$(build_SHA256SUM) -c $$($(1)_download_dir)/.$(4).hash && \
mv $$($(1)_download_dir)/$(4).temp $$($(1)_source_dir)/$(4) && \
rm -rf $$($(1)_download_dir) )
endef
define fetch_file
( test -f $$($(1)_source_dir)/$(4) || \
( $(call fetch_file_inner,$(1),$(2),$(3),$(4),$(5)) || \
$(call fetch_file_inner,$(1),$(FALLBACK_DOWNLOAD_PATH),$(4),$(4),$(5))))
endef
define int_get_build_recipe_hash
$(eval $(1)_all_file_checksums:=$(shell $(build_SHA256SUM) $(meta_depends) packages/$(1).mk $(addprefix $(PATCHES_PATH)/$(1)/,$($(1)_patches)) | cut -d" " -f1))
$(eval $(1)_recipe_hash:=$(shell echo -n "$($(1)_all_file_checksums)" | $(build_SHA256SUM) | cut -d" " -f1))
endef
define int_get_build_id
$(eval $(1)_dependencies += $($(1)_$(host_arch)_$(host_os)_dependencies) $($(1)_$(host_os)_dependencies))
$(eval $(1)_all_dependencies:=$(call int_get_all_dependencies,$(1),$($($(1)_type)_native_toolchain) $($(1)_dependencies)))
$(foreach dep,$($(1)_all_dependencies),$(eval $(1)_build_id_deps+=$(dep)-$($(dep)_version)-$($(dep)_recipe_hash)))
$(eval $(1)_build_id_long:=$(1)-$($(1)_version)-$($(1)_recipe_hash)-$(release_type) $($(1)_build_id_deps) $($($(1)_type)_id_string))
$(eval $(1)_build_id:=$(shell echo -n "$($(1)_build_id_long)" | $(build_SHA256SUM) | cut -c-$(HASH_LENGTH)))
final_build_id_long+=$($(package)_build_id_long)
#compute package-specific paths
$(1)_build_subdir?=.
$(1)_download_file?=$($(1)_file_name)
$(1)_source_dir:=$(SOURCES_PATH)
$(1)_source:=$$($(1)_source_dir)/$($(1)_file_name)
$(1)_staging_dir=$(base_staging_dir)/$(host)/$(1)/$($(1)_version)-$($(1)_build_id)
$(1)_staging_prefix_dir:=$$($(1)_staging_dir)$($($(1)_type)_prefix)
$(1)_extract_dir:=$(base_build_dir)/$(host)/$(1)/$($(1)_version)-$($(1)_build_id)
$(1)_download_dir:=$(base_download_dir)/$(1)-$($(1)_version)
$(1)_build_dir:=$$($(1)_extract_dir)/$$($(1)_build_subdir)
$(1)_cached_checksum:=$(BASE_CACHE)/$(host)/$(1)/$(1)-$($(1)_version)-$($(1)_build_id).tar.gz.hash
$(1)_patch_dir:=$(base_build_dir)/$(host)/$(1)/$($(1)_version)-$($(1)_build_id)/.patches-$($(1)_build_id)
$(1)_prefixbin:=$($($(1)_type)_prefix)/bin/
$(1)_cached:=$(BASE_CACHE)/$(host)/$(1)/$(1)-$($(1)_version)-$($(1)_build_id).tar.gz
$(1)_all_sources=$($(1)_file_name) $($(1)_extra_sources)
#stamps
$(1)_fetched=$(SOURCES_PATH)/download-stamps/.stamp_fetched-$(1)-$($(1)_file_name).hash
$(1)_extracted=$$($(1)_extract_dir)/.stamp_extracted
$(1)_preprocessed=$$($(1)_extract_dir)/.stamp_preprocessed
$(1)_cleaned=$$($(1)_extract_dir)/.stamp_cleaned
$(1)_built=$$($(1)_build_dir)/.stamp_built
$(1)_configured=$$($(1)_build_dir)/.stamp_configured
$(1)_staged=$$($(1)_staging_dir)/.stamp_staged
$(1)_postprocessed=$$($(1)_staging_prefix_dir)/.stamp_postprocessed
$(1)_download_path_fixed=$(subst :,\:,$$($(1)_download_path))
#default commands
$(1)_fetch_cmds ?= $(call fetch_file,$(1),$(subst \:,:,$$($(1)_download_path_fixed)),$$($(1)_download_file),$($(1)_file_name),$($(1)_sha256_hash))
$(1)_extract_cmds ?= mkdir -p $$($(1)_extract_dir) && echo "$$($(1)_sha256_hash) $$($(1)_source)" > $$($(1)_extract_dir)/.$$($(1)_file_name).hash && $(build_SHA256SUM) -c $$($(1)_extract_dir)/.$$($(1)_file_name).hash && tar --strip-components=1 -xf $$($(1)_source)
$(1)_preprocess_cmds ?=
$(1)_build_cmds ?=
$(1)_config_cmds ?=
$(1)_stage_cmds ?=
$(1)_set_vars ?=
all_sources+=$$($(1)_fetched)
endef
#$(foreach dep_target,$($(1)_all_dependencies),$(eval $(1)_dependency_targets=$($(dep_target)_cached)))
define int_config_attach_build_config
$(eval $(call $(1)_set_vars,$(1)))
$(1)_cflags+=$($(1)_cflags_$(release_type))
$(1)_cflags+=$($(1)_cflags_$(host_arch)) $($(1)_cflags_$(host_arch)_$(release_type))
$(1)_cflags+=$($(1)_cflags_$(host_os)) $($(1)_cflags_$(host_os)_$(release_type))
$(1)_cflags+=$($(1)_cflags_$(host_arch)_$(host_os)) $($(1)_cflags_$(host_arch)_$(host_os)_$(release_type))
$(1)_cxxflags+=$($(1)_cxxflags_$(release_type))
$(1)_cxxflags+=$($(1)_cxxflags_$(host_arch)) $($(1)_cxxflags_$(host_arch)_$(release_type))
$(1)_cxxflags+=$($(1)_cxxflags_$(host_os)) $($(1)_cxxflags_$(host_os)_$(release_type))
$(1)_cxxflags+=$($(1)_cxxflags_$(host_arch)_$(host_os)) $($(1)_cxxflags_$(host_arch)_$(host_os)_$(release_type))
$(1)_arflags+=$($(1)_arflags_$(release_type))
$(1)_arflags+=$($(1)_arflags_$(host_arch)) $($(1)_arflags_$(host_arch)_$(release_type))
$(1)_arflags+=$($(1)_arflags_$(host_os)) $($(1)_arflags_$(host_os)_$(release_type))
$(1)_arflags+=$($(1)_arflags_$(host_arch)_$(host_os)) $($(1)_arflags_$(host_arch)_$(host_os)_$(release_type))
$(1)_cppflags+=$($(1)_cppflags_$(release_type))
$(1)_cppflags+=$($(1)_cppflags_$(host_arch)) $($(1)_cppflags_$(host_arch)_$(release_type))
$(1)_cppflags+=$($(1)_cppflags_$(host_os)) $($(1)_cppflags_$(host_os)_$(release_type))
$(1)_cppflags+=$($(1)_cppflags_$(host_arch)_$(host_os)) $($(1)_cppflags_$(host_arch)_$(host_os)_$(release_type))
$(1)_ldflags+=$($(1)_ldflags_$(release_type))
$(1)_ldflags+=$($(1)_ldflags_$(host_arch)) $($(1)_ldflags_$(host_arch)_$(release_type))
$(1)_ldflags+=$($(1)_ldflags_$(host_os)) $($(1)_ldflags_$(host_os)_$(release_type))
$(1)_ldflags+=$($(1)_ldflags_$(host_arch)_$(host_os)) $($(1)_ldflags_$(host_arch)_$(host_os)_$(release_type))
$(1)_build_opts+=$$($(1)_build_opts_$(release_type))
$(1)_build_opts+=$$($(1)_build_opts_$(host_arch)) $$($(1)_build_opts_$(host_arch)_$(release_type))
$(1)_build_opts+=$$($(1)_build_opts_$(host_os)) $$($(1)_build_opts_$(host_os)_$(release_type))
$(1)_build_opts+=$$($(1)_build_opts_$(host_arch)_$(host_os)) $$($(1)_build_opts_$(host_arch)_$(host_os)_$(release_type))
$(1)_config_opts+=$$($(1)_config_opts_$(release_type))
$(1)_config_opts+=$$($(1)_config_opts_$(host_arch)) $$($(1)_config_opts_$(host_arch)_$(release_type))
$(1)_config_opts+=$$($(1)_config_opts_$(host_os)) $$($(1)_config_opts_$(host_os)_$(release_type))
$(1)_config_opts+=$$($(1)_config_opts_$(host_arch)_$(host_os)) $$($(1)_config_opts_$(host_arch)_$(host_os)_$(release_type))
$(1)_config_env+=$$($(1)_config_env_$(release_type))
$(1)_config_env+=$($(1)_config_env_$(host_arch)) $($(1)_config_env_$(host_arch)_$(release_type))
$(1)_config_env+=$($(1)_config_env_$(host_os)) $($(1)_config_env_$(host_os)_$(release_type))
$(1)_config_env+=$($(1)_config_env_$(host_arch)_$(host_os)) $($(1)_config_env_$(host_arch)_$(host_os)_$(release_type))
$(1)_build_env+=$$($(1)_build_env_$(release_type))
$(1)_build_env+=$($(1)_build_env_$(host_arch)) $($(1)_build_env_$(host_arch)_$(release_type))
$(1)_build_env+=$($(1)_build_env_$(host_os)) $($(1)_build_env_$(host_os)_$(release_type))
$(1)_build_env+=$($(1)_build_env_$(host_arch)_$(host_os)) $($(1)_build_env_$(host_arch)_$(host_os)_$(release_type))
$(1)_config_env+=PKG_CONFIG_LIBDIR=$($($(1)_type)_prefix)/lib/pkgconfig
$(1)_config_env+=PKG_CONFIG_PATH=$($($(1)_type)_prefix)/share/pkgconfig
$(1)_config_env+=PATH="$(build_prefix)/bin:$(PATH)"
$(1)_build_env+=PATH="$(build_prefix)/bin:$(PATH)"
$(1)_stage_env+=PATH="$(build_prefix)/bin:$(PATH)"
$(1)_autoconf=./configure --build=$(BUILD) --host=$($($(1)_type)_host) --prefix=$($($(1)_type)_prefix) $$($(1)_config_opts) CC="$$($(1)_cc)" CXX="$$($(1)_cxx)"
ifeq ($(filter $(1),libusb unbound),)
$(1)_autoconf += --disable-dependency-tracking
endif
ifneq ($($(1)_nm),)
$(1)_autoconf += NM="$$($(1)_nm)"
endif
ifneq ($($(1)_ranlib),)
$(1)_autoconf += RANLIB="$$($(1)_ranlib)"
endif
ifneq ($($(1)_ar),)
$(1)_autoconf += AR="$$($(1)_ar)"
endif
ifneq ($($(1)_arflags),)
$(1)_autoconf += ARFLAGS="$$($(1)_arflags)"
endif
ifneq ($($(1)_cflags),)
$(1)_autoconf += CFLAGS="$$($(1)_cflags)"
endif
ifneq ($($(1)_cxxflags),)
$(1)_autoconf += CXXFLAGS="$$($(1)_cxxflags)"
endif
ifneq ($($(1)_cppflags),)
$(1)_autoconf += CPPFLAGS="$$($(1)_cppflags)"
endif
ifneq ($($(1)_ldflags),)
$(1)_autoconf += LDFLAGS="$$($(1)_ldflags)"
endif
endef
define int_add_cmds
$($(1)_fetched):
$(AT)mkdir -p $$(@D) $(SOURCES_PATH)
$(AT)rm -f $$@
$(AT)touch $$@
$(AT)cd $$(@D); $(call $(1)_fetch_cmds,$(1))
$(AT)cd $($(1)_source_dir); $(foreach source,$($(1)_all_sources),$(build_SHA256SUM) $(source) >> $$(@);)
$(AT)touch $$@
$($(1)_extracted): | $($(1)_fetched)
$(AT)echo Extracting $(1)...
$(AT)mkdir -p $$(@D)
$(AT)cd $$(@D); $(call $(1)_extract_cmds,$(1))
$(AT)touch $$@
$($(1)_preprocessed): | $($(1)_dependencies) $($(1)_extracted)
$(AT)echo Preprocessing $(1)...
$(AT)mkdir -p $$(@D) $($(1)_patch_dir)
$(AT)$(foreach patch,$($(1)_patches),cd $(PATCHES_PATH)/$(1); cp $(patch) $($(1)_patch_dir) ;)
$(AT)cd $$(@D); $(call $(1)_preprocess_cmds, $(1))
$(AT)touch $$@
$($(1)_configured): | $($(1)_preprocessed)
$(AT)echo Configuring $(1)...
$(AT)rm -rf $(host_prefix); mkdir -p $(host_prefix)/lib; cd $(host_prefix); $(foreach package,$($(1)_all_dependencies), $(if $(strip $($(package)_cached)), tar xf $($(package)_cached);,))
$(AT)mkdir -p $$(@D)
$(AT)+cd $$(@D); $($(1)_config_env) $(call $(1)_config_cmds, $(1))
$(AT)touch $$@
$($(1)_built): | $($(1)_configured)
$(AT)echo Building $(1)...
$(AT)mkdir -p $$(@D)
$(AT)+cd $$(@D); $($(1)_build_env) $(call $(1)_build_cmds, $(1))
$(AT)touch $$@
$($(1)_staged): | $($(1)_built)
$(AT)echo Staging $(1)...
$(AT)mkdir -p $($(1)_staging_dir)/$(host_prefix)
$(AT)cd $($(1)_build_dir); $($(1)_stage_env) $(call $(1)_stage_cmds, $(1))
$(AT)rm -rf $($(1)_extract_dir)
$(AT)touch $$@
$($(1)_postprocessed): | $($(1)_staged)
$(AT)echo Postprocessing $(1)...
$(AT)cd $($(1)_staging_prefix_dir); $(call $(1)_postprocess_cmds)
$(AT)touch $$@
$($(1)_cached): | $($(1)_dependencies) $($(1)_postprocessed)
$(AT)echo Caching $(1)...
$(AT)cd $$($(1)_staging_dir)/$(host_prefix); find . | sort | tar --no-recursion -czf $$($(1)_staging_dir)/$$(@F) -T -
$(AT)mkdir -p $$(@D)
$(AT)rm -rf $$(@D) && mkdir -p $$(@D)
$(AT)mv $$($(1)_staging_dir)/$$(@F) $$(@)
$(AT)rm -rf $($(1)_staging_dir)
$($(1)_cached_checksum): $($(1)_cached)
$(AT)cd $$(@D); $(build_SHA256SUM) $$(<F) > $$(@)
.PHONY: $(1)
$(1): | $($(1)_cached_checksum)
.SECONDARY: $($(1)_cached) $($(1)_postprocessed) $($(1)_staged) $($(1)_built) $($(1)_configured) $($(1)_preprocessed) $($(1)_extracted) $($(1)_fetched)
endef
stages = fetched extracted preprocessed configured built staged postprocessed cached cached_checksum
define ext_add_stages
$(foreach stage,$(stages),
$(1)_$(stage): $($(1)_$(stage))
.PHONY: $(1)_$(stage))
endef
# These functions create the build targets for each package. They must be
# broken down into small steps so that each part is done for all packages
# before moving on to the next step. Otherwise, a package's info
# (build-id for example) would only be available to another package if it
# happened to be computed already.
#set the type for host/build packages.
$(foreach native_package,$(native_packages),$(eval $(native_package)_type=build))
$(foreach package,$(packages),$(eval $(package)_type=$(host_arch)_$(host_os)))
#set overridable defaults
$(foreach package,$(all_packages),$(eval $(call int_vars,$(package))))
#include package files
$(foreach package,$(all_packages),$(eval include packages/$(package).mk))
#compute a hash of all files that comprise this package's build recipe
$(foreach package,$(all_packages),$(eval $(call int_get_build_recipe_hash,$(package))))
#generate a unique id for this package, incorporating its dependencies as well
$(foreach package,$(all_packages),$(eval $(call int_get_build_id,$(package))))
#compute final vars after reading package vars
$(foreach package,$(all_packages),$(eval $(call int_config_attach_build_config,$(package))))
#create build targets
$(foreach package,$(all_packages),$(eval $(call int_add_cmds,$(package))))
#special exception: if a toolchain package exists, all non-native packages depend on it
$(foreach package,$(packages),$(eval $($(package)_unpacked): |$($($(host_arch)_$(host_os)_native_toolchain)_cached) ))
@@ -0,0 +1,41 @@
# iOS host for monero depends system (arm64 simulator for dev).
# Targets modern iOS (14+) on Apple Silicon simulators / devices.
IOS_MIN_VERSION=13.0
LD64_VERSION=609
ifndef IOS_PLATFORM
IOS_PLATFORM=SIMULATOR
endif
ifeq ($(IOS_PLATFORM),SIMULATOR)
IOS_SDK_ROOT := $(shell xcrun --sdk iphonesimulator --show-sdk-path)
CC_target=aarch64-apple-ios-simulator
else
IOS_SDK_ROOT := $(shell xcrun --sdk iphoneos --show-sdk-path)
CC_target=aarch64-apple-ios
endif
ios_CC=clang -target $(CC_target) -miphoneos-version-min=$(IOS_MIN_VERSION) --sysroot $(IOS_SDK_ROOT) -mlinker-version=$(LD64_VERSION)
ios_CXX=clang++ -target $(CC_target) -miphoneos-version-min=$(IOS_MIN_VERSION) --sysroot $(IOS_SDK_ROOT) -mlinker-version=$(LD64_VERSION) -stdlib=libc++
ios_CFLAGS=-pipe -isysroot $(IOS_SDK_ROOT)
ios_CXXFLAGS=-pipe -isysroot $(IOS_SDK_ROOT) -std=c++14 -stdlib=libc++
ios_ARFLAGS=cr
ios_release_CFLAGS=-O1
ios_release_CXXFLAGS=$(ios_release_CFLAGS)
ios_debug_CFLAGS=-O1
ios_debug_CXXFLAGS=$(ios_debug_CFLAGS)
ios_native_toolchain=native_cctools darwin_sdk
# iOS uses the system (macOS) ar/ranlib/strip/nm for cross-compiled archives.
ios_AR=ar
ios_RANLIB=ranlib
ios_STRIP=strip
ios_NM=nm
ios_LIBTOOL=libtool
ios_OTOOL=otool
ios_INSTALL_NAME_TOOL=install_name_tool
@@ -0,0 +1,46 @@
package=boost
$(package)_version=1.76.0
$(package)_download_path=https://archives.boost.io/release/$($(package)_version)/source/
$(package)_file_name=$(package)_$(subst .,_,$($(package)_version)).tar.gz
$(package)_sha256_hash=7bd7ddceec1a1dfdcbdb3e609b60d01739c38390a5f956385a12f3122049f0ca
$(package)_dependencies=
$(package)_patches=fix_integral_wrapper.patch
define $(package)_set_vars
$(package)_config_opts_release=variant=release
$(package)_config_opts_debug=variant=debug
$(package)_config_opts+=--layout=system --user-config=user-config.jam
$(package)_config_opts+=threading=multi link=static -sNO_BZIP2=1 -sNO_ZLIB=1
$(package)_config_opts_linux=threadapi=pthread runtime-link=shared
$(package)_config_opts_android=threadapi=pthread runtime-link=static target-os=android
$(package)_config_opts_darwin=--toolset=darwin runtime-link=shared
$(package)_config_opts_mingw32=binary-format=pe target-os=windows threadapi=win32 runtime-link=static
$(package)_config_opts_x86_64_mingw32=address-model=64
$(package)_config_opts_i686_mingw32=address-model=32
$(package)_config_opts_i686_linux=address-model=32 architecture=x86
$(package)_toolset_$(host_os)=gcc
$(package)_archiver_$(host_os)=$($(package)_ar)
$(package)_toolset_darwin=darwin
$(package)_archiver_darwin=$($(package)_libtool)
$(package)_config_libraries=chrono,filesystem,program_options,system,thread,test,date_time,regex,serialization,locale
$(package)_toolset_ios=darwin
$(package)_archiver_ios=libtool
$(package)_arflags_ios=
$(package)_cxxflags_ios=-fPIC -std=c++11 -stdlib=libc++
$(package)_cxxflags=-std=c++11
$(package)_cxxflags_linux=-fPIC
$(package)_cxxflags_freebsd=-fPIC
endef
define $(package)_preprocess_cmds
patch -p1 < $($(package)_patch_dir)/fix_integral_wrapper.patch &&\
echo "using $(boost_toolset_$(host_os)) : : $($(package)_cxx) : <root>\"$(IOS_SDK_ROOT)\" <cxxflags>\"$($(package)_cxxflags) $($(package)_cppflags) -isysroot $(IOS_SDK_ROOT)\" <linkflags>\"$($(package)_ldflags) -isysroot $(IOS_SDK_ROOT)\" <archiver>\"$(boost_archiver_$(host_os))\" <arflags>\"$($(package)_arflags)\" <striper>\"$(host_STRIP)\" <ranlib>\"$(host_RANLIB)\" <rc>\"$(host_WINDRES)\" : ;" > user-config.jam
endef
define $(package)_config_cmds
./bootstrap.sh --with-libraries=$(boost_config_libraries)
endef
define $(package)_stage_cmds
./b2 -d0 -j4 --prefix=$($(package)_staging_prefix_dir) $($(package)_config_opts) install
endef
@@ -0,0 +1,43 @@
packages:=boost openssl zeromq expat unbound
# iOS: iconv is provided by the system; libiconv-1.15 fails with modern clang.
ios_packages := boost openssl zeromq expat unbound sodium
# ccache is useless in gitian builds
ifneq ($(GITIAN),1)
native_packages := native_ccache
endif
hardware_packages := hidapi protobuf libusb
hardware_native_packages := native_protobuf
android_native_packages = android_ndk
android_packages = ncurses readline sodium
darwin_native_packages = $(hardware_native_packages)
darwin_packages = ncurses readline sodium $(hardware_packages)
# not really native...
freebsd_native_packages = freebsd_base
freebsd_packages = ncurses readline sodium
linux_packages = eudev ncurses readline sodium $(hardware_packages)
linux_native_packages = $(hardware_native_packages)
ifeq ($(build_tests),ON)
packages += gtest
endif
ifneq ($(host_arch),riscv64)
linux_packages += unwind
endif
mingw32_packages = icu4c sodium $(hardware_packages)
mingw32_native_packages = $(hardware_native_packages)
ifneq ($(build_os),darwin)
darwin_native_packages += darwin_sdk native_clang native_cctools native_libtapi
endif
@@ -0,0 +1,32 @@
package=sodium
$(package)_version=1.0.18
$(package)_download_path=https://download.libsodium.org/libsodium/releases/
$(package)_file_name=libsodium-$($(package)_version).tar.gz
$(package)_sha256_hash=6f504490b342a4f8a4c4a02fc9b866cbef8622d5df4e5452b46be121e46636c1
$(package)_patches=disable-glibc-getrandom-getentropy.patch fix-whitespace.patch
define $(package)_set_vars
$(package)_config_opts=--enable-static --disable-shared
$(package)_config_opts+=--prefix=$(host_prefix)
endef
define $(package)_preprocess_cmds
patch -p1 < $($(package)_patch_dir)/fix-whitespace.patch
endef
define $(package)_config_cmds
$($(package)_autoconf) AR_FLAGS=$($(package)_arflags)
endef
define $(package)_build_cmds
$(MAKE)
endef
define $(package)_stage_cmds
$(MAKE) DESTDIR=$($(package)_staging_dir) install
endef
define $(package)_postprocess_cmds
rm lib/*.la
endef
@@ -0,0 +1,36 @@
package=unbound
$(package)_version=1.25.1
$(package)_download_path=https://www.nlnetlabs.nl/downloads/$(package)/
$(package)_file_name=$(package)-$($(package)_version).tar.gz
$(package)_sha256_hash=0fe8b6277b0959cfd17562debac0aa5f71e0b02dc4ffa9c60271c583edab586f
$(package)_dependencies=openssl expat
$(package)_patches=disable-glibc-reallocarray.patch hardcode_config_date.patch
define $(package)_set_vars
$(package)_config_opts=--disable-shared --enable-static --without-pyunbound --prefix=$(host_prefix)
$(package)_config_opts+=--with-libexpat=$(host_prefix) --with-ssl=$(host_prefix) --with-libevent=no
$(package)_config_opts+=--without-pythonmodule --disable-flto --with-pthreads --with-libunbound-only
$(package)_config_opts_linux=--with-pic
$(package)_config_opts_w64=--enable-static-exe --sysconfdir=/etc --prefix=$(host_prefix) --target=$(host_prefix)
$(package)_config_opts_x86_64_darwin=ac_cv_func_SHA384_Init=yes
$(package)_build_opts_mingw32=LDFLAGS="$($(package)_ldflags) -lpthread"
$(package)_cflags_mingw32+="-D_WIN32_WINNT=0x600"
endef
define $(package)_preprocess_cmds
rm doc/*.odp doc/*.pdf contrib/*.tar.gz contrib/*.tar.bz2 &&\
rm -rf testdata dnscrypt/testdata
endef
define $(package)_config_cmds
$($(package)_autoconf) ac_cv_func_getentropy=no
endef
define $(package)_build_cmds
$(MAKE) $($(package)_build_opts)
endef
define $(package)_stage_cmds
$(MAKE) DESTDIR=$($(package)_staging_dir) install
endef
@@ -0,0 +1,37 @@
package=zeromq
$(package)_version=4.3.4
$(package)_download_path=https://github.com/zeromq/libzmq/releases/download/v$($(package)_version)/
$(package)_file_name=$(package)-$($(package)_version).tar.gz
$(package)_sha256_hash=c593001a89f5a85dd2ddf564805deb860e02471171b3f204944857336295c3e5
$(package)_patches=06aba27b04c5822cb88a69677382a0f053367143.patch
define $(package)_set_vars
$(package)_config_opts=--without-documentation --disable-shared --without-libsodium --disable-curve
$(package)_config_opts_linux=--with-pic
$(package)_config_opts_freebsd=--with-pic
$(package)_config_opts_ios=--host=aarch64-apple-darwin --with-pic
$(package)_cxxflags=-std=c++11
$(package)_cxxflags_ios=-std=c++11 -isysroot $(IOS_SDK_ROOT) -stdlib=libc++ -Wno-deprecated-declarations
endef
define $(package)_preprocess_cmds
patch -p1 < $($(package)_patch_dir)/06aba27b04c5822cb88a69677382a0f053367143.patch
endef
define $(package)_config_cmds
$($(package)_autoconf) AR_FLAGS=$($(package)_arflags)
endef
define $(package)_build_cmds
$(MAKE) src/libzmq.la
endef
define $(package)_stage_cmds
$(MAKE) DESTDIR=$($(package)_staging_dir) install-libLTLIBRARIES install-includeHEADERS install-pkgconfigDATA
endef
define $(package)_postprocess_cmds
rm -rf bin share &&\
rm lib/*.la
endef
+94
View File
@@ -0,0 +1,94 @@
# Koins iOS
Native SwiftUI wallet for Monero and XRP. iOS only, no JavaScript runtime.
## Layout
```
Koins/
KoinsApp.swift App entry point
AppModel.swift Observable state, orchestrates both chains
Models.swift Chain / wallet / transaction models
SelfTest.swift Launch-time verification (see below)
Bridge/
Koins-Bridging-Header.h
KoinsMoneroWallet.h/.mm ObjC++ bridge to the monero-cpp wallet
KoinsSecp256k1.h/.mm secp256k1 via the OpenSSL bundled in the monero lib
Monero/
libs/ prebuilt monero archives (simulator + device)
include/ monero-cpp headers
toolchain/ how those archives are built, plus the depends patches
Support/ Base58, RIPEMD-160, hashing, Keychain, wallet store
Wallet/ XRP keys, binary codec, RPC client, services
Views/ SwiftUI screens
```
## How the chains are implemented
- **Monero**: the wallet runs on device through `libkoins_monero_merged.a`
(monero-project `wallet2` + monero-cpp, built for `aarch64-apple-ios` and the
arm64 simulator). `KoinsMoneroWallet` serialises all C++ work on a private
queue and exchanges JSON with Swift. Wallet files live in the app's Documents
directory; the recovery seed is stored in the Keychain.
- **XRP**: implemented directly in Swift, no third-party package.
- Recovery phrase: a 12 word BIP-39 mnemonic, derived to the standard XRP
account path `m/44'/144'/0'/0/0` (BIP-32, secp256k1), so the same words
restore the same account in hardware and other mobile wallets.
- Imports also accept legacy XRP family seeds (`sEd…` / `s…`), including the
Ed25519 and secp256k1 variants.
- Key derivation and signing: Ed25519 via CryptoKit and secp256k1 via the
OpenSSL build embedded in the monero static library.
- Transaction serialization: canonical XRP binary encoding plus the
`STX`/`TXN` hash prefixes, matching `ripple-binary-codec`.
- Network: JSON-RPC against public XRP Ledger endpoints with failover.
## Build
```sh
cd apps/ios
xcodegen generate
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
```
Install and run on a booted simulator:
```sh
xcrun simctl install <udid> /tmp/koins-dd/Build/Products/Debug-iphonesimulator/Koins.app
xcrun simctl launch <udid> com.koins.app
```
Re-run `xcodegen generate` after adding or removing source files. No CocoaPods
and no Swift packages are used. The same commands work for a device: pass
`-sdk iphoneos -destination 'generic/platform=iOS'` and Xcode links the device
Monero archive automatically.
## Verification
The XRP implementation is checked against vectors produced by the reference
JavaScript stack (`xrpl`, `ripple-keypairs`, `ripple-binary-codec`).
Cryptography and serialization (no network):
```sh
xcrun simctl launch <udid> com.koins.app -koinsSelfTest
xcrun simctl spawn <udid> log show --last 1m --predicate 'subsystem == "com.koins.app"' --style compact
```
Live network check (creates and then removes a temporary wallet on both chains,
talks to a Monero daemon and the XRP Ledger):
```sh
xcrun simctl launch <udid> com.koins.app -koinsSelfTestNetwork
```
## Notes
- The Monero daemon default is `xmr-node.cakewallet.com:18081` with two
fallbacks; `MoneroService` reconnects on demand.
- Ed25519 and OpenSSL ECDSA both use randomised nonces, so signatures are valid
but not byte-identical to deterministic implementations such as
`ripple-keypairs`. Signatures are verified rather than compared.
- Refresh the simulator build when `/tmp` is cleared: the intermediate build
products live in `/tmp/koins-dd`.
+80
View File
@@ -0,0 +1,80 @@
name: Koins
options:
bundleIdPrefix: com.koins
deploymentTarget:
iOS: "16.0"
xcodeVersion: "16.0"
settings:
base:
ENABLE_USER_SCRIPT_SANDBOXING: NO
CLANG_CXX_LANGUAGE_STANDARD: "c++14"
GCC_TREAT_WARNINGS_AS_ERRORS: NO
CLANG_CXX_LIBRARY: "libc++"
SWIFT_VERSION: "5.0"
targets:
Koins:
type: application
platform: iOS
sources:
- path: Koins
excludes:
# Static archives are linked via LIBRARY_SEARCH_PATHS, not bundled.
- "Monero/libs"
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.koins.app
INFOPLIST_FILE: Koins/Info.plist
CODE_SIGN_STYLE: Automatic
DEVELOPMENT_TEAM: RX6767S6BW
CODE_SIGNING_ALLOWED[sdk=iphonesimulator*]: NO
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
SWIFT_OBJC_BRIDGING_HEADER: Koins/Bridge/Koins-Bridging-Header.h
OTHER_LDFLAGS[sdk=iphoneos*]:
- "$(inherited)"
- "-lkoins_monero_device"
OTHER_LDFLAGS[sdk=iphonesimulator*]:
- "$(inherited)"
- "-lkoins_monero_merged"
LIBRARY_SEARCH_PATHS:
- "$(SRCROOT)/Koins/Monero/libs"
HEADER_SEARCH_PATHS:
- "$(SRCROOT)/Koins/Monero/include/monero-cpp-src"
- "$(SRCROOT)/Koins/Monero/include/monero-cpp-src/wallet"
- "$(SRCROOT)/Koins/Monero/include/monero-cpp-src/common"
- "$(SRCROOT)/Koins/Monero/include/monero-cpp-src/utils"
- "$(SRCROOT)/Koins/Monero/include/monero-cpp-src/daemon"
- "$(SRCROOT)/../../build/monero-cpp/external/monero-project/src"
- "$(SRCROOT)/../../build/monero-cpp/external/monero-project/contrib"
- "$(SRCROOT)/../../build/monero-cpp/external/monero-project/external/rapidjson/include"
- "$(SRCROOT)/../../build/monero-cpp/external/monero-project/contrib/epee/include"
- "$(SRCROOT)/../../build/monero-cpp/external/monero-project/external/easylogging++"
- "$(SRCROOT)/../../build/monero-cpp/external/monero-project/external/boost"
- "$(SRCROOT)/../../build/monero-cpp/external/monero-project/contrib/depends/aarch64-apple-ios/include"
OTHER_CPLUSPLUSFLAGS:
- "$(inherited)"
- "-DKOINS_MONERO_NATIVE=1"
- "-DBOOST_UUID_DISABLE_ALIGNMENT"
GCC_PREPROCESSOR_DEFINITIONS:
- "$(inherited)"
- "KOINS_MONERO_NATIVE=1"
info:
path: Koins/Info.plist
properties:
UILaunchScreen: {}
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
UIUserInterfaceStyle: Dark
NSCameraUsageDescription: Scan a QR code to fill in a payment address.
NSFaceIDUsageDescription: Unlock Koins and approve payments.
NSAppTransportSecurity:
NSAllowsArbitraryLoads: true
schemes:
Koins:
build:
targets:
Koins: all
run:
config: Debug
+4 -1
View File
@@ -18,6 +18,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",
@@ -640,6 +641,8 @@
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"jsqr": ["jsqr@1.4.0", "", {}, "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"koins": ["koins@workspace:apps/desktop"],
@@ -846,7 +849,7 @@
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"sv-router": ["sv-router@0.18.0", "", { "dependencies": { "esm-env": "^1.2.2" }, "peerDependencies": { "svelte": "^5" }, "bin": { "sv-router": "./src/cli/index.js" } }, "sha512-gLky7FBGj8P6MAna1wxYniHJxMrXgaeTNnkZ7WH387ddRyUohfDtvEgWQFfB4oLL2XxWlw8Lfmk03TtBdK/JTA=="],
"sv-router": ["sv-router@0.18.1", "", { "dependencies": { "esm-env": "^1.2.2" }, "peerDependencies": { "svelte": "^5" }, "bin": { "sv-router": "./src/cli/index.js" } }, "sha512-xMJxgDwmcWtLDopU6xdEiBICRN40ucTlK63GUZf6cQk8689LUaUD5FlMrdtTdPFRQ2kfJBeJgoaI1k+XOuIQSQ=="],
"svelte": ["svelte@5.55.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw=="],
-1
View File
@@ -4,7 +4,6 @@
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"dev:mobile": "turbo run dev:hmr",
"lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"check-types": "turbo run check-types"