diff --git a/README.md b/README.md index d116244..1f5a165 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ # Koins -Multi-chain desktop wallet for **Ethereum**, **BSC**, **Polygon**, and **Monero**. Built with [Bun](https://bun.sh) + [Electrobun](https://electrobun.dev) + Svelte. +Desktop wallet for **Monero** and **XRP**. Built with [Bun](https://bun.sh) + [Electrobun](https://electrobun.dev) + Svelte. ## Features -- **Multi-coin mode**: ETH, BSC, Polygon wallet with seed phrase vault stored in system keychain - **Monero mode**: Self-contained Monero wallet — auto-downloads `monero-wallet-rpc` binary on first launch, no manual binary install +- **XRP mode**: XRP Ledger wallet — generate or import XRP seeds, send payments with optional destination tags - **Touch ID unlock**: Biometric auth via Swift helper, no node-gyp or FFI dylib - **Password fallback**: PBKDF2-salted password stored in keychain, used when biometric is unavailable - **QR codes**: Address QR with chain logo overlay -- **Transaction history**: Cached in local SQLite via Drizzle ORM -- **Settings**: Reset app clears all DB tables and keychain entries +- **Transaction history**: Live XRP ledger transactions and Monero wallet transactions +- **Settings**: Reset app clears all keychain entries ## Prerequisites @@ -35,41 +35,38 @@ apps/desktop/ │ │ │ ├── monero/ # Monero RPC management │ │ │ │ ├── binary.ts # Download/extract monero-wallet-rpc │ │ │ │ └── wallet.ts # Child process + RPC client -│ │ │ ├── alchemy/ # Alchemy API calls -│ │ │ ├── viem/ # viem chain calls -│ │ │ ├── tokens/ # Token metadata (DB-first cache) -│ │ │ ├── transactions/ # Tx history + details (DB-first cache) -│ │ │ ├── db/ # Drizzle ORM + SQLite schema +│ │ │ ├── xrp/ # XRP Ledger client (xrpl) │ │ │ └── biometric.ts # Biometric auth (Swift subprocess) +│ │ ├── rpc/ +│ │ │ ├── monero/ # Monero RPC handlers +│ │ │ └── xrp/ # XRP RPC handlers │ │ └── native/ │ │ └── biometric-helper.swift # Touch ID prompt binary │ ├── lib/ │ │ ├── states/ -│ │ │ └── wallet.svelte.ts # Wallet state (singleton) -│ │ ├── components/ -│ │ │ └── EvmDashboard.svelte # Shared EVM dashboard +│ │ │ ├── monero-wallet.svelte.ts # Monero wallet state (singleton) +│ │ │ └── xrp-wallet.svelte.ts # XRP wallet state (singleton) │ │ └── electrobun.ts # RPC type definitions │ └── routes/ │ ├── index.svelte # Login/welcome page -│ ├── multicoin/ # ETH / BSC / Polygon routes -│ ├── monero/ # Monero dashboard -│ ├── settings.svelte # App settings -│ └── tx/ # Transaction details +│ ├── monero/ # Monero routes +│ ├── xrp/ # XRP routes +│ └── settings.svelte # App settings └── package.json ``` ## Key Decisions - **Monero via RPC, not WASM**: `monero-ts`'s WASM wallet (`MoneroWalletFull`) doesn't work in Bun because the C++ HTTP client fails at runtime. Instead, `monero-wallet-rpc` runs as a child process. -- **SQLite cache**: All chain data (tx history, receipts, token metadata) cached in SQLite via Drizzle, not localStorage. +- **XRP via xrpl**: Uses the official `xrpl` library over the public `wss://s1.ripple.com` WebSocket server. XRP seeds are stored in the system keychain. +- **Keychain storage**: Wallet seeds and metadata are stored in the macOS keychain via `Bun.secrets`, not a local database. - **Biometric auth**: Uses a Swift binary spawned via `Bun.spawnSync()` (not `dlopen` — Bun crashes on ObjC runtime; not `node-mac-auth` — fragile node-gyp chain). -- **Separate account types**: EVM and Monero are separate account types with their own seed formats and auth flows. +- **Separate account types**: Monero and XRP are separate account types with their own seed formats and auth flows. ## Dependencies -- [Viem](https://viem.sh) — EVM chain RPC calls + wallet operations +- [xrpl](https://github.com/XRPLF/xrpl.js) — XRP Ledger client - [monero-ts](https://github.com/woodser/monero-ts) — Monero RPC client -- [Drizzle ORM](https://orm.drizzle.team) — SQLite schema + queries - [Svelte 5](https://svelte.dev) — UI framework - [Electrobun](https://electrobun.dev) — Desktop app shell - [sv-router](https://github.com/TZGyn/sv-router) — File-based routing @@ -80,5 +77,4 @@ apps/desktop/ ```bash bun run dev # Start development server -bun run db:push # Push Drizzle schema to SQLite ``` diff --git a/apps/desktop/drizzle.config.ts b/apps/desktop/drizzle.config.ts deleted file mode 100644 index 870e0ea..0000000 --- a/apps/desktop/drizzle.config.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { defineConfig } from 'drizzle-kit' -import envPaths from 'env-paths' -import { join } from 'path' -import { mkdirSync } from 'fs' -import { getENV } from './src/bun/lib/get-env' - -const env = getENV() - -const { data } = envPaths(env.appName, { suffix: '' }) -mkdirSync(data, { recursive: true }) - -export default defineConfig({ - out: './drizzle', - schema: './src/bun/lib/db/schema.ts', - dialect: 'sqlite', - dbCredentials: { - url: join(data, env.dbFilename), - }, -}) diff --git a/apps/desktop/drizzle/20260514040815_regular_sunspot/migration.sql b/apps/desktop/drizzle/20260514040815_regular_sunspot/migration.sql deleted file mode 100644 index bd62f8a..0000000 --- a/apps/desktop/drizzle/20260514040815_regular_sunspot/migration.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE `token_metadata` ( - `name` text, - `symbol` text NOT NULL, - `decimals` integer, - `logo` text, - `contractAddress` text NOT NULL, - `chainId` text NOT NULL -); ---> statement-breakpoint -CREATE UNIQUE INDEX `contract_unique` ON `token_metadata` (`chainId`,`contractAddress`); \ No newline at end of file diff --git a/apps/desktop/drizzle/20260514040815_regular_sunspot/snapshot.json b/apps/desktop/drizzle/20260514040815_regular_sunspot/snapshot.json deleted file mode 100644 index 5d6e17b..0000000 --- a/apps/desktop/drizzle/20260514040815_regular_sunspot/snapshot.json +++ /dev/null @@ -1,93 +0,0 @@ -{ - "version": "7", - "dialect": "sqlite", - "id": "2845d968-33cd-48b6-8944-03e0e46a4a90", - "prevIds": [ - "00000000-0000-0000-0000-000000000000" - ], - "ddl": [ - { - "name": "token_metadata", - "entityType": "tables" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "name", - "entityType": "columns", - "table": "token_metadata" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "symbol", - "entityType": "columns", - "table": "token_metadata" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "decimals", - "entityType": "columns", - "table": "token_metadata" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "logo", - "entityType": "columns", - "table": "token_metadata" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "contractAddress", - "entityType": "columns", - "table": "token_metadata" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "chainId", - "entityType": "columns", - "table": "token_metadata" - }, - { - "columns": [ - { - "value": "chainId", - "isExpression": false - }, - { - "value": "contractAddress", - "isExpression": false - } - ], - "isUnique": true, - "where": null, - "origin": "manual", - "name": "contract_unique", - "entityType": "indexes", - "table": "token_metadata" - } - ], - "renames": [] -} \ No newline at end of file diff --git a/apps/desktop/drizzle/20260516080547_short_firebird/migration.sql b/apps/desktop/drizzle/20260516080547_short_firebird/migration.sql deleted file mode 100644 index d55b752..0000000 --- a/apps/desktop/drizzle/20260516080547_short_firebird/migration.sql +++ /dev/null @@ -1,41 +0,0 @@ -CREATE TABLE `transaction_receipts` ( - `transactionHash` text NOT NULL, - `chainId` text NOT NULL, - `blockHash` text NOT NULL, - `blockNumber` text NOT NULL, - `from` text NOT NULL, - `to` text, - `cumulativeGasUsed` text NOT NULL, - `gasUsed` text NOT NULL, - `status` text NOT NULL, - `effectiveGasPrice` text, - `contractAddress` text, - `type` text NOT NULL, - `transactionIndex` integer NOT NULL, - `logsBloom` text -); ---> statement-breakpoint -CREATE TABLE `transactions` ( - `hash` text NOT NULL, - `chainId` text NOT NULL, - `from` text NOT NULL, - `to` text, - `value` text NOT NULL, - `blockNumber` text, - `blockHash` text, - `gas` text NOT NULL, - `gasPrice` text, - `maxFeePerGas` text, - `maxPriorityFeePerGas` text, - `input` text NOT NULL, - `nonce` integer NOT NULL, - `transactionIndex` integer, - `type` text, - `v` text, - `r` text, - `s` text, - `yParity` integer -); ---> statement-breakpoint -CREATE UNIQUE INDEX `receipt_unique` ON `transaction_receipts` (`chainId`,`transactionHash`);--> statement-breakpoint -CREATE UNIQUE INDEX `tx_unique` ON `transactions` (`chainId`,`hash`); \ No newline at end of file diff --git a/apps/desktop/drizzle/20260516080547_short_firebird/snapshot.json b/apps/desktop/drizzle/20260516080547_short_firebird/snapshot.json deleted file mode 100644 index 32b2cfc..0000000 --- a/apps/desktop/drizzle/20260516080547_short_firebird/snapshot.json +++ /dev/null @@ -1,467 +0,0 @@ -{ - "version": "7", - "dialect": "sqlite", - "id": "3271a353-c528-4d23-a2ff-9be22f763b9d", - "prevIds": [ - "2845d968-33cd-48b6-8944-03e0e46a4a90" - ], - "ddl": [ - { - "name": "token_metadata", - "entityType": "tables" - }, - { - "name": "transaction_receipts", - "entityType": "tables" - }, - { - "name": "transactions", - "entityType": "tables" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "name", - "entityType": "columns", - "table": "token_metadata" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "symbol", - "entityType": "columns", - "table": "token_metadata" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "decimals", - "entityType": "columns", - "table": "token_metadata" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "logo", - "entityType": "columns", - "table": "token_metadata" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "contractAddress", - "entityType": "columns", - "table": "token_metadata" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "chainId", - "entityType": "columns", - "table": "token_metadata" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "transactionHash", - "entityType": "columns", - "table": "transaction_receipts" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "chainId", - "entityType": "columns", - "table": "transaction_receipts" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "blockHash", - "entityType": "columns", - "table": "transaction_receipts" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "blockNumber", - "entityType": "columns", - "table": "transaction_receipts" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "from", - "entityType": "columns", - "table": "transaction_receipts" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "to", - "entityType": "columns", - "table": "transaction_receipts" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "cumulativeGasUsed", - "entityType": "columns", - "table": "transaction_receipts" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "gasUsed", - "entityType": "columns", - "table": "transaction_receipts" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "status", - "entityType": "columns", - "table": "transaction_receipts" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "effectiveGasPrice", - "entityType": "columns", - "table": "transaction_receipts" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "contractAddress", - "entityType": "columns", - "table": "transaction_receipts" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "type", - "entityType": "columns", - "table": "transaction_receipts" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "transactionIndex", - "entityType": "columns", - "table": "transaction_receipts" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "logsBloom", - "entityType": "columns", - "table": "transaction_receipts" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "hash", - "entityType": "columns", - "table": "transactions" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "chainId", - "entityType": "columns", - "table": "transactions" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "from", - "entityType": "columns", - "table": "transactions" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "to", - "entityType": "columns", - "table": "transactions" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "value", - "entityType": "columns", - "table": "transactions" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "blockNumber", - "entityType": "columns", - "table": "transactions" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "blockHash", - "entityType": "columns", - "table": "transactions" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "gas", - "entityType": "columns", - "table": "transactions" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "gasPrice", - "entityType": "columns", - "table": "transactions" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "maxFeePerGas", - "entityType": "columns", - "table": "transactions" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "maxPriorityFeePerGas", - "entityType": "columns", - "table": "transactions" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "input", - "entityType": "columns", - "table": "transactions" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "nonce", - "entityType": "columns", - "table": "transactions" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "transactionIndex", - "entityType": "columns", - "table": "transactions" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "type", - "entityType": "columns", - "table": "transactions" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "v", - "entityType": "columns", - "table": "transactions" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "r", - "entityType": "columns", - "table": "transactions" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "s", - "entityType": "columns", - "table": "transactions" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "yParity", - "entityType": "columns", - "table": "transactions" - }, - { - "columns": [ - { - "value": "chainId", - "isExpression": false - }, - { - "value": "contractAddress", - "isExpression": false - } - ], - "isUnique": true, - "where": null, - "origin": "manual", - "name": "contract_unique", - "entityType": "indexes", - "table": "token_metadata" - }, - { - "columns": [ - { - "value": "chainId", - "isExpression": false - }, - { - "value": "transactionHash", - "isExpression": false - } - ], - "isUnique": true, - "where": null, - "origin": "manual", - "name": "receipt_unique", - "entityType": "indexes", - "table": "transaction_receipts" - }, - { - "columns": [ - { - "value": "chainId", - "isExpression": false - }, - { - "value": "hash", - "isExpression": false - } - ], - "isUnique": true, - "where": null, - "origin": "manual", - "name": "tx_unique", - "entityType": "indexes", - "table": "transactions" - } - ], - "renames": [] -} \ No newline at end of file diff --git a/apps/desktop/electrobun.config.ts b/apps/desktop/electrobun.config.ts index f958f7f..065c4cd 100644 --- a/apps/desktop/electrobun.config.ts +++ b/apps/desktop/electrobun.config.ts @@ -12,7 +12,6 @@ export default { 'dist/index.html': 'views/mainview/index.html', 'dist/assets': 'views/mainview/assets', 'dist/icons': 'views/mainview/icons', - 'drizzle': 'drizzle', 'src/bun/native/biometric-helper': 'native/biometric-helper', }, // Ignore Vite output in watch mode — HMR handles view rebuilds separately diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 2afe4d2..b759a14 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -11,23 +11,19 @@ "build:canary": "bun run build:native && vite build && electrobun build --env=canary", "build:stable": "bun run build:native && vite build && electrobun build --env=stable", "build:win": "vite build && electrobun build --env=stable", - "db:push": "bun x --bun drizzle-kit push", - "db:generate": "drizzle-kit generate", "build:native": "swiftc -O -o src/bun/native/biometric-helper src/bun/native/biometric-helper.swift" }, "dependencies": { - "@alchemy/common": "^5.0.0", - "@alchemy/wallet-apis": "^5.0.0", "@koins/utils": "workspace:*", "d3-scale": "^4.0.2", "d3-shape": "^3.2.0", - "drizzle-orm": "^1.0.0-beta.22", "electrobun": "^1.18.1", + "env-paths": "^4.0.0", "mode-watcher": "^1.1.0", "monero-ts": "^0.11.8", "qrcode": "^1.5.4", "sv-router": "latest", - "viem": "^2.48.11" + "xrpl": "^5.0.0" }, "devDependencies": { "@fontsource-variable/geist-mono": "^5.2.7", @@ -45,7 +41,6 @@ "bun-types": "^1.3.14", "clsx": "^2.1.1", "concurrently": "^9.2.1", - "drizzle-kit": "^1.0.0-beta.22", "embla-carousel-svelte": "^8.6.0", "formsnap": "^2.0.1", "layerchart": "2.0.0-next.48", diff --git a/apps/desktop/public/icons/binance.png b/apps/desktop/public/icons/binance.png deleted file mode 100644 index 27688c0..0000000 Binary files a/apps/desktop/public/icons/binance.png and /dev/null differ diff --git a/apps/desktop/public/icons/ethereum.png b/apps/desktop/public/icons/ethereum.png deleted file mode 100644 index bf957d3..0000000 Binary files a/apps/desktop/public/icons/ethereum.png and /dev/null differ diff --git a/apps/desktop/public/icons/polygon.png b/apps/desktop/public/icons/polygon.png deleted file mode 100644 index 6d3c0e9..0000000 Binary files a/apps/desktop/public/icons/polygon.png and /dev/null differ diff --git a/apps/desktop/public/icons/xrp.png b/apps/desktop/public/icons/xrp.png new file mode 100644 index 0000000..89ca272 Binary files /dev/null and b/apps/desktop/public/icons/xrp.png differ diff --git a/apps/desktop/src/bun/index.ts b/apps/desktop/src/bun/index.ts index a3cc018..752e8be 100644 --- a/apps/desktop/src/bun/index.ts +++ b/apps/desktop/src/bun/index.ts @@ -6,36 +6,21 @@ import { Updater, Utils, } from 'electrobun/bun' -import { migrate } from 'drizzle-orm/bun-sqlite/migrator' -import { db } from './lib/db' import { join } from 'path' import { existsSync } from 'fs' import { getENV } from './lib/get-env' -import { createEvmHandlers } from './rpc/evm' import { createMoneroHandlers } from './rpc/monero' +import { createXrpHandlers } from './rpc/xrp' import type { MoneroWalletState } from './lib/monero' import { canPromptTouchID, promptTouchID } from './lib/biometric' -import { sqlite } from './lib/sqlite' -import { evmWallets } from './lib/db/schema' +import { getXrpWalletList } from './lib/xrp' import type { RPC } from '../lib/rpc-schema' -import { log, logError } from './lib/logger' +import { log } from './lib/logger' const env = getENV() log(`env: ${JSON.stringify(env)}`) -if (env.env === 'stable') { - try { - log('running migrations...') - migrate(db, { - migrationsFolder: join(PATHS.RESOURCES_FOLDER, 'app', 'drizzle'), - }) - log('migrations complete') - } catch (e) { - logError('migration failed', e) - } -} - const DEV_SERVER_PORT = 5173 const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}` @@ -92,10 +77,6 @@ const moneroState = { manager: null as MoneroWalletState | null, downloading: false, } -const syncState = { - target: null as { address: string; chainid: string } | null, - timer: null as ReturnType | null, -} const rpc = BrowserView.defineRPC({ maxRequestTime: 120000, @@ -108,11 +89,11 @@ const rpc = BrowserView.defineRPC({ rpc.setRequestHandler({ resetApp: async () => { try { - const wallets = db.select().from(evmWallets).all() + const wallets = await getXrpWalletList() await Promise.all([ Bun.secrets.delete({ service: 'koins', - name: 'alchemy_key', + name: 'xrp_wallets', }), ...wallets.flatMap((w) => [ Bun.secrets.delete({ @@ -121,15 +102,10 @@ rpc.setRequestHandler({ }), Bun.secrets.delete({ service: 'koins', - name: `evm_auth_${w.id}`, + name: `xrp_auth_${w.id}`, }), ]), ]) - sqlite.run('DELETE FROM token_metadata') - sqlite.run('DELETE FROM transactions') - sqlite.run('DELETE FROM transaction_receipts') - sqlite.run('DELETE FROM tx_history') - sqlite.run('DELETE FROM evm_wallets') console.log('[rpc] resetApp complete') return true } catch (e) { @@ -174,8 +150,8 @@ rpc.setRequestHandler({ logToFile: async ({ message }) => { log(message) }, - ...createEvmHandlers(rpc, syncState), ...createMoneroHandlers(moneroState), + ...createXrpHandlers(), }) log('creating BrowserWindow...') diff --git a/apps/desktop/src/bun/lib/alchemy/call.ts b/apps/desktop/src/bun/lib/alchemy/call.ts deleted file mode 100644 index 2751ad3..0000000 --- a/apps/desktop/src/bun/lib/alchemy/call.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { tryCatch } from '@koins/utils' - -const TIMEOUT = 15_000 - -export const post = async ( - url: string, - body: any, -): Promise<{ jsonrpc: '2.0'; id: number; result: T }> => { - const [response, error] = await tryCatch( - fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(TIMEOUT), - }), - ) - - if (error) { - console.log(error) - throw error - } - - const [data, dataError] = await tryCatch(response.json()) - - if (dataError) { - console.log(dataError) - throw dataError - } - - return data -} diff --git a/apps/desktop/src/bun/lib/alchemy/get-token-balances.ts b/apps/desktop/src/bun/lib/alchemy/get-token-balances.ts deleted file mode 100644 index 6e33f46..0000000 --- a/apps/desktop/src/bun/lib/alchemy/get-token-balances.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { post } from './call' -import { getChainID, getURL } from './network' -import { inspect } from 'util' - -export const getTokensBalances = async ( - key: string, - chainid: string, - address: string, -) => { - const chainId = getChainID(chainid) - if (!chainId) return - - const url = getURL({ - chainId, - key, - }) - - const balRes = await post<{ - address: `0x${string}` - tokenBalances: { - contractAddress: `0x${string}` - tokenBalance: string - }[] - }>(url, { - jsonrpc: '2.0', - id: 0, - method: 'alchemy_getTokenBalances', - params: [address, 'erc20'], - }) - - return balRes -} diff --git a/apps/desktop/src/bun/lib/alchemy/get-token-metadata.ts b/apps/desktop/src/bun/lib/alchemy/get-token-metadata.ts deleted file mode 100644 index 1f04f18..0000000 --- a/apps/desktop/src/bun/lib/alchemy/get-token-metadata.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { post } from './call' -import { getChainID, getURL } from './network' - -export const getTokenMetadata = async ( - key: string, - chainid: string, - tokenAddress: string, -) => { - const chainId = getChainID(chainid) - if (!chainId) return - - const url = getURL({ chainId, key }) - - return await post<{ - name: string | null - symbol: string | null - decimals: number | null - logo: string | null - }>(url, { - jsonrpc: '2.0', - id: 0, - method: 'alchemy_getTokenMetadata', - params: [tokenAddress], - }) -} diff --git a/apps/desktop/src/bun/lib/alchemy/get-transaction-history.ts b/apps/desktop/src/bun/lib/alchemy/get-transaction-history.ts deleted file mode 100644 index 1696e77..0000000 --- a/apps/desktop/src/bun/lib/alchemy/get-transaction-history.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { post } from './call' -import { getChainID, getURL } from './network' -import type { Transaction } from './types' -import { numberToHex } from 'viem' - -type PageResult = { - transfers: Transaction[] - pageKey?: string -} - -async function fetchPage( - url: string, - fromAddr: string | undefined, - toAddr: string | undefined, - fromBlock: string | undefined, - chainId: string, -): Promise<{ result?: PageResult }> { - const category = ['external', 'erc20'] - if (chainId !== '56') { - category.push('internal') - } - - const body = { - jsonrpc: '2.0', - id: 0, - method: 'alchemy_getAssetTransfers', - params: [ - { - ...(fromAddr ? { fromAddress: fromAddr } : {}), - ...(toAddr ? { toAddress: toAddr } : {}), - ...(fromBlock ? { fromBlock } : {}), - category, - withMetadata: true, - maxCount: numberToHex(1000), - order: 'asc' as const, - }, - ], - } - - return post(url, body) -} - -export const getTransactionHistory = async ( - key: string, - chainid: string, - address: string, - opts: { maxCount: number; fromBlock?: string }, -) => { - const chainId = getChainID(chainid) - if (!chainId) return - - const url = getURL({ chainId, key }) - const seen = new Set() - const all: Transaction[] = [] - - for (const direction of ['out', 'in'] as const) { - const fromAddr = direction === 'out' ? address : undefined - const toAddr = direction === 'in' ? address : undefined - let cursorBlock = opts.fromBlock - - for (let page = 0; page < 100; page++) { - const res = await fetchPage(url, fromAddr, toAddr, cursorBlock, chainId) - const transfers = res.result?.transfers ?? [] - if (transfers.length === 0) break - - let pageMaxBlock = 0 - let firstTimestamp = '' - let lastTimestamp = '' - for (const t of transfers) { - if (!t.hash) continue - const dedupKey = `${t.hash}_${t.category}_${t.rawContract?.address ?? ''}` - if (seen.has(dedupKey)) continue - seen.add(dedupKey) - all.push(t) - - if (t.blockNum) { - const blockNum = parseInt(t.blockNum, 16) - if (blockNum > pageMaxBlock) { - pageMaxBlock = blockNum - } - } - if (t.metadata?.blockTimestamp) { - if (!firstTimestamp) firstTimestamp = t.metadata.blockTimestamp - lastTimestamp = t.metadata.blockTimestamp - } - } - - console.log( - `[alchemy] ${direction} page ${page + 1}: ${transfers.length} transfers, ` + - `block ${pageMaxBlock}, ` + - `${firstTimestamp ? new Date(firstTimestamp).toISOString() : '?'} → ${lastTimestamp ? new Date(lastTimestamp).toISOString() : '?'}`, - ) - - if (pageMaxBlock === 0) break - cursorBlock = numberToHex(pageMaxBlock + 1) - } - } - - console.log( - `[alchemy] total: ${all.length} transfers for ${chainid}/${address.slice(0, 10)}... ` + - `from ${opts.fromBlock ?? 'genesis'}`, - ) - return all -} diff --git a/apps/desktop/src/bun/lib/alchemy/index.ts b/apps/desktop/src/bun/lib/alchemy/index.ts deleted file mode 100644 index d8c5a17..0000000 --- a/apps/desktop/src/bun/lib/alchemy/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './get-token-balances' -export * from './get-token-metadata' -export * from './types' diff --git a/apps/desktop/src/bun/lib/alchemy/network.ts b/apps/desktop/src/bun/lib/alchemy/network.ts deleted file mode 100644 index f2ac99e..0000000 --- a/apps/desktop/src/bun/lib/alchemy/network.ts +++ /dev/null @@ -1,31 +0,0 @@ -export const ALCHEMY_NETWORKS = { - '1': 'eth-mainnet', - '137': 'polygon-mainnet', - '56': 'bnb-mainnet', -} as const - -export type ChainID = keyof typeof ALCHEMY_NETWORKS - -export const CHAIN_ID = Object.keys( - ALCHEMY_NETWORKS, -) as Array - -export const getChainID = (id: string) => { - if (CHAIN_ID.includes(id as ChainID)) { - return id as ChainID - } - return null -} - -export const getURL = ({ - key, - chainId, -}: { - chainId: ChainID - key: string -}) => { - const network = ALCHEMY_NETWORKS[chainId] - const url = `https://${network}.g.alchemy.com/v2/${key}` - - return url -} diff --git a/apps/desktop/src/bun/lib/alchemy/types.ts b/apps/desktop/src/bun/lib/alchemy/types.ts deleted file mode 100644 index 9ff9ec8..0000000 --- a/apps/desktop/src/bun/lib/alchemy/types.ts +++ /dev/null @@ -1,35 +0,0 @@ -export type Token = {} - -export type TokenBalance = {} - -export type Transaction = { - category?: - | 'external' - | 'internal' - | 'token' - | 'erc20' - | 'erc721' - | 'erc1155' - | 'specialnft' - blockNum?: string - from?: string - to?: string - value?: number - erc721TokenId?: string - erc1155Metadata?: { - tokenId: string - value: string - }[] - tokenId?: string - asset?: string - uniqueId?: string - hash?: string - rawContract?: { - value?: string - address?: string - decimal?: string - } - metadata?: { - blockTimestamp?: string - } -} diff --git a/apps/desktop/src/bun/lib/db/index.ts b/apps/desktop/src/bun/lib/db/index.ts deleted file mode 100644 index d595d89..0000000 --- a/apps/desktop/src/bun/lib/db/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { drizzle } from 'drizzle-orm/bun-sqlite' -import { sqlite } from '../sqlite' -import { relations } from './relations' - -export const db = drizzle({ client: sqlite, relations }) diff --git a/apps/desktop/src/bun/lib/db/relations.ts b/apps/desktop/src/bun/lib/db/relations.ts deleted file mode 100644 index ae8b602..0000000 --- a/apps/desktop/src/bun/lib/db/relations.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { defineRelations } from 'drizzle-orm' -import * as schema from './schema' - -export const relations = defineRelations(schema, () => ({})) diff --git a/apps/desktop/src/bun/lib/db/schema.ts b/apps/desktop/src/bun/lib/db/schema.ts deleted file mode 100644 index ee2210a..0000000 --- a/apps/desktop/src/bun/lib/db/schema.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { int, sqliteTable, text, uniqueIndex, index } from 'drizzle-orm/sqlite-core' - -export const tokenMetadata = sqliteTable( - 'token_metadata', - { - name: text(), - symbol: text().notNull(), - decimals: int(), - logo: text(), - contractAddress: text().notNull(), - chainId: text().notNull(), - }, - (table) => ({ - contractUnique: uniqueIndex('contract_unique').on( - table.chainId, - table.contractAddress, - ), - }), -) - -export const transactions = sqliteTable( - 'transactions', - { - hash: text().notNull(), - chainId: text().notNull(), - from: text().notNull(), - to: text(), - value: text().notNull(), - blockNumber: text(), - blockHash: text(), - gas: text().notNull(), - gasPrice: text(), - maxFeePerGas: text(), - maxPriorityFeePerGas: text(), - input: text().notNull(), - nonce: int().notNull(), - transactionIndex: int(), - type: text(), - v: text(), - r: text(), - s: text(), - yParity: int(), - }, - (table) => ({ - txUnique: uniqueIndex('tx_unique').on(table.chainId, table.hash), - }), -) - -export const transactionReceipts = sqliteTable( - 'transaction_receipts', - { - transactionHash: text().notNull(), - chainId: text().notNull(), - blockHash: text().notNull(), - blockNumber: text().notNull(), - from: text().notNull(), - to: text(), - cumulativeGasUsed: text().notNull(), - gasUsed: text().notNull(), - status: text().notNull(), - effectiveGasPrice: text(), - contractAddress: text(), - type: text().notNull(), - transactionIndex: int().notNull(), - logsBloom: text(), - }, - (table) => ({ - receiptUnique: uniqueIndex('receipt_unique').on( - table.chainId, - table.transactionHash, - ), - }), -) - -export const evmWallets = sqliteTable('evm_wallets', { - id: text('id').primaryKey(), - name: text('name').notNull(), - passwordHash: text('password_hash'), - vaultKey: text('vault_key').notNull(), - createdAt: text('created_at').notNull(), -}) - -export const txSyncStatus = sqliteTable('tx_sync_status', { - chainId: text('chain_id').notNull(), - address: text('address').notNull(), - lastSyncBlock: text('last_sync_block').notNull(), - syncedAt: text('synced_at').notNull(), -}, (table) => ({ - syncUnique: uniqueIndex('sync_unique').on(table.chainId, table.address), -})) - -export const txHistory = sqliteTable( - 'tx_history', - { - chainId: text().notNull(), - hash: text().notNull(), - blockNum: text(), - from: text(), - to: text(), - value: text(), - asset: text(), - category: text(), - rawContractAddress: text(), - rawContractDecimal: text(), - metadataBlockTimestamp: text(), - raw: text().notNull(), - syncedAt: text().notNull(), - }, - (table) => ({ - historyUnique: uniqueIndex('tx_history_unique').on(table.chainId, table.hash, table.category, table.rawContractAddress), - blockIdx: index('tx_history_block_idx').on(table.chainId, table.blockNum), - fromIdx: index('tx_history_from_idx').on(table.from), - toIdx: index('tx_history_to_idx').on(table.to), - }), -) diff --git a/apps/desktop/src/bun/lib/sqlite/index.ts b/apps/desktop/src/bun/lib/sqlite/index.ts deleted file mode 100644 index e1b3510..0000000 --- a/apps/desktop/src/bun/lib/sqlite/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Database } from 'bun:sqlite' -import envPaths from 'env-paths' -import { join } from 'path' -import { mkdirSync } from 'fs' -import { getENV } from '../get-env' - -const env = getENV() - -const { data } = envPaths(env.appName, { suffix: '' }) -mkdirSync(data, { recursive: true }) - -console.log(data) - -export const sqlite = new Database(join(data, env.dbFilename), { - create: true, -}) diff --git a/apps/desktop/src/bun/lib/sync/index.ts b/apps/desktop/src/bun/lib/sync/index.ts deleted file mode 100644 index b06499a..0000000 --- a/apps/desktop/src/bun/lib/sync/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { syncTransactionHistory } from './sync-tx-history' \ No newline at end of file diff --git a/apps/desktop/src/bun/lib/sync/sync-tx-details.ts b/apps/desktop/src/bun/lib/sync/sync-tx-details.ts deleted file mode 100644 index 600c787..0000000 --- a/apps/desktop/src/bun/lib/sync/sync-tx-details.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { getClient, getTransactionDetails } from '../viem' -import { db } from '../db' -import { transactions, transactionReceipts } from '../db/schema' -import { getChainID } from '../alchemy/network' - -export const syncTxDetails = async ( - chainId: string, - apiKey: string, - hash: string, -) => { - const client = getClient(chainId, apiKey) - - const id = getChainID(chainId) - if (!id) return null - - const raw = await getTransactionDetails(client, hash) - if (!raw) return null - - const hashLower = hash.toLowerCase() - - try { - await db - .insert(transactions) - .values({ - hash: hashLower, - chainId: id, - from: raw.tx.from.toLowerCase(), - to: raw.tx.to?.toLowerCase() ?? null, - value: raw.tx.value.toString(), - blockNumber: raw.tx.blockNumber?.toString() ?? null, - blockHash: raw.tx.blockHash?.toLowerCase() ?? null, - gas: raw.tx.gas.toString(), - gasPrice: raw.tx.gasPrice?.toString() ?? null, - maxFeePerGas: raw.tx.maxFeePerGas?.toString() ?? null, - maxPriorityFeePerGas: - raw.tx.maxPriorityFeePerGas?.toString() ?? null, - input: raw.tx.input, - nonce: raw.tx.nonce, - transactionIndex: raw.tx.transactionIndex ?? null, - type: raw.tx.type ?? null, - v: raw.tx.v?.toString() ?? null, - r: raw.tx.r?.toLowerCase() ?? null, - s: raw.tx.s?.toLowerCase() ?? null, - yParity: raw.tx.yParity ?? null, - }) - .onConflictDoNothing() - } catch (e) { - console.log('Failed to cache transaction', e) - } - - try { - await db - .insert(transactionReceipts) - .values({ - transactionHash: hashLower, - chainId: id, - blockHash: raw.receipt.blockHash.toLowerCase(), - blockNumber: raw.receipt.blockNumber.toString(), - from: raw.receipt.from.toLowerCase(), - to: raw.receipt.to?.toLowerCase() ?? null, - cumulativeGasUsed: raw.receipt.cumulativeGasUsed.toString(), - gasUsed: raw.receipt.gasUsed.toString(), - status: raw.receipt.status, - effectiveGasPrice: - raw.receipt.effectiveGasPrice?.toString() ?? null, - contractAddress: - raw.receipt.contractAddress?.toLowerCase() ?? null, - type: raw.receipt.type, - transactionIndex: raw.receipt.transactionIndex, - logsBloom: raw.receipt.logsBloom?.toLowerCase() ?? null, - }) - .onConflictDoNothing() - } catch (e) { - console.log('Failed to cache transaction receipt', e) - } -} diff --git a/apps/desktop/src/bun/lib/sync/sync-tx-history.ts b/apps/desktop/src/bun/lib/sync/sync-tx-history.ts deleted file mode 100644 index 93db22d..0000000 --- a/apps/desktop/src/bun/lib/sync/sync-tx-history.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { eq, and } from 'drizzle-orm' -import { numberToHex } from 'viem' -import { db } from '../db' -import { txHistory, txSyncStatus } from '../db/schema' -import { getTransactionHistory } from '../alchemy/get-transaction-history' -import { syncTxDetails } from './sync-tx-details' - -const PAGE_SIZE = 76 - -export const syncTransactionHistory = async ( - key: string, - chainid: string, - address: string, -): Promise => { - const addrLower = address.toLowerCase() - - const existing = db - .select() - .from(txSyncStatus) - .where( - and( - eq(txSyncStatus.chainId, chainid), - eq(txSyncStatus.address, addrLower), - ), - ) - .get() - - const fromBlock = existing?.lastSyncBlock - ? numberToHex(Number(existing.lastSyncBlock) + 1) - : undefined - - const transfers = await getTransactionHistory( - key, - chainid, - address, - { - maxCount: 1000, - fromBlock, - }, - ) - - if (!transfers || transfers.length === 0) return 0 - - const prepared: Array = [] - let latestBlock = Number(existing?.lastSyncBlock ?? '0') - - for (const t of transfers) { - if (!t.hash) continue - if (t.blockNum) { - const blockNum = parseInt(t.blockNum, 16) - if (blockNum > latestBlock) latestBlock = blockNum - } - - prepared.push({ - chainId: chainid, - hash: t.hash, - blockNum: t.blockNum ? String(parseInt(t.blockNum, 16)) : null, - from: t.from?.toLowerCase() ?? null, - to: t.to?.toLowerCase() ?? null, - value: t.value?.toString() ?? null, - asset: t.asset ?? null, - category: t.category ?? null, - rawContractAddress: - t.rawContract?.address?.toLowerCase() ?? null, - rawContractDecimal: t.rawContract?.decimal ?? null, - metadataBlockTimestamp: t.metadata?.blockTimestamp ?? null, - raw: JSON.stringify(t), - syncedAt: new Date().toISOString(), - }) - - await syncTxDetails(chainid, key, t.hash) - } - - flush(prepared) - db.insert(txSyncStatus) - .values({ - chainId: chainid, - address: addrLower, - lastSyncBlock: String(latestBlock), - syncedAt: new Date().toISOString(), - }) - .onConflictDoUpdate({ - target: [txSyncStatus.chainId, txSyncStatus.address], - set: { - lastSyncBlock: String(latestBlock), - syncedAt: new Date().toISOString(), - }, - }) - .run() - - console.log( - `[sync] ${chainid}/${addrLower}: synced ${prepared.length} transfers up to block ${latestBlock}`, - ) - - return prepared.length -} - -function flush(prepared: Array) { - while (prepared.length > 0) { - const batch = prepared.splice(0, PAGE_SIZE) - const cols = [ - 'chainId', - 'hash', - 'blockNum', - '"from"', - '"to"', - 'value', - 'asset', - 'category', - 'rawContractAddress', - 'rawContractDecimal', - 'metadataBlockTimestamp', - 'raw', - 'syncedAt', - ] - const colKeys = [ - 'chainId', - 'hash', - 'blockNum', - 'from', - 'to', - 'value', - 'asset', - 'category', - 'rawContractAddress', - 'rawContractDecimal', - 'metadataBlockTimestamp', - 'raw', - 'syncedAt', - ] - const placeholders = batch - .map(() => `(${colKeys.map(() => '?').join(',')})`) - .join(',') - const flat = batch.flatMap((r) => - colKeys.map((c) => (r as any)[c] ?? null), - ) - const sql = `INSERT OR IGNORE INTO tx_history (${cols.join(',')}) VALUES ${placeholders}` - - const dbClient = db.$client - dbClient.exec('BEGIN') - try { - dbClient.run(sql, ...flat) - dbClient.exec('COMMIT') - } catch (e) { - dbClient.exec('ROLLBACK') - console.error('[sync] flush error:', e) - } - } -} diff --git a/apps/desktop/src/bun/lib/tokens/get-metadata.ts b/apps/desktop/src/bun/lib/tokens/get-metadata.ts deleted file mode 100644 index cd7f310..0000000 --- a/apps/desktop/src/bun/lib/tokens/get-metadata.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { eq, and } from 'drizzle-orm' -import { db } from '../db' -import { tokenMetadata } from '../db/schema' -import { getChainID } from '../alchemy/network' -import { getTokenMetadata as getTokenMetadataFromAlchemy } from '../alchemy/get-token-metadata' - -export const getTokenMetadata = async ( - key: string, - chainid: string, - tokenAddress: string, -) => { - const chainId = getChainID(chainid) - if (!chainId) return - - const cached = db - .select() - .from(tokenMetadata) - .where( - and( - eq(tokenMetadata.chainId, chainId), - eq(tokenMetadata.contractAddress, tokenAddress.toLowerCase()), - ), - ) - .get() - - if (cached) { - return { - jsonrpc: '2.0' as const, - id: 0, - result: { - name: cached.name ?? null, - symbol: cached.symbol, - decimals: cached.decimals ?? null, - logo: cached.logo ?? null, - }, - } - } - - const result = await getTokenMetadataFromAlchemy( - key, - chainid, - tokenAddress, - ) - - if (result?.result?.symbol) { - try { - await db - .insert(tokenMetadata) - .values({ - name: result.result.name, - symbol: result.result.symbol, - decimals: result.result.decimals, - logo: result.result.logo, - contractAddress: tokenAddress.toLowerCase(), - chainId, - }) - .onConflictDoNothing() - } catch (e) { - console.log('Failed to cache token metadata', e) - } - } - - return result -} diff --git a/apps/desktop/src/bun/lib/tokens/index.ts b/apps/desktop/src/bun/lib/tokens/index.ts deleted file mode 100644 index 778f742..0000000 --- a/apps/desktop/src/bun/lib/tokens/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { getTokenMetadata } from './get-metadata' diff --git a/apps/desktop/src/bun/lib/transactions/get-details.ts b/apps/desktop/src/bun/lib/transactions/get-details.ts deleted file mode 100644 index 29df5c3..0000000 --- a/apps/desktop/src/bun/lib/transactions/get-details.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { eq, and } from 'drizzle-orm' -import { formatEther, formatGwei, type PublicClient } from 'viem' -import { db } from '../db' -import { transactions, transactionReceipts } from '../db/schema' -import { getChainID } from '../alchemy/network' - -export const getTransactionDetails = async ( - hash: string, - chainId: string, -) => { - if (!hash.startsWith('0x')) return null - - const id = getChainID(chainId) - if (!id) return null - - const hashLower = hash.toLowerCase() - - const cachedTx = db - .select() - .from(transactions) - .where( - and( - eq(transactions.hash, hashLower), - eq(transactions.chainId, id), - ), - ) - .get() - - const cachedReceipt = db - .select() - .from(transactionReceipts) - .where( - and( - eq(transactionReceipts.transactionHash, hashLower), - eq(transactionReceipts.chainId, id), - ), - ) - .get() - - if (cachedTx && cachedReceipt) { - const value = BigInt(cachedTx.value) - const gasUsed = BigInt(cachedReceipt.gasUsed) - const gasPrice = cachedTx.gasPrice - ? BigInt(cachedTx.gasPrice) - : BigInt(0) - - return { - hash: cachedTx.hash, - from: cachedTx.from, - to: cachedTx.to, - value: formatEther(value), - blockNumber: cachedReceipt.blockNumber, - fee: ( - parseFloat(formatGwei(gasUsed)) * - parseFloat(formatGwei(gasPrice)) - ).toString(), - gasPrice: formatGwei(gasPrice), - status: cachedReceipt.status as 'success' | 'reverted', - type: cachedTx.type ?? 'legacy', - nonce: cachedTx.nonce, - input: cachedTx.input, - } - } - - return null -} diff --git a/apps/desktop/src/bun/lib/transactions/get-history.ts b/apps/desktop/src/bun/lib/transactions/get-history.ts deleted file mode 100644 index a7a3b57..0000000 --- a/apps/desktop/src/bun/lib/transactions/get-history.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { eq, and, or, desc } from 'drizzle-orm' -import { db } from '../db' -import { txHistory } from '../db/schema' -import type { Transaction } from '../alchemy/types' - -export const getTransactionHistory = async ( - key: string, - chainid: string, - address: string, - opts: { maxCount: number }, -) => { - return getCachedTransactionHistory(chainid, address) -} - -export const getCachedTransactionHistory = ( - chainid: string, - address: string, -) => { - const addrLower = address.toLowerCase() - const rows = db - .select() - .from(txHistory) - .where( - and( - eq(txHistory.chainId, chainid), - or( - eq(txHistory.from, addrLower), - eq(txHistory.to, addrLower), - ), - ), - ) - .orderBy(desc(txHistory.blockNum)) - .all() - - return rows.map(toTransfer) -} - -function toTransfer(row: typeof txHistory.$inferSelect): Transaction { - if (row.raw) { - return JSON.parse(row.raw) as Transaction - } - return { - hash: row.hash, - blockNum: row.blockNum - ? `0x${parseInt(row.blockNum).toString(16)}` - : undefined, - from: row.from ?? undefined, - to: row.to ?? undefined, - value: row.value ? Number(row.value) : undefined, - asset: row.asset ?? undefined, - category: row.category as Transaction['category'], - rawContract: - row.rawContractAddress || row.rawContractDecimal - ? { - address: row.rawContractAddress ?? undefined, - decimal: row.rawContractDecimal ?? undefined, - } - : undefined, - metadata: row.metadataBlockTimestamp - ? { blockTimestamp: row.metadataBlockTimestamp } - : undefined, - } -} diff --git a/apps/desktop/src/bun/lib/transactions/index.ts b/apps/desktop/src/bun/lib/transactions/index.ts deleted file mode 100644 index 422254f..0000000 --- a/apps/desktop/src/bun/lib/transactions/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - getTransactionHistory, - getCachedTransactionHistory, -} from './get-history' -export { getTransactionDetails } from './get-details' diff --git a/apps/desktop/src/bun/lib/viem/get-transaction-details.ts b/apps/desktop/src/bun/lib/viem/get-transaction-details.ts deleted file mode 100644 index 78812f6..0000000 --- a/apps/desktop/src/bun/lib/viem/get-transaction-details.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { isHex, type PublicClient } from 'viem' - -export const getTransactionDetails = async ( - client: PublicClient, - hash: string, -) => { - if (!isHex(hash)) return null - - const [tx, receipt] = await Promise.all([ - client.getTransaction({ hash }), - client.getTransactionReceipt({ hash }), - ]) - - return { - tx: { - hash: tx.hash, - from: tx.from, - to: tx.to ?? null, - value: tx.value, - blockNumber: tx.blockNumber ?? null, - gasPrice: tx.gasPrice ?? null, - gas: tx.gas, - input: tx.input, - nonce: tx.nonce, - type: tx.type ?? 'legacy', - blockHash: tx.blockHash ?? null, - maxFeePerGas: tx.maxFeePerGas ?? null, - maxPriorityFeePerGas: tx.maxPriorityFeePerGas ?? null, - transactionIndex: tx.transactionIndex ?? null, - v: tx.v ?? null, - r: tx.r?.toString() ?? null, - s: tx.s?.toString() ?? null, - yParity: tx.yParity ?? null, - }, - receipt: { - blockHash: receipt.blockHash, - blockNumber: receipt.blockNumber, - from: receipt.from, - to: receipt.to ?? null, - cumulativeGasUsed: receipt.cumulativeGasUsed, - gasUsed: receipt.gasUsed, - status: receipt.status, - effectiveGasPrice: receipt.effectiveGasPrice ?? null, - contractAddress: receipt.contractAddress ?? null, - type: receipt.type, - transactionIndex: receipt.transactionIndex, - logsBloom: receipt.logsBloom?.toString() ?? null, - }, - } -} diff --git a/apps/desktop/src/bun/lib/viem/getChain.ts b/apps/desktop/src/bun/lib/viem/getChain.ts deleted file mode 100644 index 4b44314..0000000 --- a/apps/desktop/src/bun/lib/viem/getChain.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { bsc, mainnet, polygon, type Chain } from 'viem/chains' -import type { ChainID } from '../alchemy/network' - -const chains: Record = { - '1': mainnet, - '56': bsc, - '137': polygon, -} - -export const getChain = (id: ChainID) => { - return chains[id] -} diff --git a/apps/desktop/src/bun/lib/viem/getClient.ts b/apps/desktop/src/bun/lib/viem/getClient.ts deleted file mode 100644 index 81bf22b..0000000 --- a/apps/desktop/src/bun/lib/viem/getClient.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createPublicClient, http } from 'viem' -import { getChainID, getURL } from '../alchemy/network' -import { getChain } from './getChain' - -export const getClient = (chainId: string, apiKey: string) => { - const id = getChainID(chainId) - - if (!id) { - throw new Error() - } - - const url = getURL({ chainId: id, key: apiKey }) - const publicClient = createPublicClient({ - chain: getChain(id), - transport: http(url), - }) - - return publicClient -} diff --git a/apps/desktop/src/bun/lib/viem/index.ts b/apps/desktop/src/bun/lib/viem/index.ts deleted file mode 100644 index 202fb9a..0000000 --- a/apps/desktop/src/bun/lib/viem/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './getClient' -export { getTransactionDetails } from './get-transaction-details' diff --git a/apps/desktop/src/bun/lib/xrp/index.ts b/apps/desktop/src/bun/lib/xrp/index.ts new file mode 100644 index 0000000..59e3ee1 --- /dev/null +++ b/apps/desktop/src/bun/lib/xrp/index.ts @@ -0,0 +1,188 @@ +import { + Client, + Wallet, + xrpToDrops, + dropsToXrp, + isValidClassicAddress, +} from 'xrpl' +import type { + XrpBalance, + XrpTxDetails, + XrpTxEntry, + XrpWalletInfo, +} from '../../../lib/rpc-schema' + +const SERVER = 'wss://s1.ripple.com' +const RIPPLE_EPOCH = 946684800 +const WALLET_LIST_KEY = 'xrp_wallets' +const SERVICE = 'koins' + +let client: Client | null = null + +export async function getClient(): Promise { + if (client?.isConnected()) return client + client = new Client(SERVER) + await client.connect() + return client +} + +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 async function getXrpWalletList(): Promise { + const raw = await Bun.secrets.get({ service: SERVICE, name: WALLET_LIST_KEY }) + if (!raw) return [] + try { + return JSON.parse(raw) + } catch { + return [] + } +} + +export async function saveXrpWalletList( + wallets: XrpWalletInfo[], +): Promise { + await Bun.secrets.set({ + service: SERVICE, + name: WALLET_LIST_KEY, + value: JSON.stringify(wallets), + }) +} + +export async function getBalance(address: string): Promise { + const c = await getClient() + try { + const balance = await c.getXrpBalance(address) + return { address, balance: String(balance), funded: true } + } catch (e) { + if (e instanceof Error && e.message.includes('Account not found')) { + return { address, 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 { + const c = await getClient() + const res = await c.request({ + command: 'account_tx', + account: address, + ledger_index_min: -1, + ledger_index_max: -1, + limit: 25, + }) + const entries: XrpTxEntry[] = [] + for (const entry of res.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 ? String(tx.date + RIPPLE_EPOCH) : '0', + direction: tx.Destination === address ? 'in' : 'out', + from: tx.Account, + to: tx.Destination, + destinationTag: tx.DestinationTag, + fee: dropsAmount(tx.Fee), + confirmed: entry.validated === true, + }) + } + return entries +} + +export async function getTxDetails( + hash: string, +): Promise { + const c = await getClient() + try { + const res = await c.request({ command: 'tx', transaction: hash }) + const d = res.result as any + const tx = d.tx_json ?? d + if (tx.TransactionType !== 'Payment') return null + const meta = d.meta ?? {} + const delivered = meta.delivered_amount ?? meta.DeliveredAmount ?? tx.Amount + return { + hash: d.hash ?? hash, + from: tx.Account, + to: tx.Destination, + amount: dropsAmount(delivered), + fee: dropsAmount(tx.Fee), + timestamp: tx.date ? String(tx.date + RIPPLE_EPOCH) : '0', + ledgerIndex: d.ledger_index ?? 0, + destinationTag: tx.DestinationTag, + confirmed: d.validated === true, + } + } catch { + return null + } +} + +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 c = await getClient() + const prepared = await c.autofill({ + TransactionType: 'Payment', + Account: wallet.classicAddress, + Amount: xrpToDrops(amount), + Destination: to, + ...(destinationTag !== undefined ? { DestinationTag: destinationTag } : {}), + }) + const signed = wallet.sign(prepared) + const result = await c.submit(signed.tx_blob) + const engineResult = (result.result as any).engine_result + if (engineResult !== 'tesSUCCESS') { + const msg = + (result.result as any).engine_result_message ?? 'Transaction failed' + throw new Error(`${engineResult}: ${msg}`) + } + return { hash: signed.hash, fee: String(dropsToXrp((prepared as any).Fee ?? '0')) } +} + +export async function getFee(): Promise<{ fee: string } | null> { + try { + const c = await getClient() + const res = await c.request({ command: 'fee' }) + return { fee: String(dropsToXrp(res.result.drops.open_ledger_fee)) } + } catch { + return null + } +} diff --git a/apps/desktop/src/bun/rpc/evm/index.ts b/apps/desktop/src/bun/rpc/evm/index.ts deleted file mode 100644 index ebbad09..0000000 --- a/apps/desktop/src/bun/rpc/evm/index.ts +++ /dev/null @@ -1,641 +0,0 @@ -import { getTokenMetadata } from '../../lib/tokens' -import { getTokensBalances } from '../../lib/alchemy' -import { tryCatch } from '@koins/utils' -import { - getTransactionHistory, - getTransactionDetails, - getCachedTransactionHistory, -} from '../../lib/transactions' -import { syncTransactionHistory } from '../../lib/sync' -import { eq, and } from 'drizzle-orm' -import { db } from '../../lib/db' -import { getClient } from '../../lib/viem' -import { - formatGwei, - parseEther, - parseUnits, - encodeFunctionData, - createWalletClient, -} from 'viem' -import { mnemonicToAccount } from 'viem/accounts' -import { getChain } from '../../lib/viem/getChain' -import { getChainID } from '../../lib/alchemy/network' -import { alchemyTransport } from '@alchemy/common' -import { sqlite } from '../../lib/sqlite' -import { txHistory, evmWallets } from '../../lib/db/schema' -import type { - TokenBalanceResult, - TokenPriceEntry, - TxEntry, -} from '../../../lib/rpc-schema' - -export function createEvmHandlers( - rpc: { - send: { - transfersUpdate: (p: { - count: number - chainid: string - address: string - }) => void - } - }, - syncState: { - target: { address: string; chainid: string } | null - timer: ReturnType | null - }, -) { - const ALCHEMY_NETWORKS: Record = { - '1': 'eth-mainnet', - '137': 'polygon-mainnet', - '56': 'bnb-mainnet', - } - - const NATIVE_SYMBOLS: Record = { - '1': 'ETH', - '137': 'POL', - '56': 'BNB', - } - - async function fetchAlchemyTransfers( - key: string, - chainid: string, - address: string, - ) { - const transactions = await getTransactionHistory( - key, - chainid, - address, - { maxCount: 10 }, - ) - if (!transactions) return [] - return transactions - } - - function formatUnits(balance: string, decimals: number): string { - const bal = BigInt(balance) - if (bal === 0n) return '0' - const divisor = 10n ** BigInt(decimals) - const intPart = bal / divisor - const fracPart = (bal % divisor) - .toString() - .padStart(decimals, '0') - .replace(/0+$/, '') - return fracPart ? `${intPart}.${fracPart}` : `${intPart}` - } - - async function fetchAlchemyTokenBalances( - key: string, - chainid: string, - address: string, - ): Promise { - const [balRes, balResError] = await tryCatch( - getTokensBalances(key, chainid, address), - ) - if (balResError) { - console.log('Get Tokens Balance Error', balResError) - return [] - } - if (!balRes) return [] - const tokens = balRes.result?.tokenBalances ?? [] - const nonZero = tokens.filter( - (t: any) => t.tokenBalance !== '0x0', - ) - const metas = await Promise.allSettled( - nonZero.map((t: any) => - getTokenMetadata(key, chainid, t.contractAddress), - ), - ) - if (!metas) return [] - return nonZero - .map((t: any, i: number) => { - const meta = - metas[i].status === 'fulfilled' && metas[i].value - ? metas[i].value.result - : null - return { - symbol: meta?.symbol ?? 'Unknown', - decimals: meta?.decimals ?? 0, - balance: formatUnits(t.tokenBalance, meta?.decimals ?? 18), - contractAddress: t.contractAddress, - logo: meta?.logo ?? undefined, - } - }) - .filter((t) => t.logo && t.decimals !== 0) - } - - function mapTransfer(t: any): TxEntry { - const isExternal = t.category === 'external' - return { - hash: t.hash, - timeStamp: String( - Math.floor( - new Date(t.metadata?.blockTimestamp).getTime() / 1000, - ), - ), - from: t.from, - to: t.to, - value: String(t.value), - ...(isExternal - ? {} - : { - tokenSymbol: t.asset, - tokenDecimal: t.rawContract?.decimal - ? String(parseInt(t.rawContract.decimal, 16)) - : undefined, - contractAddress: t.rawContract?.address ?? undefined, - }), - } - } - - async function pairTransfers( - transfers: any[], - address: string, - key?: string, - chainid?: string, - ): Promise { - const addrLower = address.toLowerCase() - const byHash = new Map() - for (const t of transfers) { - if (!t.hash) continue - const h = t.hash - if (!byHash.has(h)) byHash.set(h, []) - byHash.get(h)!.push(t) - } - - const combined: TxEntry[] = [] - - for (const [, group] of byHash) { - const native = group.filter( - (t: any) => - t.category === 'external' || t.category === 'internal', - ) - const tokens = group.filter((t: any) => t.category === 'erc20') - - const extOut = native.filter( - (t: any) => t.from?.toLowerCase() === addrLower, - ) - const extIn = native.filter( - (t: any) => t.to?.toLowerCase() === addrLower, - ) - const tokIn = tokens.filter( - (t: any) => t.to?.toLowerCase() === addrLower, - ) - const tokOut = tokens.filter( - (t: any) => t.from?.toLowerCase() === addrLower, - ) - - if (extOut.length > 0 && tokIn.length > 0) { - combined.push({ - ...mapTransfer(extOut[0]), - tokenSymbol: tokIn[0].asset, - tokenDecimal: tokIn[0].rawContract?.decimal - ? String(parseInt(tokIn[0].rawContract.decimal, 16)) - : undefined, - contractAddress: tokIn[0].rawContract?.address ?? undefined, - pairedValue: String(tokIn[0].value), - pairedSymbol: tokIn[0].asset, - pairedDecimals: tokIn[0].rawContract?.decimal - ? String(parseInt(tokIn[0].rawContract.decimal, 16)) - : undefined, - pairedContractAddress: - tokIn[0].rawContract?.address ?? undefined, - }) - } else if (extOut.length > 0) { - combined.push(mapTransfer(extOut[0])) - } else if (extIn.length > 0) { - combined.push(mapTransfer(extIn[0])) - } else if (tokOut.length > 0 && tokIn.length > 0) { - combined.push({ - ...mapTransfer(tokOut[0]), - pairedValue: String(tokIn[0].value), - pairedSymbol: tokIn[0].asset, - pairedDecimals: tokIn[0].rawContract?.decimal - ? String(parseInt(tokIn[0].rawContract.decimal, 16)) - : undefined, - pairedContractAddress: - tokIn[0].rawContract?.address ?? undefined, - }) - } else if (tokOut.length > 0 && extIn.length > 0) { - combined.push({ - ...mapTransfer(tokOut[0]), - pairedValue: String(extIn[0].value), - pairedSymbol: chainid ? NATIVE_SYMBOLS[chainid] : undefined, - }) - } else if (tokOut.length > 0) { - combined.push(mapTransfer(tokOut[0])) - } else if (tokIn.length > 0) { - combined.push(mapTransfer(tokIn[0])) - } - } - - if (key && chainid) { - const addrSet = new Set() - for (const entry of combined) { - if (entry.contractAddress) - addrSet.add(entry.contractAddress.toLowerCase()) - if (entry.pairedContractAddress) - addrSet.add(entry.pairedContractAddress.toLowerCase()) - } - const logoMap = new Map() - await Promise.all( - Array.from(addrSet).map(async (addr) => { - const meta = await getTokenMetadata(key, chainid, addr) - if (meta?.result?.logo) logoMap.set(addr, meta.result.logo) - }), - ) - for (const entry of combined) { - if (entry.contractAddress) - entry.logo = logoMap.get( - entry.contractAddress.toLowerCase(), - ) - if (entry.pairedContractAddress) - entry.pairedLogo = logoMap.get( - entry.pairedContractAddress.toLowerCase(), - ) - } - } - - combined.sort((a, b) => Number(b.timeStamp) - Number(a.timeStamp)) - return combined - } - - return { - fetchTxHistory: async ({ - address, - chainid, - }: { - address: string - chainid: string - }) => { - const key = await Bun.secrets.get({ - service: 'koins', - name: 'alchemy_key', - }) - if (!key) return [] - const [all, allError] = await tryCatch( - fetchAlchemyTransfers(key, chainid, address), - ) - if (allError) { - console.log(allError) - return [] - } - return pairTransfers(all, address, key, chainid) - }, - fetchCachedTxHistory: async ({ - address, - chainid, - }: { - address: string - chainid: string - }) => { - const transfers = await getCachedTransactionHistory( - chainid, - address, - ) - return pairTransfers(transfers, address) - }, - syncTxHistory: async ({ - address, - chainid, - }: { - address: string - chainid: string - }) => { - const key = await Bun.secrets.get({ - service: 'koins', - name: 'alchemy_key', - }) - if (!key) return - try { - const count = await syncTransactionHistory( - key, - chainid, - address, - ) - rpc.send.transfersUpdate({ count, chainid, address }) - } catch (e) { - console.error('[sync] error:', e) - } - }, - setAutoSync: async ( - target: { address: string; chainid: string } | null, - ) => { - if (syncState.timer) clearInterval(syncState.timer) - syncState.timer = null - syncState.target = null - if (!target) return - syncState.target = target - syncState.timer = setInterval(async () => { - if (!syncState.target) return - const key = await Bun.secrets.get({ - service: 'koins', - name: 'alchemy_key', - }) - if (!key) return - try { - const count = await syncTransactionHistory( - key, - syncState.target.chainid, - syncState.target.address, - ) - rpc.send.transfersUpdate({ - count, - chainid: syncState.target.chainid, - address: syncState.target.address, - }) - } catch (e) { - console.error('[auto-sync] error:', e) - } - }, 30000) - }, - flushTxCache: async () => { - sqlite.run('DELETE FROM tx_history') - sqlite.run('DELETE FROM tx_sync_status') - console.log('[rpc] flushTxCache complete') - }, - fetchTokenBalances: async ({ - address, - chainid, - }: { - address: string - chainid: string - }) => { - const key = await Bun.secrets.get({ - service: 'koins', - name: 'alchemy_key', - }) - if (!key) return [] - try { - return await fetchAlchemyTokenBalances(key, chainid, address) - } catch (error) { - console.log(error) - return [] - } - }, - fetchTokenPrices: async ({ - symbols, - addresses, - }: { - symbols?: string[] - addresses?: { network: string; address: string }[] - }) => { - const key = await Bun.secrets.get({ - service: 'koins', - name: 'alchemy_key', - }) - if (!key) return [] - try { - const results: TokenPriceEntry[] = [] - if (symbols && symbols.length > 0) { - const url = `https://api.g.alchemy.com/prices/v1/${key}/tokens/by-symbol?${symbols.map((s) => `symbols=${encodeURIComponent(s)}`).join('&')}` - const res = await fetch(url, { - signal: AbortSignal.timeout(10000), - }) - if (res.ok) { - const body = await res.json() - for (const d of body.data ?? []) { - for (const p of d.prices ?? []) { - results.push({ - symbol: d.symbol, - currency: p.currency, - value: p.value, - lastUpdatedAt: p.lastUpdatedAt, - }) - } - } - } - } - if (addresses && addresses.length > 0) { - const url = `https://api.g.alchemy.com/prices/v1/${key}/tokens/by-address` - const res = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ addresses }), - signal: AbortSignal.timeout(10000), - }) - if (res.ok) { - const body = await res.json() - for (const d of body.data ?? []) { - for (const p of d.prices ?? []) { - results.push({ - symbol: d.symbol, - currency: p.currency, - value: p.value, - lastUpdatedAt: p.lastUpdatedAt, - network: d.network, - address: d.address, - }) - } - } - } - } - console.log('[prices] results:', results.length) - return results - } catch (error) { - console.log('[prices] error:', error) - return [] - } - }, - fetchGasPrice: async ({ chainid }: { chainid: string }) => { - const key = await Bun.secrets.get({ - service: 'koins', - name: 'alchemy_key', - }) - if (!key) { - console.log('[gas] no key') - return null - } - try { - const client = getClient(chainid, key) - const gasPrice = await client.getGasPrice() - console.log( - '[gas] price for chain', - chainid, - ':', - gasPrice.toString(), - ) - return formatGwei(gasPrice) - } catch (error) { - console.log('[gas] error:', error) - return null - } - }, - fetchTransactionDetails: async ({ - hash, - chainid, - address, - }: { - hash: string - chainid: string - address?: string - }) => { - const key = await Bun.secrets.get({ - service: 'koins', - name: 'alchemy_key', - }) - if (!key) return null - try { - const details = await getTransactionDetails(hash, chainid) - if (!details) return null - if (address) { - const transfers = db - .select() - .from(txHistory) - .where( - and( - eq(txHistory.chainId, chainid), - eq(txHistory.hash, hash.toLowerCase()), - ), - ) - .all() - if (transfers.length > 0) { - const paired = await pairTransfers(transfers, address) - if (paired.length > 0 && paired[0].pairedValue) { - details.pairedValue = paired[0].pairedValue - details.pairedSymbol = paired[0].pairedSymbol - details.pairedDecimals = paired[0].pairedDecimals - details.pairedContractAddress = - paired[0].pairedContractAddress - details.pairedLogo = paired[0].pairedLogo - } - } - } - return details - } catch (error) { - console.log(error) - return null - } - }, - evmCreateWallet: async ({ - name, - phrase, - passwordHash, - }: { - name: string - phrase: string - passwordHash?: string - }) => { - console.log('[rpc] evmCreateWallet:', name) - const id = crypto.randomUUID() - const vaultKey = `evm_seed_${id}` - await Bun.secrets.set({ - service: 'koins', - name: vaultKey, - value: phrase, - }) - await db.insert(evmWallets).values({ - id, - name, - passwordHash: passwordHash ?? null, - vaultKey, - createdAt: new Date().toISOString(), - }) - console.log('[rpc] evmCreateWallet complete:', id) - return { id, name, createdAt: new Date().toISOString() } - }, - evmListWallets: async () => { - console.log('[rpc] evmListWallets') - const wallets = db.select().from(evmWallets).all() - return wallets.map((w: any) => ({ - id: w.id, - name: w.name, - hasPassword: !!w.passwordHash, - vaultKey: w.vaultKey, - createdAt: w.createdAt, - })) - }, - evmGetSeed: async ({ vaultKey }: { vaultKey: string }) => { - console.log('[rpc] evmGetSeed:', vaultKey) - const seed = await Bun.secrets.get({ - service: 'koins', - name: vaultKey, - }) - if (!seed) throw new Error('Seed not found in keychain') - return seed - }, - evmDeleteWallet: async ({ id }: { id: string }) => { - console.log('[rpc] evmDeleteWallet:', id) - const wallet = db - .select() - .from(evmWallets) - .where(eq(evmWallets.id, id)) - .get() - if (!wallet) throw new Error('Wallet not found') - await Promise.all([ - Bun.secrets.delete({ - service: 'koins', - name: wallet.vaultKey, - }), - Bun.secrets.delete({ - service: 'koins', - name: `evm_auth_${id}`, - }), - ]) - db.delete(evmWallets).where(eq(evmWallets.id, id)).run() - console.log('[rpc] evmDeleteWallet complete') - }, - evmSendTransfer: async ({ - seed, - to, - amount, - chainid, - contractAddress, - tokenDecimals, - }: { - seed: string - to: string - amount: string - chainid: string - contractAddress?: string - tokenDecimals?: number - }) => { - const key = await Bun.secrets.get({ - service: 'koins', - name: 'alchemy_key', - }) - if (!key) throw new Error('API key not set') - const _amount = String(amount) - const account = mnemonicToAccount(seed) - const id = getChainID(chainid) - if (!id) throw new Error('Unsupported chain') - const chain = getChain(id) - const walletClient = createWalletClient({ - account, - chain, - transport: alchemyTransport({ apiKey: key }), - }) - if (contractAddress && tokenDecimals !== undefined) { - const erc20Abi = [ - { - name: 'transfer', - type: 'function', - inputs: [ - { name: 'to', type: 'address' }, - { name: 'amount', type: 'uint256' }, - ], - outputs: [{ name: '', type: 'bool' }], - }, - ] as const - const data = encodeFunctionData({ - abi: erc20Abi, - functionName: 'transfer', - args: [ - to as `0x${string}`, - parseUnits(_amount, tokenDecimals), - ], - }) - const hash = await walletClient.sendTransaction({ - to: contractAddress as `0x${string}`, - data, - value: 0n, - }) - return hash - } - const hash = await walletClient.sendTransaction({ - to: to as `0x${string}`, - value: parseEther(_amount), - }) - return hash - }, - } -} diff --git a/apps/desktop/src/bun/rpc/xrp/index.ts b/apps/desktop/src/bun/rpc/xrp/index.ts new file mode 100644 index 0000000..4dcbcb4 --- /dev/null +++ b/apps/desktop/src/bun/rpc/xrp/index.ts @@ -0,0 +1,153 @@ +import { + generateWallet, + walletFromSecret, + getXrpWalletList, + saveXrpWalletList, + getBalance, + getTransactions, + getTxDetails, + sendPayment, + getFee, +} from '../../lib/xrp' +import type { XrpWalletInfo } from '../../../lib/rpc-schema' + +const SERVICE = 'koins' + +export function createXrpHandlers() { + return { + xrpListWallets: async () => { + console.log('[rpc] xrpListWallets') + return getXrpWalletList() + }, + xrpCreateWallet: async ({ + name, + hasPassword, + }: { + name: string + hasPassword?: boolean + }) => { + console.log('[rpc] xrpCreateWallet:', name) + const { address, seed } = generateWallet() + const id = crypto.randomUUID() + const vaultKey = `xrp_seed_${id}` + await Bun.secrets.set({ service: SERVICE, name: vaultKey, value: seed }) + const wallets = await getXrpWalletList() + const createdAt = new Date().toISOString() + const info: XrpWalletInfo = { + id, + name, + address, + hasPassword: hasPassword ?? false, + vaultKey, + createdAt, + } + await saveXrpWalletList([...wallets, info]) + console.log('[rpc] xrpCreateWallet complete:', id) + return { id, address, seed, createdAt } + }, + xrpImportWallet: async ({ + name, + secret, + hasPassword, + }: { + name: string + secret: string + hasPassword?: boolean + }) => { + console.log('[rpc] xrpImportWallet:', name) + const { address } = walletFromSecret(secret) + const id = crypto.randomUUID() + const vaultKey = `xrp_seed_${id}` + await Bun.secrets.set({ + service: SERVICE, + name: vaultKey, + value: secret.trim(), + }) + const wallets = await getXrpWalletList() + const createdAt = new Date().toISOString() + const info: XrpWalletInfo = { + id, + name, + address, + hasPassword: hasPassword ?? false, + vaultKey, + createdAt, + } + await saveXrpWalletList([...wallets, info]) + console.log('[rpc] xrpImportWallet complete:', id) + return { id, address, createdAt } + }, + xrpDeleteWallet: async ({ id }: { id: string }) => { + console.log('[rpc] xrpDeleteWallet:', id) + const wallets = await getXrpWalletList() + const wallet = wallets.find((w) => w.id === id) + if (!wallet) throw new Error('Wallet not found') + await Promise.all([ + Bun.secrets.delete({ service: SERVICE, name: wallet.vaultKey }), + Bun.secrets.delete({ service: SERVICE, name: `xrp_auth_${id}` }), + ]) + await saveXrpWalletList(wallets.filter((w) => w.id !== id)) + console.log('[rpc] xrpDeleteWallet complete') + }, + xrpGetSeed: async ({ vaultKey }: { vaultKey: string }) => { + console.log('[rpc] xrpGetSeed:', vaultKey) + const seed = await Bun.secrets.get({ service: SERVICE, name: vaultKey }) + if (!seed) throw new Error('Seed not found in keychain') + return seed + }, + xrpGetBalance: async ({ address }: { address: string }) => { + console.log('[rpc] xrpGetBalance:', address) + return getBalance(address) + }, + xrpGetTransactions: async ({ address }: { address: string }) => { + console.log('[rpc] xrpGetTransactions:', address) + try { + return await getTransactions(address) + } catch (e) { + if (e instanceof Error && e.message.includes('Account not found')) { + return [] + } + throw e + } + }, + xrpGetTxDetails: async ({ hash }: { hash: string }) => { + console.log('[rpc] xrpGetTxDetails:', hash) + return getTxDetails(hash) + }, + xrpSend: async ({ + secret, + to, + amount, + destinationTag, + }: { + secret: string + to: string + amount: string + destinationTag?: number + }) => { + console.log('[rpc] xrpSend:', { to, amount, destinationTag }) + const result = await sendPayment(secret, to, amount, destinationTag) + console.log('[rpc] xrpSend complete:', result.hash) + return result + }, + xrpGetFee: async () => { + console.log('[rpc] xrpGetFee') + return getFee() + }, + fetchXrpPrice: async () => { + try { + const res = await fetch( + 'https://api.coingecko.com/api/v3/simple/price?ids=ripple&vs_currencies=usd', + { signal: AbortSignal.timeout(10000) }, + ) + if (!res.ok) return null + const data = await res.json() + if (!data?.ripple?.usd) return null + return { usd: String(data.ripple.usd) } + } catch (error) { + console.log('[xrp][price] error:', error) + return null + } + }, + } +} diff --git a/apps/desktop/src/lib/components/EvmDashboard.svelte b/apps/desktop/src/lib/components/EvmDashboard.svelte deleted file mode 100644 index 32afe3c..0000000 --- a/apps/desktop/src/lib/components/EvmDashboard.svelte +++ /dev/null @@ -1,799 +0,0 @@ - - -{#if !w.ready} -
- Loading... -
-{:else if w.wallets.length === 0 || showCreateForm} - - - - {#if showCreateForm} - {@const cur = w.currentWallet} - {cur ? `Add Another Wallet` : `Import / Create Wallet`} - {:else} - Import / Create Wallet - {/if} - - - Name your wallet and enter your seed phrase to save it - securely, or generate a new wallet - - - -
- -