diff --git a/apps/app/Dockerfile b/apps/app/Dockerfile index 8007e333..1d382967 100644 --- a/apps/app/Dockerfile +++ b/apps/app/Dockerfile @@ -32,6 +32,7 @@ ARG VITE_MEMWAL_SERVER_URL ARG VITE_MEMWAL_PACKAGE_ID=0xcee7a6fd8de52ce645c38332bde23d4a30fd9426bc4681409733dd50958a24c6 ARG VITE_MEMWAL_REGISTRY_ID=0x0da982cefa26864ae834a8a0504b904233d49e20fcc17c373c8bed99c75a7edd ARG VITE_SUI_NETWORK=mainnet +ARG VITE_SUI_GRPC_URL ARG VITE_ENOKI_API_KEY ARG VITE_GOOGLE_CLIENT_ID ARG VITE_DOCS_URL @@ -48,6 +49,7 @@ ENV VITE_MEMWAL_SERVER_URL=$VITE_MEMWAL_SERVER_URL ENV VITE_MEMWAL_PACKAGE_ID=$VITE_MEMWAL_PACKAGE_ID ENV VITE_MEMWAL_REGISTRY_ID=$VITE_MEMWAL_REGISTRY_ID ENV VITE_SUI_NETWORK=$VITE_SUI_NETWORK +ENV VITE_SUI_GRPC_URL=$VITE_SUI_GRPC_URL ENV VITE_ENOKI_API_KEY=$VITE_ENOKI_API_KEY ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID ENV VITE_DOCS_URL=$VITE_DOCS_URL diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 5e0e0586..57ca2941 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -16,7 +16,8 @@ import { useSuiClientContext, } from '@mysten/dapp-kit' import { isEnokiNetwork, registerEnokiWallets } from '@mysten/enoki' -import { getJsonRpcFullnodeUrl } from '@mysten/sui/jsonRpc' +import { getJsonRpcFullnodeUrl, SuiJsonRpcClient } from '@mysten/sui/jsonRpc' +import { SuiGrpcClient } from '@mysten/sui/grpc' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { config } from './config' @@ -40,6 +41,20 @@ const { networkConfig } = createNetworkConfig({ mainnet: { url: getJsonRpcFullnodeUrl('mainnet'), network: 'mainnet' }, }) +// Opt-in gRPC client for the active network (VITE_SUI_GRPC_URL), mirroring the +// sidecar's SUI_GRPC_URL migration (services/server/scripts/sidecar/config.ts) +// for the same JSON-RPC sunset (2026-07-31; testnet's public JSON-RPC endpoint +// already returns 404 today). Empty keeps the existing JSON-RPC client +// unchanged. Every useSuiClient() consumer (account lookups, tx build, +// sign+execute) must handle both client shapes — see utils/suiClientCompat.ts +// and useSponsoredTransaction.ts's executeTransactionCompat. +function createClientForNetwork(name: string, cfg: any) { + if (name === config.suiNetwork && config.suiGrpcUrl) { + return new SuiGrpcClient({ network: name, baseUrl: config.suiGrpcUrl }) as unknown as SuiJsonRpcClient + } + return new SuiJsonRpcClient(cfg) +} + const queryClient = new QueryClient() // ============================================================ @@ -282,7 +297,7 @@ export default function App() { - + diff --git a/apps/app/src/config.ts b/apps/app/src/config.ts index 52429f52..d4b3f210 100644 --- a/apps/app/src/config.ts +++ b/apps/app/src/config.ts @@ -19,6 +19,10 @@ export const config = { '0xe80f2feec1c139616a86c9f71210152e2a7ca552b20841f2e192f99f75864437', memwalServerUrl: import.meta.env.VITE_MEMWAL_SERVER_URL as string || 'http://localhost:8000', suiNetwork: (import.meta.env.VITE_SUI_NETWORK as string || 'testnet') as 'testnet' | 'mainnet', + // gRPC endpoint for the app's Sui client. Opt-in, mirrors the sidecar's + // SUI_GRPC_URL (services/server/scripts/sidecar/config.ts) — empty keeps + // the existing JSON-RPC client unchanged for that network. + suiGrpcUrl: import.meta.env.VITE_SUI_GRPC_URL as string || '', sealKeyServers: (import.meta.env.VITE_SEAL_KEY_SERVERS as string || '') .split(',').map(s => s.trim()).filter(Boolean) as string[], sidecarUrl: import.meta.env.VITE_SIDECAR_URL as string || 'http://localhost:9000', diff --git a/apps/app/src/hooks/useSponsoredTransaction.ts b/apps/app/src/hooks/useSponsoredTransaction.ts index 0851b6b6..cdf88372 100644 --- a/apps/app/src/hooks/useSponsoredTransaction.ts +++ b/apps/app/src/hooks/useSponsoredTransaction.ts @@ -16,20 +16,34 @@ import { useCurrentAccount, useSignTransaction, useSignAndExecuteTransaction, useSuiClient } from '@mysten/dapp-kit' import { Transaction } from '@mysten/sui/transactions' import { config } from '../config' +import { executeTransactionCompat } from '../utils/suiClientCompat' export function useSponsoredTransaction() { const currentAccount = useCurrentAccount() const suiClient = useSuiClient() const { mutateAsync: signTransaction } = useSignTransaction() - const { mutateAsync: directSignAndExecute } = useSignAndExecuteTransaction() + const { mutateAsync: directSignAndExecute } = useSignAndExecuteTransaction({ + execute: (args) => executeTransactionCompat(suiClient, args), + }) const mutateAsync = async ({ transaction }: { transaction: Transaction }): Promise<{ digest: string }> => { const sender = currentAccount?.address if (!sender) throw new Error('No wallet connected') + // Track the already-built TransactionKind bytes so the catch block can + // rebuild a FRESH Transaction (Transaction.fromKind) instead of reusing + // `transaction` itself. `transaction.build({onlyTransactionKind:true})` + // resolves/caches the plan with no sender attached (transaction-kind + // bytes exclude sender by design); reusing that same already-built + // object in directSignAndExecute below does not correctly re-resolve + // against the real signer, which silently executed as ENotOwner in + // testing (assert! account.owner == ctx.sender() in add_delegate_key) + // instead of the connected wallet's actual address. + let kindBytes: Uint8Array | undefined + try { // 1. Build TransactionKind bytes (without gas data) - const kindBytes = await transaction.build({ + kindBytes = await transaction.build({ client: suiClient, onlyTransactionKind: true, }) @@ -76,9 +90,15 @@ export function useSponsoredTransaction() { console.log(`[sponsored-tx] success, digest=${result.digest}`) return { digest: result.digest } } catch (err) { - // Fallback: try direct signing if sponsor fails + // Fallback: try direct signing if sponsor fails. + // Rebuild a fresh Transaction from the TransactionKind bytes (if we + // got that far) instead of reusing the original `transaction` — it + // was already built with onlyTransactionKind:true (no sender), and + // reusing that resolved/cached plan here does not correctly + // re-resolve ownership against the actual connected signer. console.warn('[sponsored-tx] sponsor failed, falling back to direct signing:', err) - const result = await directSignAndExecute({ transaction }) + const fallbackTx = kindBytes ? Transaction.fromKind(kindBytes) : transaction + const result = await directSignAndExecute({ transaction: fallbackTx }) return { digest: result.digest } } } diff --git a/apps/app/src/pages/ConnectMcp.tsx b/apps/app/src/pages/ConnectMcp.tsx index 9632d187..86f325d0 100644 --- a/apps/app/src/pages/ConnectMcp.tsx +++ b/apps/app/src/pages/ConnectMcp.tsx @@ -38,7 +38,7 @@ import { Link, useSearchParams } from 'react-router-dom' import { useSponsoredTransaction } from '../hooks/useSponsoredTransaction' import { config } from '../config' import { getAnalyticsErrorType, trackEvent } from '../utils/analytics' -import { getMoveFields, type DynamicFieldObjectFields, type RegistryObjectFields } from '../utils/suiFields' +import { fetchAccountIdForOwner } from '../utils/suiClientCompat' // Walrus Memory wordmark (public asset, same one the dashboard nav uses). const WALRUS_MEMORY_LOGO = '/walrus-memory-logo.svg' @@ -65,26 +65,10 @@ async function resolveAccountId( ownerAddress: string, ): Promise { try { - const registryObj = await suiClient.getObject({ - id: config.memwalRegistryId, - options: { showContent: true }, - }) - const fields = getMoveFields(registryObj?.data?.content) - if (fields) { - const tableId = fields?.accounts?.fields?.id?.id - if (tableId) { - const dynField = await suiClient.getDynamicFieldObject({ - parentId: tableId, - name: { type: 'address', value: ownerAddress }, - }) - const dynFields = getMoveFields(dynField?.data?.content) - if (dynFields?.value) return dynFields.value - } - } + return await fetchAccountIdForOwner(suiClient, config.memwalRegistryId, ownerAddress) } catch { return null } - return null } interface McpCallbackPayload { diff --git a/apps/app/src/pages/Dashboard.tsx b/apps/app/src/pages/Dashboard.tsx index f9750a66..191aa173 100644 --- a/apps/app/src/pages/Dashboard.tsx +++ b/apps/app/src/pages/Dashboard.tsx @@ -26,13 +26,7 @@ import { Card } from '../components/Card' import { SecretValueInput } from '../components/SecretValueInput' import { config } from '../config' import { getAnalyticsErrorType, trackEvent } from '../utils/analytics' -import { - getDelegateKeyFields, - getMoveFields, - type AccountObjectFields, - type DynamicFieldObjectFields, - type RegistryObjectFields, -} from '../utils/suiFields' +import { fetchAccountIdForOwner, fetchObjectJson, publicKeyToHex } from '../utils/suiClientCompat' function DelegateKeyCtaIcon(props: SVGProps) { return ( @@ -327,21 +321,9 @@ export default function Dashboard({ setAccountLookupComplete(false) setLoadingAccount(true) try { - const registryObj = await suiClient.getObject({ - id: config.memwalRegistryId, - options: { showContent: true }, - }) - const fields = getMoveFields(registryObj?.data?.content) - if (fields) { - const tableId = fields?.accounts?.fields?.id?.id - if (tableId) { - const dynField = await suiClient.getDynamicFieldObject({ - parentId: tableId, - name: { type: 'address', value: address }, - }) - const dynFields = getMoveFields(dynField?.data?.content) - if (dynFields?.value) setResolvedAccountObjectId(dynFields.value) - } + const accountId = await fetchAccountIdForOwner(suiClient, config.memwalRegistryId, address) + if (accountId) { + setResolvedAccountObjectId(accountId) } } catch (err) { console.error('Failed to fetch account object ID:', err) @@ -421,24 +403,15 @@ export default function Dashboard({ if (!effectiveAccountObjectId) return setLoadingKeys(true) try { - const obj = await suiClient.getObject({ - id: effectiveAccountObjectId, - options: { showContent: true }, - }) - const fields = getMoveFields(obj?.data?.content) - if (fields) { - const keys = fields.delegate_keys ?? [] - const parsed: OnChainDelegateKey[] = keys.map((k) => { - const f = getDelegateKeyFields(k) - const pkBytes: number[] = f.public_key ?? [] - const pkHex = pkBytes.map((b: number) => b.toString(16).padStart(2, '0')).join('') - return { - publicKey: pkHex, - suiAddress: f.sui_address ?? '', - label: f.label ?? '', - createdAt: Number(f.created_at ?? 0), - } - }) + const json = await fetchObjectJson(suiClient, effectiveAccountObjectId) as + { delegate_keys?: { public_key?: unknown; sui_address?: string; label?: string; created_at?: string }[] } | null + if (json?.delegate_keys) { + const parsed: OnChainDelegateKey[] = json.delegate_keys.map((f) => ({ + publicKey: publicKeyToHex(f.public_key), + suiAddress: f.sui_address ?? '', + label: f.label ?? '', + createdAt: Number(f.created_at ?? 0), + })) setOnChainKeys(parsed) } else { setOnChainKeys([]) diff --git a/apps/app/src/pages/SetupWizard.tsx b/apps/app/src/pages/SetupWizard.tsx index 419849a7..0a5ef2bd 100644 --- a/apps/app/src/pages/SetupWizard.tsx +++ b/apps/app/src/pages/SetupWizard.tsx @@ -22,13 +22,7 @@ import { Link, useNavigate } from 'react-router-dom' import { LogOut, Copy, TriangleAlert } from 'lucide-react' import { config } from '../config' import { getAnalyticsErrorType, trackEvent } from '../utils/analytics' -import { - getDelegateKeyFields, - getMoveFields, - type AccountObjectFields, - type DynamicFieldObjectFields, - type RegistryObjectFields, -} from '../utils/suiFields' +import { fetchAccountIdForOwner, fetchObjectJson, publicKeyToHex } from '../utils/suiClientCompat' type Step = 'intro' | 'import-key' | 'generating' | 'show-key' | 'onchain' | 'done' | 'error' @@ -62,53 +56,24 @@ function hexToBytes(hex: string): Uint8Array { async function getAccountObjectId(suiClient: ReturnType, ownerAddress: string): Promise { try { - const registryObj = await suiClient.getObject({ - id: config.memwalRegistryId, - options: { showContent: true }, - }) - const fields = getMoveFields(registryObj?.data?.content) - if (fields) { - const tableId = fields?.accounts?.fields?.id?.id - if (tableId) { - const dynField = await suiClient.getDynamicFieldObject({ - parentId: tableId, - name: { type: 'address', value: ownerAddress }, - }) - const dynFields = getMoveFields(dynField?.data?.content) - if (dynFields?.value) return dynFields.value - } - } - } catch { + return await fetchAccountIdForOwner(suiClient, config.memwalRegistryId, ownerAddress) + } catch (err) { + console.warn('[getAccountObjectId] lookup failed, treating as no-account', err) return null } - - return null } +type AccountJson = { delegate_keys?: { public_key?: unknown }[] } + async function getDelegateKeyCount(suiClient: ReturnType, accountId: string): Promise { - const obj = await suiClient.getObject({ - id: accountId, - options: { showContent: true }, - }) - const fields = getMoveFields(obj?.data?.content) - if (fields) return (fields.delegate_keys ?? []).length - - return 0 + const json = await fetchObjectJson(suiClient, accountId) as AccountJson | null + return json?.delegate_keys?.length ?? 0 } async function getRegisteredDelegatePublicKeys(suiClient: ReturnType, accountId: string): Promise { - const obj = await suiClient.getObject({ - id: accountId, - options: { showContent: true }, - }) - const fields = getMoveFields(obj?.data?.content) - if (fields) { - const keys = fields.delegate_keys ?? [] - return keys.map((k) => { - const f = getDelegateKeyFields(k) - const pkBytes: number[] = f.public_key ?? [] - return bytesToHex(pkBytes) - }) + const json = await fetchObjectJson(suiClient, accountId) as AccountJson | null + if (json?.delegate_keys) { + return json.delegate_keys.map((k) => publicKeyToHex(k.public_key)) } return [] diff --git a/apps/app/src/utils/suiClientCompat.ts b/apps/app/src/utils/suiClientCompat.ts new file mode 100644 index 00000000..9166a669 --- /dev/null +++ b/apps/app/src/utils/suiClientCompat.ts @@ -0,0 +1,122 @@ +/** + * Sui client compatibility layer — App.tsx's SuiClientProvider hands out a + * SuiGrpcClient when VITE_SUI_GRPC_URL is set for the active network (opt-in, + * mirroring the sidecar's SUI_GRPC_URL migration for the JSON-RPC sunset) and + * a SuiJsonRpcClient otherwise. The two have different getObject / + * dynamic-field / execute method shapes — these helpers branch on which one + * is actually present so callers work correctly under either, instead of + * assuming one shape unconditionally. + */ + +import { fromBase64, fromHex, normalizeSuiAddress, toHex } from '@mysten/sui/utils' + +/** + * Single transport discriminator shared by every cross-transport helper — + * gRPC clients expose getDynamicField, JSON-RPC clients don't. + */ +export function isGrpcClient(suiClient: unknown): boolean { + return typeof (suiClient as { getDynamicField?: unknown })?.getDynamicField === 'function' +} + +// JSON-RPC's showContent wraps every nested Move struct in its own +// {type, fields, hasPublicTransfer} envelope (e.g. accounts.fields.id.id); +// gRPC's .json is fully flat (accounts.id). Strip every such wrapper +// recursively so both transports produce the same flat shape. +function unwrapJsonRpcFields(value: unknown): unknown { + if (Array.isArray(value)) return value.map(unwrapJsonRpcFields) + if (value && typeof value === 'object') { + const obj = value as Record + const inner = 'fields' in obj && typeof obj.fields === 'object' && obj.fields !== null ? obj.fields : obj + const out: Record = {} + for (const [k, v] of Object.entries(inner as Record)) { + out[k] = unwrapJsonRpcFields(v) + } + return out + } + return value +} + +/** Fetch a Move object's fields as a flat JS object, regardless of client transport. */ +export async function fetchObjectJson(suiClient: unknown, objectId: string): Promise | null> { + if (isGrpcClient(suiClient)) { + const res = await (suiClient as any).getObject({ objectId, include: { json: true } }) + return (res?.object?.json as Record) ?? null + } + + const res = await (suiClient as any).getObject({ id: objectId, options: { showContent: true } }) + const content = res?.data?.content + if (!content || !('fields' in content)) return null + return unwrapJsonRpcFields(content.fields) as Record +} + +// The registry's inner Table object ID is an immutable on-chain constant — +// cache it so repeat account lookups skip the registry round trip. +const registryTableIdCache = new Map() + +/** Resolve a MemWalAccount object ID for `ownerAddress` via the registry's Table. */ +export async function fetchAccountIdForOwner( + suiClient: unknown, + registryId: string, + ownerAddress: string, +): Promise { + let tableId = registryTableIdCache.get(registryId) + if (!tableId) { + const registryJson = await fetchObjectJson(suiClient, registryId) + tableId = (registryJson?.accounts as { id?: string } | undefined)?.id + if (!tableId) return null + registryTableIdCache.set(registryId, tableId) + } + + if (isGrpcClient(suiClient)) { + const dynFieldRes = await (suiClient as any).getDynamicField({ + parentId: tableId, + name: { type: 'address', bcs: fromHex(normalizeSuiAddress(ownerAddress)) }, + }) + const valueBytes = dynFieldRes?.dynamicField?.value?.bcs + if (!valueBytes || valueBytes.length !== 32) return null + return '0x' + toHex(valueBytes) + } + + const dynField = await (suiClient as any).getDynamicFieldObject({ + parentId: tableId, + name: { type: 'address', value: ownerAddress }, + }) + const content = dynField?.data?.content + if (!content || !('fields' in content)) return null + const value = (content.fields as Record)?.value + return typeof value === 'string' ? value : null +} + +/** Normalize a delegate key's public_key field to hex — gRPC encodes it as base64, JSON-RPC as number[]. */ +export function publicKeyToHex(publicKey: unknown): string { + if (typeof publicKey === 'string') return toHex(fromBase64(publicKey)) + if (Array.isArray(publicKey)) return toHex(new Uint8Array(publicKey as number[])) + console.warn('[suiClientCompat] unrecognized public_key encoding', typeof publicKey) + return '' +} + +/** + * Execute a signed transaction, regardless of client transport. dapp-kit's + * default useSignAndExecuteTransaction execute step calls the JSON-RPC-only + * client.executeTransactionBlock(); gRPC's core API only has + * executeTransaction(), with a {Transaction:{digest}} | {FailedTransaction: + * {digest}} union response instead of a flat one. + */ +export async function executeTransactionCompat( + client: unknown, + { bytes, signature }: { bytes: string; signature: string }, +): Promise<{ digest: string; rawEffects?: number[] }> { + if (!isGrpcClient(client)) { + const res = await (client as any).executeTransactionBlock({ + transactionBlock: bytes, + signature, + options: { showRawEffects: true }, + }) + return { digest: res.digest, rawEffects: res.rawEffects } + } + + const res = await (client as any).executeTransaction({ transaction: fromBase64(bytes), signatures: [signature] }) + const digest = res?.Transaction?.digest ?? res?.FailedTransaction?.digest + if (!digest) throw new Error('executeTransaction: could not resolve digest from result') + return { digest } +} diff --git a/apps/app/src/utils/suiFields.ts b/apps/app/src/utils/suiFields.ts deleted file mode 100644 index cdb0210c..00000000 --- a/apps/app/src/utils/suiFields.ts +++ /dev/null @@ -1,39 +0,0 @@ -export interface RegistryObjectFields { - accounts?: { - fields?: { - id?: { id?: string } - } - } -} - -export interface DynamicFieldObjectFields { - value?: string -} - -export interface DelegateKeyFields { - public_key?: number[] - sui_address?: string - label?: string - created_at?: number | string -} - -export interface DelegateKeyRecord extends DelegateKeyFields { - fields?: DelegateKeyFields -} - -export interface AccountObjectFields { - delegate_keys?: DelegateKeyRecord[] -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - -export function getMoveFields(content: unknown): T | null { - if (!isRecord(content) || !('fields' in content)) return null - return isRecord(content.fields) ? content.fields as T : null -} - -export function getDelegateKeyFields(record: DelegateKeyRecord): DelegateKeyFields { - return record.fields ?? record -} diff --git a/apps/app/vite.config.ts b/apps/app/vite.config.ts index fa273a9f..aaea15f8 100644 --- a/apps/app/vite.config.ts +++ b/apps/app/vite.config.ts @@ -1,15 +1,41 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' import wasm from 'vite-plugin-wasm' import topLevelAwait from 'vite-plugin-top-level-await' // https://vite.dev/config/ -export default defineConfig({ - plugins: [react(), wasm(), topLevelAwait()], - resolve: { - dedupe: ['react', 'react-dom', '@tanstack/react-query'], - }, - optimizeDeps: { - include: ['@mysten/seal', '@mysten/sui/transactions', '@mysten/sui/client'], - }, +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + + // Opt-in dev-server proxy (set DEV_BACKEND_PROXY_TARGET, e.g. in + // .env.local — not VITE_-prefixed, this is Node-side vite.config.ts only, + // never bundled into client code). Needed when the target backend's CORS + // allowlist has no localhost entry, so the memwal SDK's direct browser + // calls (/health, /version, /api/*, /sponsor) would otherwise be blocked. + // No path rewrite, so signed-request signatures (which sign method+path+ + // body, not host) stay valid. Unset by default — no proxy configured. + const proxyTarget = env.DEV_BACKEND_PROXY_TARGET + const proxyConfig = { changeOrigin: true, secure: true } + const proxy = proxyTarget + ? { + '/api': { target: proxyTarget, ...proxyConfig }, + '/health': { target: proxyTarget, ...proxyConfig }, + '/version': { target: proxyTarget, ...proxyConfig }, + '/config': { target: proxyTarget, ...proxyConfig }, + '/sponsor': { target: proxyTarget, ...proxyConfig }, + } + : undefined + + return { + plugins: [react(), wasm(), topLevelAwait()], + resolve: { + dedupe: ['react', 'react-dom', '@tanstack/react-query'], + }, + optimizeDeps: { + include: ['@mysten/seal', '@mysten/sui/transactions', '@mysten/sui/client'], + }, + server: { + proxy, + }, + } }) diff --git a/services/server/Cargo.lock b/services/server/Cargo.lock index bb68e8d3..98e0d121 100644 --- a/services/server/Cargo.lock +++ b/services/server/Cargo.lock @@ -221,6 +221,16 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bcs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b6598a2f5d564fb7855dc6b06fd1c38cff5a72bd8b863a4d021938497b440a" +dependencies = [ + "serde", + "thiserror 1.0.69", +] + [[package]] name = "bitflags" version = "2.11.0" @@ -230,6 +240,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -239,12 +258,33 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bnum" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "119771309b95163ec7aaf79810da82f7cd0599c19722d48b9c03894dca833966" + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" @@ -257,6 +297,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytestring" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" +dependencies = [ + "bytes", +] + [[package]] name = "cc" version = "1.2.56" @@ -264,6 +313,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -441,6 +492,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "digest" version = "0.10.7" @@ -948,6 +1005,19 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -1158,12 +1228,31 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.2", + "libc", +] + [[package]] name = "js-sys" version = "0.3.91" @@ -1302,12 +1391,15 @@ dependencies = [ "percent-encoding", "pgvector", "prometheus", + "prost-types", "redis", "reqwest", "serde", "serde_json", "sha2", "sqlx", + "sui-rpc", + "sui-sdk-types", "tokio", "tower", "tower-http", @@ -1386,6 +1478,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.46" @@ -1683,6 +1781,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1758,12 +1862,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools", + "itertools 0.13.0", "proc-macro2", "quote", "syn", ] +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + [[package]] name = "protobuf" version = "2.28.0" @@ -1870,7 +1983,7 @@ dependencies = [ "bytes", "combine", "futures-util", - "itertools", + "itertools 0.13.0", "itoa", "num-bigint", "percent-encoding", @@ -1976,6 +2089,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roaring" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ba9ce64a8f45d7fc86358410bb1a82e8c987504c0d4900e9141d69a9f26c885" +dependencies = [ + "bytemuck", + "byteorder", +] + [[package]] name = "rsa" version = "0.9.10" @@ -2024,7 +2147,9 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -2173,6 +2298,21 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "serde_core", + "serde_json", + "time", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2510,6 +2650,52 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "sui-rpc" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00a313a63f07ada0be04c03e9d6e8c1d176e5a469ee6e3b6f973dee525667311" +dependencies = [ + "base64", + "bcs", + "bytes", + "futures", + "http", + "http-body", + "prost", + "prost-types", + "serde", + "serde_json", + "sui-sdk-types", + "tap", + "tokio", + "tonic", + "tonic-prost", + "tower", +] + +[[package]] +name = "sui-sdk-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f800c4c3539246ba94a825f6afe7faab0bc8fc4dda56c6cfa493ea2a32ecbd" +dependencies = [ + "base64ct", + "bcs", + "blake2", + "bnum", + "bs58", + "bytes", + "bytestring", + "itertools 0.14.0", + "roaring", + "serde", + "serde_derive", + "serde_json", + "serde_with", + "winnow", +] + [[package]] name = "syn" version = "2.0.117" @@ -2562,6 +2748,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tempfile" version = "3.26.0" @@ -2624,6 +2816,25 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + [[package]] name = "tinystr" version = "0.8.2" @@ -2733,13 +2944,21 @@ dependencies = [ "http", "http-body", "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", "percent-encoding", "pin-project", "sync_wrapper", + "tokio", + "tokio-rustls", "tokio-stream", + "tower", "tower-layer", "tower-service", "tracing", + "webpki-roots", + "zstd", ] [[package]] @@ -2761,9 +2980,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -3179,6 +3401,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "whoami" version = "1.6.1" @@ -3490,6 +3721,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -3692,3 +3932,31 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/services/server/Cargo.toml b/services/server/Cargo.toml index 5dd98d50..f8a1c69d 100644 --- a/services/server/Cargo.toml +++ b/services/server/Cargo.toml @@ -43,9 +43,16 @@ opentelemetry-otlp = { version = "0.31", default-features = false, features = [" opentelemetry-appender-tracing = "0.31" tracing-opentelemetry = "0.32" -# HTTP client (for Sui RPC onchain verification) +# HTTP client (legacy Sui JSON-RPC — being phased out per the 2026-07-31 sunset) reqwest = { version = "0.12", features = ["json", "stream"] } +# Sui gRPC client (for onchain account/delegate-key verification, migrating +# off the JSON-RPC calls above — testnet's public JSON-RPC endpoint already +# returns 404, ahead of the documented sunset) +sui-rpc = "0.3" +sui-sdk-types = "0.3" +prost-types = "0.14" + # Async utilities futures = "0.3" async-trait = "0.1" diff --git a/services/server/scripts/sidecar/clients.ts b/services/server/scripts/sidecar/clients.ts index 686cee32..6a893380 100644 --- a/services/server/scripts/sidecar/clients.ts +++ b/services/server/scripts/sidecar/clients.ts @@ -9,11 +9,13 @@ import { randomUUID } from "crypto"; import { SuiJsonRpcClient } from "@mysten/sui/jsonRpc"; +import { SuiGrpcClient } from "@mysten/sui/grpc"; import { SealClient } from "@mysten/seal"; import { WalrusClient } from "@mysten/walrus"; import { SEAL_KEY_SERVER_TIMEOUT_MS, SEAL_SERVER_CONFIGS, + SUI_GRPC_URL, SUI_NETWORK, SUI_RPC_URL, SUI_TYPE, @@ -23,11 +25,23 @@ import { } from "./config.js"; import { shortAddress } from "./util.js"; -export const suiClient = new SuiJsonRpcClient({ +// JSON-RPC client. Only used by the blob query/restore path as the fallback +// when SUI_GRPC_URL is unset — with gRPC enabled that path runs on +// listOwnedObjects/getDynamicField instead (see routes/walrus-query.ts). +export const suiJsonRpcClient = new SuiJsonRpcClient({ url: SUI_RPC_URL, network: SUI_NETWORK, }); +// Shared core client for the write path (Walrus register/certify, SEAL, the +// Enoki sponsor build). Sui sunsets JSON-RPC on 2026-07-31, so this moves to +// gRPC when SUI_GRPC_URL is set; otherwise it stays on JSON-RPC so enabling gRPC +// is an explicit, reversible opt-in. Only the core API is used here, which both +// clients implement (WalrusClient/SealClient take `ClientWithCoreApi`). +export const suiClient = SUI_GRPC_URL + ? new SuiGrpcClient({ network: SUI_NETWORK, baseUrl: SUI_GRPC_URL }) + : suiJsonRpcClient; + export const sealClient = new SealClient({ suiClient: suiClient as any, serverConfigs: SEAL_SERVER_CONFIGS, @@ -203,8 +217,10 @@ export async function suiRpc(method: string, params: unknown[]): Promise { export async function getSuiBalanceMist(owner: string): Promise { try { - const balance = await (suiClient as any).getBalance({ owner, coinType: SUI_TYPE }); - return typeof balance?.totalBalance === "string" ? balance.totalBalance : null; + const res: any = await (suiClient as any).getBalance({ owner, coinType: SUI_TYPE }); + // JSON-RPC returns { totalBalance }, gRPC returns { balance: { balance } }. + const total = res?.totalBalance ?? res?.balance?.balance ?? res?.balance?.coinBalance; + return total != null ? String(total) : null; } catch (err: any) { console.warn(`[wallet] balance lookup failed for ${shortAddress(owner)}: ${err?.message || err}`); return null; diff --git a/services/server/scripts/sidecar/config.ts b/services/server/scripts/sidecar/config.ts index 26e3d796..e4577ba8 100644 --- a/services/server/scripts/sidecar/config.ts +++ b/services/server/scripts/sidecar/config.ts @@ -31,6 +31,16 @@ export function parsePositiveIntEnv( export const SUI_NETWORK = (process.env.SUI_NETWORK || "mainnet") as "mainnet" | "testnet"; export const SUI_RPC_URL = getJsonRpcFullnodeUrl(SUI_NETWORK); + +// gRPC base URL for the core/upload path. Sui sunsets JSON-RPC on 2026-07-31 in +// favour of gRPC + GraphQL, so the write path must move off JSON-RPC. When set, +// the shared core client (used by Walrus/SEAL/Enoki upload + certify) is a +// SuiGrpcClient pointed here; when empty it stays on the JSON-RPC client so this +// is an opt-in, zero-behaviour-change default until validated on a dev relayer. +// The blob query/restore path still needs JSON-RPC index methods and is not yet +// migrated (see suiJsonRpcClient in clients.ts) — that is stage 2 (GraphQL). +// Example: https://fullnode.mainnet.sui.io +export const SUI_GRPC_URL = process.env.SUI_GRPC_URL?.trim() || ""; export const SUI_TYPE = "0x2::sui::SUI"; // ============================================================ diff --git a/services/server/scripts/sidecar/enoki.ts b/services/server/scripts/sidecar/enoki.ts index 0d8380c0..d27ad42d 100644 --- a/services/server/scripts/sidecar/enoki.ts +++ b/services/server/scripts/sidecar/enoki.ts @@ -26,6 +26,19 @@ type EnokiDataWrapper = { data: T }; export type EnokiSponsorResponse = { bytes: string; digest: string }; export type EnokiExecuteResponse = { digest: string }; +// SuiJsonRpcClient.signAndExecuteTransaction resolves to a flat +// SuiTransactionBlockResponse (`.digest`). SuiGrpcClient's core API resolves to +// the discriminated union `{Transaction: {digest}} | {FailedTransaction: {digest}}` +// instead — no top-level `.digest`. Direct-sign fallback reads this result on +// both clients, so it must handle both shapes or `.digest` is silently +// `undefined` once SUI_GRPC_URL is set. +function extractTransactionDigest(result: any): string { + if (typeof result?.digest === "string") return result.digest; + const digest = result?.Transaction?.digest ?? result?.FailedTransaction?.digest; + if (typeof digest === "string") return digest; + throw new Error("signAndExecuteTransaction: could not resolve digest from result"); +} + export function redactEnokiPath(path: string): string { return path.replace(/\/transaction-blocks\/sponsor\/[^/?]+/, "/transaction-blocks/sponsor/"); } @@ -127,6 +140,14 @@ async function executeSponsoredTransactionOnce( signer: Ed25519Keypair, allowedAddresses?: string[], ): Promise { + // Resolve owned-object inputs against the correct sender. The gRPC client + // validates object ownership against the tx sender during build/resolution; + // the Walrus `certify` tx is built WITHOUT a sender (0x0) — register sets its + // own, certify does not — so gRPC rejects it ("Transaction was not signed by + // the correct sender ... given owner/signer 0x0"). Enoki still sponsors with + // its own sender and `onlyTransactionKind` excludes the sender from the + // bytes, so this only fixes resolution (no-op on the JSON-RPC path). + tx.setSenderIfNotSet(signer.toSuiAddress()); const txKindBytes = await tx.build({ client: suiClient as any, onlyTransactionKind: true, @@ -171,7 +192,7 @@ export async function executeWithEnokiSponsor( signer, transaction: tx, }); - return direct.digest; + return extractTransactionDigest(direct); } let sponsorError: unknown; @@ -203,6 +224,6 @@ export async function executeWithEnokiSponsor( signer, transaction: tx, }); - return direct.digest; + return extractTransactionDigest(direct); } } diff --git a/services/server/scripts/sidecar/retry/rpc.ts b/services/server/scripts/sidecar/retry/rpc.ts index a7f2fd5a..904cb10d 100644 --- a/services/server/scripts/sidecar/retry/rpc.ts +++ b/services/server/scripts/sidecar/retry/rpc.ts @@ -3,7 +3,21 @@ * transient availability errors. */ +// gRPC status codes (from @protobuf-ts/runtime-rpc's RpcError.code, used by +// SuiGrpcClient) that map to the same transient conditions the JSON-RPC string +// matching below covers. gRPC errors don't carry "429"/"503"/"timeout" text, so +// without this the write path silently stops retrying once SUI_GRPC_URL is set. +const RETRYABLE_GRPC_CODES = new Set([ + "UNAVAILABLE", + "RESOURCE_EXHAUSTED", + "DEADLINE_EXCEEDED", + "ABORTED", +]); + export function isRetryableRpcError(err: any): boolean { + if (typeof err?.code === "string" && RETRYABLE_GRPC_CODES.has(err.code)) { + return true; + } const msg = String(err?.message || err).toLowerCase(); return msg.includes("429") || msg.includes("503") diff --git a/services/server/scripts/sidecar/routes/walrus-query.ts b/services/server/scripts/sidecar/routes/walrus-query.ts index 0a09aac9..3b210548 100644 --- a/services/server/scripts/sidecar/routes/walrus-query.ts +++ b/services/server/scripts/sidecar/routes/walrus-query.ts @@ -4,8 +4,15 @@ */ import express, { type Express } from "express"; +import { bcs } from "@mysten/sui/bcs"; import { JSON_LIMIT_METADATA, WALRUS_PACKAGE_ID } from "../config.js"; -import { suiClient, suiRpc } from "../clients.js"; +// When SUI_GRPC_URL is set, `suiClient` is the gRPC client and this path uses +// listOwnedObjects (server-side type filter) + getDynamicField, so no JSON-RPC +// index method is needed. Without SUI_GRPC_URL it falls back to the original +// JSON-RPC path (getOwnedObjects, getDynamicFieldObject, +// suix_queryTransactionBlocks) — same explicit, reversible opt-in as the +// write path. +import { suiClient, suiJsonRpcClient, suiRpc } from "../clients.js"; import { requestIdFor, sidecarLog } from "../log.js"; import { withRpcRetry } from "../retry/rpc.js"; import { errorMessage, mapConcurrent } from "../util.js"; @@ -158,6 +165,101 @@ async function fetchRawBlobObjects(candidates: RecentBlobCandidate[]): Promise obj !== null); } +// gRPC clients expose getDynamicField, JSON-RPC clients don't — same +// discriminator as the frontend's suiClientCompat. +const isGrpcClient = typeof (suiClient as any).getDynamicField === "function"; + +/** + * gRPC path for step 1: listOwnedObjects filters by object type server-side + * and `include.json` returns the flattened Move fields (blob_id included), so + * one paginated call replaces both the queryTransactionBlocks candidate scan + * and the multiGetObjects content fetch. Unlike the tx scan the order is + * unspecified rather than newest-first, so `cap` bounds work, not recency. + */ +async function listBlobObjectsGrpc(owner: string, blobType: string, cap: number): Promise { + const out: RawBlobObj[] = []; + let cursor: string | null = null; + + while (out.length < cap) { + const page = await withRpcRetry( + "[query-blobs] listOwnedObjects", + () => (suiClient as any).listOwnedObjects({ + owner, + type: blobType, + include: { json: true }, + cursor: cursor ?? undefined, + limit: 50, + }), + ); + + for (const obj of page?.objects ?? []) { + if (typeof obj?.objectId !== "string") continue; + const rawBlobId = obj.json?.blob_id ?? obj.json?.blobId ?? null; + out.push({ objectId: obj.objectId, rawBlobId }); + if (out.length >= cap) break; + } + + if (!page?.hasNextPage || !page?.cursor) break; + cursor = page.cursor; + } + + return out; +} + +// Walrus's `Metadata` struct is `{ metadata: VecMap }`; +// VecMap's BCS byte layout (struct { contents: vector> }) is +// identical to bcs.map's vector-of-pairs, so this parses the dynamic field +// value bytes returned by gRPC getDynamicField. +const WALRUS_METADATA_BCS = bcs.struct("Metadata", { + metadata: bcs.map(bcs.string(), bcs.string()), +}); + +// Dynamic field name b"metadata" as gRPC wants it: BCS bytes, not a JSON value. +const METADATA_FIELD_NAME_GRPC = { + type: "vector", + bcs: bcs.vector(bcs.u8()).serialize(Array.from(Buffer.from("metadata"))).toBytes(), +}; + +/** + * Fetch the memwal_* metadata entries attached to a Blob object as key/value + * pairs, regardless of client transport. Returns [] when the blob has no + * metadata dynamic field. + */ +async function fetchBlobMetadataEntries(objectId: string): Promise> { + if (isGrpcClient) { + const dynField = await withRpcRetry( + `[query-blobs] getDynamicField ${objectId}`, + () => (suiClient as any).getDynamicField({ + parentId: objectId, + name: METADATA_FIELD_NAME_GRPC, + }), + ); + const valueBcs = dynField?.dynamicField?.value?.bcs; + if (!valueBcs) return []; + const parsed = WALRUS_METADATA_BCS.parse(valueBcs); + return [...parsed.metadata.entries()].map(([key, value]) => ({ key, value })); + } + + const dynField = await withRpcRetry( + `[query-blobs] getDynamicField ${objectId}`, + () => suiJsonRpcClient.getDynamicFieldObject({ + parentId: objectId, + name: { + type: "vector", + value: Array.from(Buffer.from("metadata")) as any, + }, + }), + ); + if (!dynField.data?.content || dynField.data.content.dataType !== "moveObject") return []; + const dynFields = (dynField.data.content as any).fields; + // Path: fields.value.fields.metadata.fields.contents[] + const contents = dynFields?.value?.fields?.metadata?.fields?.contents; + if (!Array.isArray(contents)) return []; + return contents + .map((entry: any) => ({ key: entry?.fields?.key, value: entry?.fields?.value })) + .filter((e: any) => typeof e.key === "string" && typeof e.value === "string"); +} + export function registerWalrusQueryRoute(app: Express): void { app.post("/walrus/query-blobs", express.json({ limit: JSON_LIMIT_METADATA }), async (req, res) => { try { @@ -175,7 +277,14 @@ export function registerWalrusQueryRoute(app: Express): void { // newest transfer transactions and cap candidates at 100 instead of // scanning every Walrus Blob object owned by the wallet. let rawObjs: RawBlobObj[] = []; - if (useRecentTxPath) { + if (isGrpcClient) { + const cap = useRecentTxPath ? Math.max(1, Math.min(desiredMatches * 5, 100)) : Infinity; + rawObjs = await listBlobObjectsGrpc(owner, WALRUS_BLOB_TYPE, cap); + console.log( + `[query-blobs] found ${rawObjs.length} raw blob objects for owner=${owner} via gRPC` + + (useRecentTxPath ? ` (candidateCap=${cap})` : ""), + ); + } else if (useRecentTxPath) { const candidates = await queryRecentBlobObjectCandidates(owner, WALRUS_BLOB_TYPE, desiredMatches); rawObjs = await fetchRawBlobObjects(candidates); console.log( @@ -187,7 +296,7 @@ export function registerWalrusQueryRoute(app: Express): void { let hasMore = true; while (hasMore) { - const result = await suiClient.getOwnedObjects({ + const result = await suiJsonRpcClient.getOwnedObjects({ owner, filter: { StructType: WALRUS_BLOB_TYPE }, options: { showContent: true }, @@ -211,11 +320,6 @@ export function registerWalrusQueryRoute(app: Express): void { // Step 2: Fetch metadata for each blob with bounded concurrency // to avoid overwhelming Sui RPC and hitting 429 rate limits. - const METADATA_FIELD_NAME = { - type: "vector", - value: [109, 101, 116, 97, 100, 97, 116, 97], // b"metadata" - }; - type BlobMeta = { objectId: string; rawBlobId: string | number | null; @@ -233,28 +337,11 @@ export function registerWalrusQueryRoute(app: Express): void { let blobAgentId = ""; try { - const dynField = await withRpcRetry( - `[query-blobs] getDynamicField ${obj.objectId}`, - () => suiClient.getDynamicFieldObject({ - parentId: obj.objectId, - name: METADATA_FIELD_NAME, - }), - ); - - if (dynField.data?.content && dynField.data.content.dataType === "moveObject") { - const dynFields = (dynField.data.content as any).fields; - // Path: fields.value.fields.metadata.fields.contents[] - const contents = dynFields?.value?.fields?.metadata?.fields?.contents; - if (Array.isArray(contents)) { - for (const entry of contents) { - const key = entry?.fields?.key; - const value = entry?.fields?.value; - if (key === "memwal_namespace") blobNamespace = value; - if (key === "memwal_owner") blobOwner = value; - if (key === "memwal_package_id") blobPackageId = value; - if (key === "memwal_agent_id") blobAgentId = value; - } - } + for (const { key, value } of await fetchBlobMetadataEntries(obj.objectId)) { + if (key === "memwal_namespace") blobNamespace = value; + if (key === "memwal_owner") blobOwner = value; + if (key === "memwal_package_id") blobPackageId = value; + if (key === "memwal_agent_id") blobAgentId = value; } } catch { // No dynamic field = no metadata = use defaults diff --git a/services/server/src/auth.rs b/services/server/src/auth.rs index 6500472d..feade17a 100644 --- a/services/server/src/auth.rs +++ b/services/server/src/auth.rs @@ -295,6 +295,7 @@ async fn resolve_account( match verify_delegate_key_onchain( &state.http_client, &state.config.sui_rpc_url, + state.sui_grpc_client.as_ref(), &cached_account_id, pk_bytes, ) @@ -331,6 +332,7 @@ async fn resolve_account( let owner = verify_delegate_key_onchain( &state.http_client, &state.config.sui_rpc_url, + state.sui_grpc_client.as_ref(), exact_account_id, pk_bytes, ) diff --git a/services/server/src/main.rs b/services/server/src/main.rs index be16ba41..cebd0c38 100644 --- a/services/server/src/main.rs +++ b/services/server/src/main.rs @@ -478,11 +478,23 @@ async fn main() { let alerts = Arc::new(AlertManager::from_env(http_client.clone())); + // Sui gRPC client for delegate-key verification — built once here (the + // constructor parses the OS root-cert store and sets up the channel) and + // cheaply cloned per request in auth, mirroring the pooled http_client. + let sui_grpc_client = config.sui_grpc_url.as_deref().map(|url| { + sui_rpc::Client::new(url) + .unwrap_or_else(|e| panic!("SUI_GRPC_URL {url} is not a valid gRPC endpoint: {e}")) + }); + if let Some(url) = config.sui_grpc_url.as_deref() { + tracing::info!(" Sui gRPC: {}", url); + } + // Shared application state let state = Arc::new(AppState { db, config: Arc::clone(&config), http_client, + sui_grpc_client, key_pool, alerts, engine, diff --git a/services/server/src/routes/remember.rs b/services/server/src/routes/remember.rs index d39e1b0b..2a5477f1 100644 --- a/services/server/src/routes/remember.rs +++ b/services/server/src/routes/remember.rs @@ -1057,6 +1057,7 @@ mod tests { port: 8000, database_url: "postgres://test".to_string(), sui_rpc_url: "http://localhost:9000".to_string(), + sui_grpc_url: None, sui_network: "testnet".to_string(), memwal_account_id: None, openai_api_key: Some("test-key".to_string()), diff --git a/services/server/src/storage/sui.rs b/services/server/src/storage/sui.rs index 9891a162..b851cba1 100644 --- a/services/server/src/storage/sui.rs +++ b/services/server/src/storage/sui.rs @@ -1,18 +1,33 @@ +use base64::Engine as _; use serde::Deserialize; /// Verify that a given public key is registered as a delegate key /// in the onchain MemWalAccount object. /// -/// Uses Sui JSON-RPC `sui_getObject` to fetch the object and parse -/// its fields — no full `sui-sdk` dependency needed. +/// Routes through gRPC when `grpc_client` is provided (opt-in via +/// SUI_GRPC_URL, mirrors the sidecar's write-path migration — JSON-RPC +/// sunsets 2026-07-31, and testnet's public JSON-RPC endpoint already +/// returns 404 today), otherwise falls back to the original Sui JSON-RPC +/// `sui_getObject` call below. The gRPC client is built once at startup +/// (AppState) and cloned here — clones share the underlying tonic channel. /// /// Returns `Ok(owner_address)` if the key is found, `Err` otherwise. pub async fn verify_delegate_key_onchain( http_client: &reqwest::Client, rpc_url: &str, + grpc_client: Option<&sui_rpc::Client>, account_object_id: &str, public_key_bytes: &[u8], ) -> Result { + if let Some(grpc_client) = grpc_client { + return verify_delegate_key_onchain_grpc( + grpc_client.clone(), + account_object_id, + public_key_bytes, + ) + .await; + } + // Build JSON-RPC request let body = serde_json::json!({ "jsonrpc": "2.0", @@ -130,6 +145,132 @@ pub async fn verify_delegate_key_onchain( ))) } +// ── gRPC value helpers ── +// google.protobuf.Value (via prost-types) is a dynamic JSON-like tree, not +// serde_json::Value — these navigate it the same way `fields.get(...)` reads +// the parsed JSON-RPC content above, so the two code paths stay structurally +// parallel and easy to compare. +fn grpc_value_as_struct(v: &prost_types::Value) -> Option<&prost_types::Struct> { + match &v.kind { + Some(prost_types::value::Kind::StructValue(s)) => Some(s), + _ => None, + } +} + +fn grpc_value_as_str(v: &prost_types::Value) -> Option<&str> { + match &v.kind { + Some(prost_types::value::Kind::StringValue(s)) => Some(s.as_str()), + _ => None, + } +} + +fn grpc_value_as_bool(v: &prost_types::Value) -> Option { + match &v.kind { + Some(prost_types::value::Kind::BoolValue(b)) => Some(*b), + _ => None, + } +} + +fn grpc_value_as_list(v: &prost_types::Value) -> Option<&[prost_types::Value]> { + match &v.kind { + Some(prost_types::value::Kind::ListValue(l)) => Some(&l.values), + _ => None, + } +} + +/// gRPC counterpart of `verify_delegate_key_onchain` above — same checks +/// (owner, active, delegate_keys membership), fetched via +/// LedgerService.GetObject instead of JSON-RPC's `sui_getObject`. +/// +/// The gRPC `.json` object representation is flatter than JSON-RPC's +/// `.fields` shape and encodes delegate key `public_key` as base64 (not a +/// byte-array) — verified live against real testnet objects while migrating +/// the sidecar and web app to gRPC for this same JSON-RPC sunset. +async fn verify_delegate_key_onchain_grpc( + mut client: sui_rpc::Client, + account_object_id: &str, + public_key_bytes: &[u8], +) -> Result { + let address: sui_sdk_types::Address = account_object_id + .parse() + .map_err(|e| OnchainVerifyError::RpcError(format!("invalid object id: {}", e)))?; + let mut request = sui_rpc::proto::sui::rpc::v2::GetObjectRequest::new(&address); + request.read_mask = Some(prost_types::FieldMask { + paths: vec!["json".to_string()], + }); + + let started = std::time::Instant::now(); + let response = client.ledger_client().get_object(request).await; + let status_label = match &response { + Ok(_) => "200".to_string(), + Err(status) => status.code().to_string(), + }; + crate::observability::observe_external("sui_grpc", "GetObject", &status_label, started.elapsed()); + + let object = response + .map_err(|e| OnchainVerifyError::RpcError(format!("gRPC GetObject failed: {}", e)))? + .into_inner() + .object + .ok_or_else(|| OnchainVerifyError::RpcError("gRPC response missing object".into()))?; + + let json = object + .json + .ok_or_else(|| OnchainVerifyError::RpcError("Object has no json content".into()))?; + let fields = grpc_value_as_struct(&json) + .ok_or_else(|| OnchainVerifyError::RpcError("Object json is not a struct".into()))?; + + let owner = fields + .fields + .get("owner") + .and_then(grpc_value_as_str) + .ok_or_else(|| OnchainVerifyError::RpcError("Missing 'owner' field".into()))? + .to_string(); + + let active = fields + .fields + .get("active") + .and_then(grpc_value_as_bool) + .unwrap_or(true); + if !active { + tracing::warn!( + "account {} is deactivated — rejecting delegate key auth (gRPC)", + account_object_id + ); + return Err(OnchainVerifyError::AccountDeactivated(format!( + "Account {} has been deactivated", + account_object_id + ))); + } + + let delegate_keys = fields + .fields + .get("delegate_keys") + .and_then(grpc_value_as_list) + .ok_or_else(|| OnchainVerifyError::RpcError("Missing 'delegate_keys' field".into()))?; + + for dk in delegate_keys { + let Some(dk_fields) = grpc_value_as_struct(dk) else { + continue; + }; + let Some(stored_b64) = dk_fields.fields.get("public_key").and_then(grpc_value_as_str) else { + continue; + }; + let Ok(stored_bytes) = base64::engine::general_purpose::STANDARD.decode(stored_b64) else { + continue; + }; + if stored_bytes == public_key_bytes { + tracing::info!("delegate key verified onchain (gRPC), owner: {}", owner); + return Ok(owner); + } + } + + Err(OnchainVerifyError::KeyNotFound(format!( + "Public key not found in {} delegate key(s) for account {} (gRPC)", + delegate_keys.len(), + account_object_id + ))) +} + /// Scan the AccountRegistry to find which account holds a given delegate key. /// /// Flow: @@ -295,8 +436,13 @@ pub async fn find_account_by_delegate_key( continue; } - // Fetch the actual MemWalAccount to check delegate_keys - match verify_delegate_key_onchain(http_client, rpc_url, account_id, public_key_bytes) + // Fetch the actual MemWalAccount to check delegate_keys. + // Registry-scan fallback stays JSON-RPC-only for now: gRPC has no + // single-key dynamic-field lookup (only paginated + // ListDynamicFields), and this path only runs when the SDK sent + // no x-account-id hint, which modern SDKs always do — see + // Strategy 2 in resolve_account (auth.rs). + match verify_delegate_key_onchain(http_client, rpc_url, None, account_id, public_key_bytes) .await { Ok(owner) => { @@ -638,4 +784,42 @@ mod tests { Box::new(OnchainVerifyError::AccountDeactivated("test".into())); assert!(err.to_string().contains("deactivated")); } + + // ── gRPC path: live network tests (real testnet, no mocking) ───────── + // `cargo test -- --ignored` to run. Not part of the default suite since + // it needs real network access — but this is exactly how the equivalent + // gRPC shape bugs were caught on the TS side during this same migration + // (guessing from docs got the shape wrong twice; live testing didn't). + + #[tokio::test] + #[ignore] + async fn test_verify_delegate_key_onchain_grpc_real_account() { + // Real MemWalAccount on testnet with a known delegate_keys entry, + // confirmed live while migrating the sidecar/web app to gRPC for + // this same JSON-RPC sunset. + let account_id = "0xfba86e31b07ce36748ffe46de494bd4a2fa0058a5851ec4006141abcc5498fe2"; + let wrong_key = [0u8; 32]; + + let client = sui_rpc::Client::new("https://fullnode.testnet.sui.io").unwrap(); + let result = verify_delegate_key_onchain_grpc(client, account_id, &wrong_key).await; + + // The account genuinely exists and gRPC parses it correctly — a + // non-matching key must fail with KeyNotFound, not RpcError. Getting + // RpcError here means the gRPC request/response shape is wrong, not + // that the key is missing. + match result { + Err(OnchainVerifyError::KeyNotFound(_)) => {} + other => panic!("expected KeyNotFound for a real account with a wrong key, got: {other:?}"), + } + } + + #[tokio::test] + #[ignore] + async fn test_verify_delegate_key_onchain_grpc_missing_object() { + // Object that doesn't exist onchain — must surface as an error, not panic. + let fake_id = "0x0000000000000000000000000000000000000000000000000000000000000001"; + let client = sui_rpc::Client::new("https://fullnode.testnet.sui.io").unwrap(); + let result = verify_delegate_key_onchain_grpc(client, fake_id, &[0u8; 32]).await; + assert!(result.is_err()); + } } diff --git a/services/server/src/types.rs b/services/server/src/types.rs index 246b4005..5e5e017b 100644 --- a/services/server/src/types.rs +++ b/services/server/src/types.rs @@ -74,6 +74,13 @@ pub struct AppState { /// `Arc` so the engine + handlers share one immutable config. pub config: Arc, pub http_client: reqwest::Client, + /// Shared Sui gRPC client for onchain delegate-key verification, built + /// once at startup when `SUI_GRPC_URL` is set (`None` keeps the JSON-RPC + /// path). Constructing `sui_rpc::Client` parses the OS root-cert store + /// and opens a fresh TLS channel, so it must NOT be rebuilt per request — + /// it is `Clone` (shares the underlying tonic channel), mirroring how + /// `http_client` reuses one pooled reqwest client. + pub sui_grpc_client: Option, /// Alert dispatchers for operational notifications. Individual alert /// paths decide when failures are terminal enough to notify. pub alerts: Arc, @@ -186,6 +193,12 @@ pub struct Config { pub port: u16, pub database_url: String, pub sui_rpc_url: String, + /// gRPC endpoint for onchain account/delegate-key verification. When set, + /// verify_delegate_key_onchain uses gRPC instead of JSON-RPC; empty keeps + /// the JSON-RPC path unchanged (mirrors the sidecar's SUI_GRPC_URL opt-in + /// from the write-path gRPC migration — JSON-RPC sunsets 2026-07-31, and + /// testnet's public JSON-RPC endpoint already returns 404). + pub sui_grpc_url: Option, /// network name (mainnet/testnet/devnet). Surfaced via /// `GET /config` so the SDK can select the matching Sui fullnode /// without the user having to configure it. @@ -257,6 +270,10 @@ impl Config { .expect("DATABASE_URL must be set (e.g. postgresql://memwal:memwal_secret@localhost:5432/memwal)"), sui_rpc_url: std::env::var("SUI_RPC_URL") .unwrap_or_else(|_| default_rpc.to_string()), + sui_grpc_url: std::env::var("SUI_GRPC_URL") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), sui_network: network.clone(), memwal_account_id: std::env::var("MEMWAL_ACCOUNT_ID").ok(), openai_api_key: std::env::var("OPENAI_API_KEY").ok(),