add vue lynx mobile wallet
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
@tailwind base;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
background-color: #0a0a0b;
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
page {
|
||||
background-color: #0a0a0b;
|
||||
}
|
||||
|
||||
input {
|
||||
background-color: #18181b;
|
||||
color: #fafafa;
|
||||
caret-color: #fafafa;
|
||||
}
|
||||
|
||||
scroll-view {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
view {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
text {
|
||||
color: #fafafa;
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue-lynx'
|
||||
|
||||
import './App.css'
|
||||
import {
|
||||
generateWallet,
|
||||
walletFromSecret,
|
||||
isValidAddress,
|
||||
getWallet,
|
||||
saveWallet,
|
||||
deleteWallet,
|
||||
getSeed,
|
||||
saveSeed,
|
||||
getBalance,
|
||||
getTransactions,
|
||||
sendPayment,
|
||||
type XrpWalletInfo,
|
||||
type XrpBalance,
|
||||
type XrpTxEntry,
|
||||
} from './lib/xrp'
|
||||
|
||||
type Screen =
|
||||
| 'welcome'
|
||||
| 'create'
|
||||
| 'import'
|
||||
| 'dashboard'
|
||||
| 'send'
|
||||
| 'receive'
|
||||
|
||||
const screen = ref<Screen>('welcome')
|
||||
const wallet = ref<XrpWalletInfo | null>(null)
|
||||
const seed = ref<string | null>(null)
|
||||
|
||||
const balance = ref<XrpBalance | null>(null)
|
||||
const transactions = ref<XrpTxEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
|
||||
const newName = ref('')
|
||||
const newMnemonic = ref('')
|
||||
const importName = ref('')
|
||||
const importSecret = ref('')
|
||||
const createdSeed = ref('')
|
||||
|
||||
const sendTo = ref('')
|
||||
const sendAmount = ref('')
|
||||
const sendTag = ref('')
|
||||
const sending = ref(false)
|
||||
|
||||
const showAddress = computed(() => wallet.value?.address ?? '')
|
||||
const truncatedAddress = computed(() => {
|
||||
const a = showAddress.value
|
||||
if (a.length <= 16) return a
|
||||
return `${a.slice(0, 8)}...${a.slice(-8)}`
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
wallet.value = getWallet()
|
||||
if (wallet.value) {
|
||||
seed.value = getSeed()
|
||||
screen.value = 'dashboard'
|
||||
refresh()
|
||||
}
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
const w = wallet.value
|
||||
if (!w) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const [bal, txs] = await Promise.all([
|
||||
getBalance(w.address),
|
||||
getTransactions(w.address),
|
||||
])
|
||||
balance.value = bal
|
||||
transactions.value = txs
|
||||
} catch (e) {
|
||||
console.info('[koins] refresh error:', e instanceof Error ? e.message : String(e))
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load wallet'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
error.value = ''
|
||||
if (!newName.value.trim()) {
|
||||
error.value = 'Enter a wallet name'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { address, seed: newSeed } = generateWallet()
|
||||
const w: XrpWalletInfo = {
|
||||
id: `xrp_${Date.now()}`,
|
||||
name: newName.value.trim(),
|
||||
address,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
wallet.value = w
|
||||
seed.value = newSeed
|
||||
saveWallet(w)
|
||||
saveSeed(newSeed)
|
||||
createdSeed.value = newSeed
|
||||
newName.value = ''
|
||||
screen.value = 'create'
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to create wallet'
|
||||
}
|
||||
}
|
||||
|
||||
function finishCreate() {
|
||||
createdSeed.value = ''
|
||||
screen.value = 'dashboard'
|
||||
refresh()
|
||||
}
|
||||
|
||||
function handleImport() {
|
||||
error.value = ''
|
||||
if (!importName.value.trim()) {
|
||||
error.value = 'Enter a wallet name'
|
||||
return
|
||||
}
|
||||
if (!importSecret.value.trim()) {
|
||||
error.value = 'Enter your seed or secret'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { address } = walletFromSecret(importSecret.value)
|
||||
const w: XrpWalletInfo = {
|
||||
id: `xrp_${Date.now()}`,
|
||||
name: importName.value.trim(),
|
||||
address,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
wallet.value = w
|
||||
seed.value = importSecret.value.trim()
|
||||
saveWallet(w)
|
||||
saveSeed(importSecret.value.trim())
|
||||
importName.value = ''
|
||||
importSecret.value = ''
|
||||
screen.value = 'dashboard'
|
||||
refresh()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to import wallet'
|
||||
}
|
||||
}
|
||||
|
||||
function handleSend() {
|
||||
error.value = ''
|
||||
if (!isValidAddress(sendTo.value.trim())) {
|
||||
error.value = 'Invalid XRP address'
|
||||
return
|
||||
}
|
||||
const amount = parseFloat(sendAmount.value)
|
||||
if (!amount || amount <= 0) {
|
||||
error.value = 'Enter a valid amount'
|
||||
return
|
||||
}
|
||||
if (!seed.value) {
|
||||
error.value = 'Wallet is locked'
|
||||
return
|
||||
}
|
||||
const bal = parseFloat(balance.value?.balance ?? '0')
|
||||
if (amount > bal) {
|
||||
error.value = 'Insufficient balance'
|
||||
return
|
||||
}
|
||||
sending.value = true
|
||||
sendPayment(
|
||||
seed.value,
|
||||
sendTo.value.trim(),
|
||||
sendAmount.value,
|
||||
sendTag.value ? parseInt(sendTag.value, 10) : undefined,
|
||||
)
|
||||
.then(() => {
|
||||
success.value = 'Payment sent'
|
||||
sendTo.value = ''
|
||||
sendAmount.value = ''
|
||||
sendTag.value = ''
|
||||
refresh()
|
||||
})
|
||||
.catch((e) => {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to send'
|
||||
})
|
||||
.finally(() => {
|
||||
sending.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (!wallet.value) return
|
||||
deleteWallet()
|
||||
wallet.value = null
|
||||
seed.value = null
|
||||
balance.value = null
|
||||
transactions.value = []
|
||||
screen.value = 'welcome'
|
||||
}
|
||||
|
||||
function goDashboard() {
|
||||
screen.value = 'dashboard'
|
||||
refresh()
|
||||
}
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
if (!ts) return 'pending'
|
||||
const d = new Date(ts * 1000)
|
||||
const month = d.toLocaleDateString(undefined, { month: 'short' })
|
||||
return `${month} ${d.getDate()}`
|
||||
}
|
||||
|
||||
function truncateHash(hash: string): string {
|
||||
if (hash.length <= 14) return hash
|
||||
return `${hash.slice(0, 7)}...${hash.slice(-7)}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app flex-1 bg-background px-5 pt-12 pb-6">
|
||||
|
||||
<!-- Welcome -->
|
||||
<view v-if="screen === 'welcome'" class="flex flex-col items-center">
|
||||
<text class="mt-12 text-5xl font-bold text-foreground">Koins</text>
|
||||
<text class="mt-1 mb-6 text-lg text-muted">XRP Wallet</text>
|
||||
<text class="mb-4 text-sm text-center text-muted">
|
||||
Create or import your XRP wallet
|
||||
</text>
|
||||
|
||||
<view class="mt-5 flex w-full flex-row gap-3">
|
||||
<view class="flex-1 bg-primary h-[52px] rounded-xl items-center justify-center" @tap="screen = 'create'">
|
||||
<text class="text-base font-semibold text-primary-foreground">Create Wallet</text>
|
||||
</view>
|
||||
<view class="flex-1 bg-zinc-800 h-[52px] rounded-xl items-center justify-center" @tap="screen = 'import'">
|
||||
<text class="text-base font-semibold text-foreground">Import</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Create -->
|
||||
<view v-else-if="screen === 'create'" class="flex flex-col items-center">
|
||||
<text class="self-start mb-5 text-[26px] font-semibold text-foreground">Create Wallet</text>
|
||||
<text v-if="!createdSeed" class="mb-4 text-sm text-center text-muted">Choose a name for your new wallet</text>
|
||||
<view v-if="!createdSeed" class="flex w-full flex-col gap-3">
|
||||
<input
|
||||
class="input h-[52px] w-full rounded-xl border border-border bg-card px-4 text-base text-foreground"
|
||||
:value="newName"
|
||||
@input="newName = $event.detail.value"
|
||||
placeholder="Wallet name"
|
||||
placeholder-class="text-muted"
|
||||
/>
|
||||
<view class="h-[52px] bg-primary w-full rounded-xl items-center justify-center" @tap="handleCreate">
|
||||
<text class="text-base font-semibold text-primary-foreground">Generate Wallet</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="flex w-full flex-col gap-2.5 rounded-2xl border border-yellow-800 bg-yellow-950 p-5">
|
||||
<text class="text-lg font-semibold text-yellow-200">Your Seed Phrase</text>
|
||||
<text class="text-[13px] text-yellow-300">Write this down. It is the only way to recover your wallet. Never share it.</text>
|
||||
<text class="rounded-[10px] border border-yellow-800 bg-card p-3.5 text-[15px] leading-6 text-foreground">
|
||||
{{ createdSeed }}
|
||||
</text>
|
||||
<view class="h-[52px] bg-primary w-full rounded-xl items-center justify-center" @tap="finishCreate">
|
||||
<text class="text-base font-semibold text-primary-foreground">I've Saved It</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text v-if="error" class="mt-3 text-center text-[13px] text-destructive">{{ error }}</text>
|
||||
</view>
|
||||
|
||||
<!-- Import -->
|
||||
<view v-else-if="screen === 'import'" class="flex flex-col items-center">
|
||||
<text class="self-start mb-5 text-[26px] font-semibold text-foreground">Import Wallet</text>
|
||||
<view class="flex w-full flex-col gap-3">
|
||||
<input
|
||||
class="input h-[52px] w-full rounded-xl border border-border bg-card px-4 text-base text-foreground"
|
||||
:value="importName"
|
||||
@input="importName = $event.detail.value"
|
||||
placeholder="Wallet name"
|
||||
placeholder-class="text-muted"
|
||||
/>
|
||||
<input
|
||||
class="input h-[52px] w-full rounded-xl border border-border bg-card px-4 text-base text-foreground"
|
||||
:value="importSecret"
|
||||
@input="importSecret = $event.detail.value"
|
||||
placeholder="Seed phrase or secret"
|
||||
placeholder-class="text-muted"
|
||||
/>
|
||||
<view class="h-[52px] bg-primary w-full rounded-xl items-center justify-center" @tap="handleImport">
|
||||
<text class="text-base font-semibold text-primary-foreground">Import Wallet</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="error" class="mt-3 text-center text-[13px] text-destructive">{{ error }}</text>
|
||||
</view>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<view v-else-if="screen === 'dashboard'" class="flex flex-col items-center">
|
||||
<view class="flex w-full flex-row items-start justify-between">
|
||||
<view class="flex flex-col">
|
||||
<text class="text-[17px] font-semibold text-foreground">{{ wallet?.name ?? 'Wallet' }}</text>
|
||||
<text class="mt-0.5 text-[13px] text-muted">{{ truncatedAddress }}</text>
|
||||
</view>
|
||||
<view class="flex flex-row gap-3">
|
||||
<text class="text-sm text-primary" @tap="handleDelete">Delete</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="mt-5 flex w-full flex-col items-center rounded-2xl border border-border bg-card p-7">
|
||||
<text class="text-sm text-muted">Total Balance</text>
|
||||
<text class="mt-1.5 text-4xl font-bold text-foreground">{{ loading && !balance ? '...' : (balance?.balance ?? '0') }} XRP</text>
|
||||
<text v-if="balance && !balance.funded" class="mt-1.5 text-[13px] text-yellow-400">Account not funded yet</text>
|
||||
<text v-if="error" class="mt-3 text-center text-[13px] text-destructive">{{ error }}</text>
|
||||
</view>
|
||||
|
||||
<view class="mt-5 flex w-full flex-row gap-3">
|
||||
<view class="flex-1 bg-primary h-[52px] rounded-xl items-center justify-center" @tap="screen = 'send'">
|
||||
<text class="text-base font-semibold text-primary-foreground">Send</text>
|
||||
</view>
|
||||
<view class="flex-1 bg-zinc-800 h-[52px] rounded-xl items-center justify-center" @tap="screen = 'receive'">
|
||||
<text class="text-base font-semibold text-foreground">Receive</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text class="self-start mt-7 mb-2 text-lg font-semibold text-foreground">Transactions</text>
|
||||
<scroll-view class="tx-list flex w-full flex-col" :scroll-y="true">
|
||||
<view v-if="transactions.length === 0 && !loading" class="py-6">
|
||||
<text class="text-sm text-center text-muted">No transactions yet</text>
|
||||
</view>
|
||||
<view v-for="tx in transactions" :key="tx.hash" class="mb-2 flex w-full flex-row items-center rounded-xl bg-card p-3.5">
|
||||
<view
|
||||
class="mr-3 h-9 w-9 rounded-full items-center justify-center"
|
||||
:class="tx.direction === 'in' ? 'bg-emerald-950' : 'bg-red-950'"
|
||||
>
|
||||
<text class="text-lg font-bold" :class="tx.direction === 'in' ? 'text-emerald-400' : 'text-red-400'">
|
||||
{{ tx.direction === 'in' ? '↓' : '↑' }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="flex flex-1 flex-col">
|
||||
<text class="text-[15px] font-medium text-foreground">{{ tx.direction === 'in' ? 'Received' : 'Sent' }}</text>
|
||||
<text class="mt-0.5 text-xs text-muted">{{ formatDate(tx.timestamp) }} · {{ truncateHash(tx.hash) }}</text>
|
||||
</view>
|
||||
<text class="text-[15px] font-semibold" :class="tx.direction === 'in' ? 'text-emerald-400' : 'text-red-400'">
|
||||
{{ tx.direction === 'in' ? '+' : '-' }}{{ tx.amount }}
|
||||
</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- Send -->
|
||||
<view v-else-if="screen === 'send'" class="flex flex-col items-center">
|
||||
<text class="self-start mb-5 text-[26px] font-semibold text-foreground">Send XRP</text>
|
||||
<view class="flex w-full flex-col gap-3">
|
||||
<input
|
||||
class="input h-[52px] w-full rounded-xl border border-border bg-card px-4 text-base text-foreground"
|
||||
:value="sendTo"
|
||||
@input="sendTo = $event.detail.value"
|
||||
placeholder="Destination address (r...)"
|
||||
placeholder-class="text-muted"
|
||||
/>
|
||||
<input
|
||||
class="input h-[52px] w-full rounded-xl border border-border bg-card px-4 text-base text-foreground"
|
||||
:value="sendAmount"
|
||||
@input="sendAmount = $event.detail.value"
|
||||
placeholder="Amount (XRP)"
|
||||
placeholder-class="text-muted"
|
||||
/>
|
||||
<input
|
||||
class="input h-[52px] w-full rounded-xl border border-border bg-card px-4 text-base text-foreground"
|
||||
:value="sendTag"
|
||||
@input="sendTag = $event.detail.value"
|
||||
placeholder="Destination tag (optional)"
|
||||
placeholder-class="text-muted"
|
||||
/>
|
||||
<view class="h-[52px] bg-primary w-full rounded-xl items-center justify-center" @tap="handleSend">
|
||||
<text class="text-base font-semibold text-primary-foreground">{{ sending ? 'Sending...' : 'Send' }}</text>
|
||||
</view>
|
||||
<view class="h-[52px] bg-zinc-800 w-full rounded-xl items-center justify-center" @tap="goDashboard">
|
||||
<text class="text-base font-semibold text-foreground">Cancel</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="error" class="mt-3 text-center text-[13px] text-destructive">{{ error }}</text>
|
||||
<text v-if="success" class="mt-3 text-center text-[13px] text-success">{{ success }}</text>
|
||||
</view>
|
||||
|
||||
<!-- Receive -->
|
||||
<view v-else-if="screen === 'receive'" class="flex flex-col items-center">
|
||||
<text class="self-start mb-5 text-[26px] font-semibold text-foreground">Receive XRP</text>
|
||||
<view class="flex w-full flex-col items-center rounded-2xl border border-border bg-card p-7">
|
||||
<text class="text-sm leading-6 text-center text-foreground">{{ showAddress }}</text>
|
||||
<text class="mt-4 text-sm text-center text-muted">Share this address to receive XRP</text>
|
||||
</view>
|
||||
<view class="mt-5 h-[52px] bg-primary w-full rounded-xl items-center justify-center" @tap="goDashboard">
|
||||
<text class="text-base font-semibold text-primary-foreground">Done</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
import './lib/text-encoder'
|
||||
|
||||
declare const lynxCoreInject: { tt?: Record<string, any> } | undefined
|
||||
|
||||
// The LynxExplorer SDK calls tt.callDestroyLifetimeFun() when destroying a
|
||||
// card. vue-lynx's runtime does not define it, which throws during page
|
||||
// teardown and can break subsequent loads. Provide the expected no-op hook.
|
||||
if (typeof lynxCoreInject !== 'undefined' && lynxCoreInject?.tt) {
|
||||
const tt = lynxCoreInject.tt
|
||||
if (typeof tt.callDestroyLifetimeFun !== 'function') {
|
||||
tt.callDestroyLifetimeFun = () => {}
|
||||
}
|
||||
}
|
||||
|
||||
import { createApp } from 'vue-lynx'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
app.mount()
|
||||
@@ -0,0 +1,38 @@
|
||||
declare const lynx: {
|
||||
getStorageSync(key: string): string
|
||||
setStorageSync(key: string, value: string): void
|
||||
removeStorageSync(key: string): void
|
||||
} | undefined
|
||||
|
||||
function lynxStorageAvailable(): boolean {
|
||||
return typeof lynx !== 'undefined' && typeof lynx.getStorageSync === 'function'
|
||||
}
|
||||
|
||||
export function getItem(key: string): string | null {
|
||||
try {
|
||||
const ls = globalThis.localStorage?.getItem(key)
|
||||
if (ls !== null && ls !== undefined) return ls
|
||||
} catch {}
|
||||
try {
|
||||
if (lynxStorageAvailable()) return lynx!.getStorageSync(key)
|
||||
} catch {}
|
||||
return null
|
||||
}
|
||||
|
||||
export function setItem(key: string, value: string): void {
|
||||
try {
|
||||
globalThis.localStorage?.setItem(key, value)
|
||||
} catch {}
|
||||
try {
|
||||
if (lynxStorageAvailable()) lynx!.setStorageSync(key, value)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function removeItem(key: string): void {
|
||||
try {
|
||||
globalThis.localStorage?.removeItem(key)
|
||||
} catch {}
|
||||
try {
|
||||
if (lynxStorageAvailable()) lynx!.removeStorageSync(key)
|
||||
} catch {}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// TextEncoder/TextDecoder polyfill for runtimes that lack them (Lynx PrimJS).
|
||||
// Needed by @noble/hashes (xrpl crypto stack) and other WASM-adjacent libs.
|
||||
// Must be imported BEFORE any module that transitively imports xrpl, because
|
||||
// @noble/curves calls utf8ToBytes at module-evaluation time.
|
||||
if (typeof globalThis.TextEncoder === 'undefined') {
|
||||
const utf8Encode = (str: string): Uint8Array => {
|
||||
const bytes: number[] = []
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
let code = str.charCodeAt(i)
|
||||
if (code < 0x80) {
|
||||
bytes.push(code)
|
||||
} else if (code < 0x800) {
|
||||
bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f))
|
||||
} else if (code >= 0xd800 && code <= 0xdbff && i + 1 < str.length) {
|
||||
const low = str.charCodeAt(i + 1)
|
||||
if (low >= 0xdc00 && low <= 0xdfff) {
|
||||
i++
|
||||
code = 0x10000 + ((code - 0xd800) << 10) + (low - 0xdc00)
|
||||
bytes.push(
|
||||
0xf0 | (code >> 18),
|
||||
0x80 | ((code >> 12) & 0x3f),
|
||||
0x80 | ((code >> 6) & 0x3f),
|
||||
0x80 | (code & 0x3f),
|
||||
)
|
||||
} else {
|
||||
bytes.push(0xef, 0xbf, 0xbd)
|
||||
}
|
||||
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
||||
bytes.push(0xef, 0xbf, 0xbd)
|
||||
} else {
|
||||
bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f))
|
||||
}
|
||||
}
|
||||
return Uint8Array.from(bytes)
|
||||
}
|
||||
|
||||
globalThis.TextEncoder = class TextEncoder {
|
||||
encoding = 'utf-8'
|
||||
encode(input: string = ''): Uint8Array {
|
||||
return utf8Encode(input)
|
||||
}
|
||||
} as unknown as typeof TextEncoder
|
||||
}
|
||||
|
||||
if (typeof globalThis.TextDecoder === 'undefined') {
|
||||
globalThis.TextDecoder = class TextDecoder {
|
||||
encoding = 'utf-8'
|
||||
decode(bytes?: Uint8Array): string {
|
||||
if (!bytes) return ''
|
||||
let result = ''
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
const b = bytes[i]
|
||||
if (b < 0x80) {
|
||||
result += String.fromCharCode(b)
|
||||
} else if (b < 0xe0 && i + 1 < bytes.length) {
|
||||
result += String.fromCharCode(((b & 0x1f) << 6) | (bytes[++i] & 0x3f))
|
||||
} else if (b < 0xf0 && i + 2 < bytes.length) {
|
||||
result += String.fromCharCode(
|
||||
((b & 0x0f) << 12) | ((bytes[++i] & 0x3f) << 6) | (bytes[++i] & 0x3f),
|
||||
)
|
||||
} else if (i + 3 < bytes.length) {
|
||||
const cp =
|
||||
((b & 0x07) << 18) |
|
||||
((bytes[++i] & 0x3f) << 12) |
|
||||
((bytes[++i] & 0x3f) << 6) |
|
||||
(bytes[++i] & 0x3f)
|
||||
result += String.fromCodePoint(cp)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
} as unknown as typeof TextDecoder
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { Wallet, xrpToDrops, dropsToXrp, isValidClassicAddress } from 'xrpl'
|
||||
import { getItem, setItem, removeItem } from './storage'
|
||||
|
||||
const RPC_URL = 'https://s1.ripple.com:51234/'
|
||||
const RIPPLE_EPOCH = 946684800
|
||||
const WALLET_KEY = 'koins_xrp_wallet'
|
||||
const SEED_KEY = 'koins_xrp_seed'
|
||||
|
||||
export interface XrpWalletInfo {
|
||||
id: string
|
||||
name: string
|
||||
address: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface XrpBalance {
|
||||
balance: string
|
||||
funded: boolean
|
||||
}
|
||||
|
||||
export interface XrpTxEntry {
|
||||
hash: string
|
||||
amount: string
|
||||
timestamp: number
|
||||
direction: 'in' | 'out'
|
||||
fee?: string
|
||||
confirmed: boolean
|
||||
destinationTag?: number
|
||||
}
|
||||
|
||||
async function rpc(command: string, params: Record<string, unknown>): Promise<any> {
|
||||
const res = await fetch(RPC_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
method: command,
|
||||
params: [params],
|
||||
id: 1,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error(`RPC ${command} failed: HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
const err = data?.result?.error ?? data?.error
|
||||
if (err) {
|
||||
const msg = data?.result?.error_message ?? data?.error_message ?? err
|
||||
const e = new Error(String(msg))
|
||||
;(e as any).code = err
|
||||
throw e
|
||||
}
|
||||
return data.result
|
||||
}
|
||||
|
||||
export function isValidAddress(address: string): boolean {
|
||||
return isValidClassicAddress(address)
|
||||
}
|
||||
|
||||
export function generateWallet(): { address: string; seed: string } {
|
||||
const wallet = Wallet.generate()
|
||||
return { address: wallet.classicAddress, seed: wallet.seed! }
|
||||
}
|
||||
|
||||
export function walletFromSecret(secret: string): { address: string } {
|
||||
const s = secret.trim()
|
||||
try {
|
||||
return { address: Wallet.fromSeed(s).classicAddress }
|
||||
} catch {
|
||||
return { address: Wallet.fromMnemonic(s).classicAddress }
|
||||
}
|
||||
}
|
||||
|
||||
export function walletForSigning(secret: string): Wallet {
|
||||
const s = secret.trim()
|
||||
try {
|
||||
return Wallet.fromSeed(s)
|
||||
} catch {
|
||||
return Wallet.fromMnemonic(s)
|
||||
}
|
||||
}
|
||||
|
||||
export function getWallet(): XrpWalletInfo | null {
|
||||
const raw = getItem(WALLET_KEY)
|
||||
if (!raw) return null
|
||||
try {
|
||||
return JSON.parse(raw) as XrpWalletInfo
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function saveWallet(wallet: XrpWalletInfo): void {
|
||||
setItem(WALLET_KEY, JSON.stringify(wallet))
|
||||
}
|
||||
|
||||
export function deleteWallet(): void {
|
||||
removeItem(WALLET_KEY)
|
||||
removeItem(SEED_KEY)
|
||||
}
|
||||
|
||||
export function getSeed(): string | null {
|
||||
return getItem(SEED_KEY)
|
||||
}
|
||||
|
||||
export function saveSeed(seed: string): void {
|
||||
setItem(SEED_KEY, seed)
|
||||
}
|
||||
|
||||
export async function getBalance(address: string): Promise<XrpBalance> {
|
||||
try {
|
||||
const result = await rpc('account_info', {
|
||||
account: address,
|
||||
ledger_index: 'current',
|
||||
})
|
||||
const balance = (result.account_data?.Balance ?? '0') as string
|
||||
return { balance: dropsAmount(balance), funded: true }
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'actNotFound') {
|
||||
return { balance: '0', funded: false }
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function dropsAmount(value: unknown): string {
|
||||
if (typeof value !== 'string') return '0'
|
||||
return String(dropsToXrp(value))
|
||||
}
|
||||
|
||||
export async function getTransactions(address: string): Promise<XrpTxEntry[]> {
|
||||
const result = await rpc('account_tx', {
|
||||
account: address,
|
||||
ledger_index_min: -1,
|
||||
ledger_index_max: -1,
|
||||
limit: 25,
|
||||
})
|
||||
const entries: XrpTxEntry[] = []
|
||||
for (const entry of result.transactions as any[]) {
|
||||
const tx = entry.tx_json ?? entry.tx
|
||||
if (!tx || tx.TransactionType !== 'Payment') continue
|
||||
const meta = entry.meta ?? entry.metaData ?? {}
|
||||
if (meta.TransactionResult && meta.TransactionResult !== 'tesSUCCESS') continue
|
||||
const delivered = meta.delivered_amount ?? meta.DeliveredAmount ?? tx.Amount
|
||||
entries.push({
|
||||
hash: entry.hash ?? tx.hash,
|
||||
amount: dropsAmount(delivered),
|
||||
timestamp: tx.date ? tx.date + RIPPLE_EPOCH : 0,
|
||||
direction: tx.Destination === address ? 'in' : 'out',
|
||||
fee: dropsAmount(tx.Fee),
|
||||
confirmed: entry.validated === true,
|
||||
destinationTag: tx.DestinationTag,
|
||||
})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
export async function sendPayment(
|
||||
secret: string,
|
||||
to: string,
|
||||
amount: string,
|
||||
destinationTag?: number,
|
||||
): Promise<{ hash: string; fee: string }> {
|
||||
if (!isValidClassicAddress(to)) throw new Error('Invalid XRP address')
|
||||
const wallet = walletForSigning(secret)
|
||||
const account = wallet.classicAddress
|
||||
|
||||
const [info, feeResult] = await Promise.all([
|
||||
rpc('account_info', { account, ledger_index: 'current' }),
|
||||
rpc('fee', {}),
|
||||
])
|
||||
const accountData = info.account_data ?? {}
|
||||
const sequence = accountData.Sequence as number
|
||||
const balanceDrops = BigInt(accountData.Balance ?? '0')
|
||||
const feeDrops = BigInt(feeResult.drops?.open_ledger_fee ?? '10')
|
||||
|
||||
const amountDrops = BigInt(xrpToDrops(amount))
|
||||
if (amountDrops + feeDrops > balanceDrops) {
|
||||
throw new Error('Insufficient balance (includes network fee)')
|
||||
}
|
||||
|
||||
const tx: Record<string, unknown> = {
|
||||
TransactionType: 'Payment',
|
||||
Account: account,
|
||||
Destination: to,
|
||||
Amount: xrpToDrops(amount),
|
||||
Fee: feeDrops.toString(),
|
||||
Sequence: sequence,
|
||||
Flags: 0,
|
||||
}
|
||||
if (destinationTag !== undefined) tx.DestinationTag = destinationTag
|
||||
|
||||
const signed = wallet.sign(tx)
|
||||
const result = await rpc('submit', { tx_blob: signed.tx_blob })
|
||||
const engineResult = result.engine_result
|
||||
if (engineResult !== 'tesSUCCESS') {
|
||||
const msg = result.engine_result_message ?? 'Transaction failed'
|
||||
throw new Error(`${engineResult}: ${msg}`)
|
||||
}
|
||||
return {
|
||||
hash: signed.hash,
|
||||
fee: dropsAmount(feeDrops.toString()),
|
||||
}
|
||||
}
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
/// <reference types="@lynx-js/rspeedy/client" />
|
||||
|
||||
declare module '@lynx-js/types' {
|
||||
interface GlobalProps {
|
||||
/**
|
||||
* Define your global properties in this interface.
|
||||
* These types will be accessible through `lynx.__globalProps`.
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
declare module '*.vue' {
|
||||
import type { Component } from 'vue-lynx'
|
||||
|
||||
const component: Component
|
||||
export default component
|
||||
}
|
||||
|
||||
declare module '*.png' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["vue-lynx/types"],
|
||||
|
||||
"noEmit": true,
|
||||
},
|
||||
"vueCompilerOptions": {
|
||||
"plugins": ["vue-lynx/types/volar-plugin"]
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.vue"],
|
||||
}
|
||||
Reference in New Issue
Block a user