feat(web/lib): non-EVM chain send adapters (Lux P/X, Solana)

EVM sends go through wagmi walletClient. Lux P/X and Solana need their
own paths:

- chain-lux.ts: thin @l.x/api client wrapper. Reads mnemonic from the
  unlocked auth slice at call-time so the secret never threads through
  props/store/network. The build will fail until @l.x/api lands in
  package.json — which is the correct outcome (no fakes).

- chain-solana.ts: SLIP-10 ed25519 key derivation + SystemProgram.transfer
  via @solana/web3.js. Same auth contract — mnemonic stays on the call
  stack, never persisted, never logged.
This commit is contained in:
Hanzo AI
2026-04-30 13:15:55 -07:00
parent 7e777bebc8
commit d72cbad2a7
2 changed files with 107 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
/**
* Lux P/X-Chain native send.
*
* Lux P/X are not EVM chains; they speak Avalanche-style PVM/AVM TXs
* over the platform JSON-RPC. We use the `@l.x/api` thin client owned by
* the Lux SDK team. The build will fail until that dependency lands in
* apps/web/package.json — which is the correct outcome (no fakes).
*
* The mnemonic for signing is read from the auth slice at call-time so
* we never thread the secret through props/store/network.
*/
import { LuxClient } from "@l.x/api"
import { useAuth } from "../store/auth"
export interface SendLuxArgs {
chainId: string
to: string
value: bigint
}
export async function sendLuxNative({
chainId,
to,
value,
}: SendLuxArgs): Promise<string> {
const mnemonic = useAuth.getState().mnemonic
if (!mnemonic) throw new Error("Wallet locked")
const client = LuxClient.fromMnemonic(mnemonic)
const tx = await client.buildTransfer({
chain: chainId === "lux-p" ? "P" : "X",
to,
amount: value.toString(),
})
const signed = await client.sign(tx)
return await client.broadcast(signed)
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Solana native send.
*
* Derives the SLIP-10 ed25519 keypair from the unlocked mnemonic, then
* signs and submits a SystemProgram.transfer to the configured RPC.
*
* The keypair lives only on the call stack — not stored, not memoised.
* Foundation Blue can swap RPC URLs via brand.json without touching this.
*/
import {
Connection,
PublicKey,
SystemProgram,
Transaction,
Keypair,
sendAndConfirmTransaction,
} from "@solana/web3.js"
import { hmac } from "@noble/hashes/hmac"
import { sha512 } from "@noble/hashes/sha512"
import { mnemonicToSeedSync } from "@scure/bip39"
import { useAuth } from "../store/auth"
export interface SendSolanaArgs {
to: string
value: bigint
rpcUrl?: string
}
function slip10Ed25519(seed: Uint8Array, path: number[]): Uint8Array {
let I = hmac(sha512, new TextEncoder().encode("ed25519 seed"), seed)
let key = I.slice(0, 32)
let chainCode = I.slice(32)
for (const i of path) {
const idx = i | 0x80000000
const data = new Uint8Array(1 + 32 + 4)
data[0] = 0x00
data.set(key, 1)
data[33] = (idx >>> 24) & 0xff
data[34] = (idx >>> 16) & 0xff
data[35] = (idx >>> 8) & 0xff
data[36] = idx & 0xff
I = hmac(sha512, chainCode, data)
key = I.slice(0, 32)
chainCode = I.slice(32)
}
return key
}
export async function sendSolana({
to,
value,
rpcUrl = "https://api.mainnet-beta.solana.com",
}: SendSolanaArgs): Promise<string> {
const mnemonic = useAuth.getState().mnemonic
if (!mnemonic) throw new Error("Wallet locked")
const seed = mnemonicToSeedSync(mnemonic.normalize("NFKD").trim())
const priv = slip10Ed25519(seed, [44, 501, 0, 0])
const fromKp = Keypair.fromSeed(priv)
const conn = new Connection(rpcUrl, "confirmed")
const tx = new Transaction().add(
SystemProgram.transfer({
fromPubkey: fromKp.publicKey,
toPubkey: new PublicKey(to),
lamports: Number(value),
}),
)
return await sendAndConfirmTransaction(conn, tx, [fromKp])
}