From 44cda48d93c08d1622cbb35559215e7771400d11 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 10 Apr 2026 08:18:27 +0700 Subject: [PATCH 1/3] fix: use XChaCha20-Poly1305 for blinding factor encryption Replaced custom XOR-mask scheme with AEAD encryption. The previous approach had no authentication (bit-flip attacks undetected) and deterministic keystream (two-time-pad on same recipient). Now uses random 24-byte nonce per message, prepended to ciphertext. --- packages/agent/package.json | 1 + packages/agent/src/tools/send.ts | 30 ++++++++++++++++++------------ pnpm-lock.yaml | 3 +++ 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/agent/package.json b/packages/agent/package.json index 28297e0..ec883c5 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -12,6 +12,7 @@ "@anthropic-ai/sdk": "^0.39.0", "@mariozechner/pi-agent-core": "^0.66.1", "@mariozechner/pi-ai": "^0.66.1", + "@noble/ciphers": "^2.1.1", "@sinclair/typebox": "^0.34.49", "@sipher/sdk": "workspace:*", "better-sqlite3": "^12.8.0", diff --git a/packages/agent/src/tools/send.ts b/packages/agent/src/tools/send.ts index ec50d19..54dea9d 100644 --- a/packages/agent/src/tools/send.ts +++ b/packages/agent/src/tools/send.ts @@ -7,6 +7,8 @@ import { commit, } from '@sip-protocol/sdk' import { sha256 } from '@noble/hashes/sha256' +import { xchacha20poly1305 } from '@noble/ciphers/chacha' +import { randomBytes } from '@noble/ciphers/webcrypto' import { createConnection, buildPrivateSendTx, @@ -204,22 +206,26 @@ export async function executeSend(params: SendParams): Promise { viewingKeyHash = new Uint8Array(32).fill(0) } - // Encrypt amount + blinding factor for recipient to verify commitment. - // Layout: [8 bytes amount LE] || [32 bytes blinding] XOR-masked with viewing key hash. + // Encrypt amount + blinding factor with XChaCha20-Poly1305 (AEAD). + // Key = viewing key hash (32 bytes), nonce = random 24 bytes. + // Layout: [24 bytes nonce] || [ciphertext + 16 bytes tag] + // Plaintext: [8 bytes amount LE] || [32 bytes blinding] let encryptedAmount: Uint8Array if (isStealthMetaAddress) { const amountLeBytes = bigintToLeBytes(BigInt(amountBase)) const blindingBytes = hexToBytes(blinding) - const payload = new Uint8Array(amountLeBytes.length + blindingBytes.length) - payload.set(amountLeBytes, 0) - payload.set(blindingBytes, amountLeBytes.length) - // Generate 64 bytes of keystream from viewing key hash - const mask1 = sha256(viewingKeyHash) - const mask2 = sha256(mask1) - encryptedAmount = new Uint8Array(payload.length) - for (let i = 0; i < payload.length; i++) { - encryptedAmount[i] = payload[i] ^ (i < 32 ? mask1[i] : mask2[i - 32]) - } + const plaintext = new Uint8Array(amountLeBytes.length + blindingBytes.length) + plaintext.set(amountLeBytes, 0) + plaintext.set(blindingBytes, amountLeBytes.length) + + const nonce = randomBytes(24) + const cipher = xchacha20poly1305(viewingKeyHash, nonce) + const ciphertext = cipher.encrypt(plaintext) + + // Prepend nonce so recipient can decrypt + encryptedAmount = new Uint8Array(nonce.length + ciphertext.length) + encryptedAmount.set(nonce, 0) + encryptedAmount.set(ciphertext, nonce.length) } else { encryptedAmount = new Uint8Array(0) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 627a81f..3b3e9ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -160,6 +160,9 @@ importers: '@mariozechner/pi-ai': specifier: ^0.66.1 version: 0.66.1(bufferutil@4.1.0)(utf-8-validate@5.0.10)(ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(zod@4.3.6) + '@noble/ciphers': + specifier: ^2.1.1 + version: 2.1.1 '@sinclair/typebox': specifier: ^0.34.49 version: 0.34.49 From 18a05eda874efcdaba8baec701a4b0a64453c222 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 10 Apr 2026 08:18:35 +0700 Subject: [PATCH 2/3] fix: fetch real decimals for unknown SPL tokens in vault API Previous code assumed 9 decimals for all non-USDC/USDT tokens. Now batch-fetches mint account data (decimals at offset 44) via getMultipleAccountsInfo. Also adds balanceStatus field so frontend can distinguish zero balance from RPC unavailability. --- packages/agent/src/routes/vault-api.ts | 42 +++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/agent/src/routes/vault-api.ts b/packages/agent/src/routes/vault-api.ts index 9af011c..43c1694 100644 --- a/packages/agent/src/routes/vault-api.ts +++ b/packages/agent/src/routes/vault-api.ts @@ -13,6 +13,13 @@ const MINT_LABELS: Record = { [USDT_MINT.toBase58()]: 'USDT', } +// Known decimals for common mints (avoids extra RPC call) +const KNOWN_DECIMALS: Record = { + [WSOL_MINT.toBase58()]: 9, + [USDC_MINT.toBase58()]: 6, + [USDT_MINT.toBase58()]: 6, +} + interface TokenBalance { mint: string symbol: string @@ -32,6 +39,7 @@ vaultRouter.get('/', async (req: Request, res: Response) => { const connection = createConnection(network) let solBalance = 0 + let balanceStatus: 'ok' | 'unavailable' = 'ok' const tokens: TokenBalance[] = [] try { @@ -46,18 +54,43 @@ vaultRouter.get('/', async (req: Request, res: Response) => { programId: TOKEN_PROGRAM_ID, }) + // Collect unknown mints to batch-fetch decimals + const unknownMints: PublicKey[] = [] + const tokenEntries: { mint: PublicKey; mintStr: string; rawAmount: bigint }[] = [] + for (const { account } of tokenAccounts.value) { - // SPL token account data layout: mint(32) + owner(32) + amount(8) + ... const data = account.data const mint = new PublicKey(data.subarray(0, 32)) const mintStr = mint.toBase58() const rawAmount = data.readBigUInt64LE(64) - // Skip zero-balance accounts and wrapped SOL (shown as native SOL above) if (rawAmount === 0n || mint.equals(WSOL_MINT)) continue + tokenEntries.push({ mint, mintStr, rawAmount }) + if (!(mintStr in KNOWN_DECIMALS)) { + unknownMints.push(mint) + } + } + + // Batch-fetch decimals for unknown mints (SPL mint layout: decimals at offset 44) + const fetchedDecimals: Record = {} + if (unknownMints.length > 0) { + try { + const mintAccounts = await connection.getMultipleAccountsInfo(unknownMints) + for (let i = 0; i < unknownMints.length; i++) { + const info = mintAccounts[i] + if (info?.data && info.data.length >= 45) { + fetchedDecimals[unknownMints[i].toBase58()] = info.data[44] + } + } + } catch { + // Non-fatal — fall back to 9 for unknown mints + } + } + + for (const { mintStr, rawAmount } of tokenEntries) { const symbol = MINT_LABELS[mintStr] ?? mintStr.slice(0, 8) + '...' - const decimals = mint.equals(USDC_MINT) || mint.equals(USDT_MINT) ? 6 : 9 + const decimals = KNOWN_DECIMALS[mintStr] ?? fetchedDecimals[mintStr] ?? 9 const uiAmount = Number(rawAmount) / 10 ** decimals tokens.push({ @@ -69,7 +102,7 @@ vaultRouter.get('/', async (req: Request, res: Response) => { }) } } catch (err) { - // RPC failures should not block the entire response — return what we have + balanceStatus = 'unavailable' console.warn('[vault] balance fetch failed:', err instanceof Error ? err.message : err) } @@ -81,6 +114,7 @@ vaultRouter.get('/', async (req: Request, res: Response) => { balances: { sol: solBalance, tokens, + status: balanceStatus, }, activity, }) From dd1eba36ba66cfde6a14fa2ce47cbdd4dc614326 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 10 Apr 2026 08:18:42 +0700 Subject: [PATCH 3/3] chore: remove dead isTokenSupported function from Jupiter provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Always returned true — no consumers after private-swap pre-flight removal. Added clarifying comment to getSupportedTokens (label lookup, not a whitelist). --- src/services/jupiter-provider.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/services/jupiter-provider.ts b/src/services/jupiter-provider.ts index 55b5daf..39753d9 100644 --- a/src/services/jupiter-provider.ts +++ b/src/services/jupiter-provider.ts @@ -182,14 +182,11 @@ export async function buildSwapTransaction(params: SwapTransactionParams): Promi // ─── Utility ──────────────────────────────────────────────────────────────── +/** Token label lookup (informational — Jupiter supports all SPL tokens). */ export function getSupportedTokens(): typeof SUPPORTED_TOKENS { return SUPPORTED_TOKENS } -export function isTokenSupported(_mint: string): boolean { - return true -} - export function resetJupiterProvider(): void { quoteCache.clear() }