From 242d6a2c0bdb498b9c99fedd91ee14f6cea1d479 Mon Sep 17 00:00:00 2001 From: ducnmm <165614309+ducnmm@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:21:19 +0700 Subject: [PATCH 01/11] spike(relayer): gRPC core client for write path via SUI_GRPC_URL (JSON-RPC sunset 2026-07-31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For dev/testnet validation only. Config-gated and OFF by default (SUI_GRPC_URL empty -> unchanged JSON-RPC behaviour), so this is a no-op until the env is set. - config.ts: add SUI_GRPC_URL. - clients.ts: shared write-path suiClient (Walrus/SEAL/Enoki build+certify) uses SuiGrpcClient when SUI_GRPC_URL is set, else JSON-RPC. Keep a dedicated suiJsonRpcClient for the query/restore path (getOwnedObjects / getDynamicFieldObject / suix_queryTransactionBlocks are JSON-RPC-only). getSuiBalanceMist now reads both {totalBalance} (JSON-RPC) and {balance:{balance}} (gRPC) shapes — found via live smoke test. - walrus-query.ts: route index-method reads through suiJsonRpcClient. NOT validated e2e: full Walrus register/certify/get_blob + Enoki tx.build + SEAL decrypt over gRPC need a live relayer — that is what this dev deploy tests. Query path + Rust side JSON-RPC are stage 2. --- services/server/scripts/sidecar/clients.ts | 22 ++++++++++++++++--- services/server/scripts/sidecar/config.ts | 10 +++++++++ .../scripts/sidecar/routes/walrus-query.ts | 10 ++++++--- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/services/server/scripts/sidecar/clients.ts b/services/server/scripts/sidecar/clients.ts index 686cee32..a1b0308d 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. Still required by the blob query/restore path, which uses +// index methods (getOwnedObjects, getDynamicFieldObject, suix_queryTransaction- +// Blocks) that gRPC does not expose — migrating that path to GraphQL is stage 2. +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/routes/walrus-query.ts b/services/server/scripts/sidecar/routes/walrus-query.ts index 0a09aac9..40b1e8a2 100644 --- a/services/server/scripts/sidecar/routes/walrus-query.ts +++ b/services/server/scripts/sidecar/routes/walrus-query.ts @@ -5,7 +5,11 @@ import express, { type Express } from "express"; import { JSON_LIMIT_METADATA, WALRUS_PACKAGE_ID } from "../config.js"; -import { suiClient, suiRpc } from "../clients.js"; +// Query/restore path uses JSON-RPC index methods (getOwnedObjects, +// getDynamicFieldObject, suix_queryTransactionBlocks) that gRPC does not expose, +// so it stays on the JSON-RPC client even when the write path moves to gRPC. +// Migrating this path to GraphQL is stage 2 before the 2026-07-31 sunset. +import { suiJsonRpcClient, suiRpc } from "../clients.js"; import { requestIdFor, sidecarLog } from "../log.js"; import { withRpcRetry } from "../retry/rpc.js"; import { errorMessage, mapConcurrent } from "../util.js"; @@ -187,7 +191,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 }, @@ -235,7 +239,7 @@ export function registerWalrusQueryRoute(app: Express): void { try { const dynField = await withRpcRetry( `[query-blobs] getDynamicField ${obj.objectId}`, - () => suiClient.getDynamicFieldObject({ + () => suiJsonRpcClient.getDynamicFieldObject({ parentId: obj.objectId, name: METADATA_FIELD_NAME, }), From 435d42b46952c977ca87a16ecb9bd5b38a2a7147 Mon Sep 17 00:00:00 2001 From: ducnmm <165614309+ducnmm@users.noreply.github.com> Date: Fri, 3 Jul 2026 20:45:45 +0700 Subject: [PATCH 02/11] fix(relayer): set tx sender before build so gRPC resolves owned objects Under the gRPC client the Walrus certify upload failed at certify_sponsor with 'Transaction was not signed by the correct sender ... given owner/signer 0x0': the SDK's certify tx is built without a sender (register sets its own via setSenderIfNotSet, certify does not), and the gRPC client validates owned-object inputs against the tx sender during build/resolution. Set the sender in the Enoki sponsor path before tx.build; Enoki still sponsors with its own sender and onlyTransactionKind excludes the sender from the bytes, so this is resolution-only and a no-op on JSON-RPC. Surfaced by the dev testnet gRPC relayer test. --- services/server/scripts/sidecar/enoki.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/services/server/scripts/sidecar/enoki.ts b/services/server/scripts/sidecar/enoki.ts index 0d8380c0..2e897fbe 100644 --- a/services/server/scripts/sidecar/enoki.ts +++ b/services/server/scripts/sidecar/enoki.ts @@ -127,6 +127,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, From 90099fe29d6d5519b945e05328bc03c2ccdae901 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Thu, 9 Jul 2026 12:53:24 +0700 Subject: [PATCH 03/11] fix(relayer): resolve digest under gRPC + retry gRPC transient errors Two bugs found while verifying PR #355's SUI_GRPC_URL write path: - enoki.ts direct-sign fallback read `direct.digest` assuming SuiJsonRpcClient's flat SuiTransactionBlockResponse shape. Under SuiGrpcClient, signAndExecuteTransaction resolves to the core-API union `{Transaction: {digest}} | {FailedTransaction: {digest}}` with no top-level `.digest`, so this silently returned undefined once SUI_GRPC_URL was set. Added extractTransactionDigest() to handle both shapes explicitly. - retry/rpc.ts's isRetryableRpcError only matched JSON-RPC-style error text ("429", "503", "timeout"). gRPC errors from SuiGrpcClient carry a status code (RpcError.code) instead, so transient gRPC failures (UNAVAILABLE, RESOURCE_EXHAUSTED, DEADLINE_EXCEEDED, ABORTED) were not retried on the write path. Added a code-based check alongside the existing string matching. Verified both fixes locally: sidecar boots clean on JSON-RPC and gRPC (real mainnet fullnode), getSuiBalanceMist resolves identically on both, and a simulated gRPC transient error now retries and recovers. --- services/server/scripts/sidecar/enoki.ts | 17 +++++++++++++++-- services/server/scripts/sidecar/retry/rpc.ts | 14 ++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/services/server/scripts/sidecar/enoki.ts b/services/server/scripts/sidecar/enoki.ts index 2e897fbe..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/"); } @@ -179,7 +192,7 @@ export async function executeWithEnokiSponsor( signer, transaction: tx, }); - return direct.digest; + return extractTransactionDigest(direct); } let sponsorError: unknown; @@ -211,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") From e89788c020951180a42ece9a98ec989a65a150ef Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Thu, 9 Jul 2026 14:39:24 +0700 Subject: [PATCH 04/11] fix(app): unblock testnet UI flows broken by JSON-RPC outage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not part of PR #355's own diff (services/server/scripts/sidecar/*) — these are pre-existing bugs in apps/app surfaced only because testnet's public JSON-RPC endpoint is returning HTTP 404 right now (confirmed live), ahead of the documented 2026-07-31 sunset. @mysten/dapp-kit's SuiClientProvider defaults every network to JSON-RPC, so every useSuiClient() consumer broke identically once exercised against testnet. - App.tsx: SuiClientProvider now uses a SuiGrpcClient for testnet via the createClient override (mainnet untouched, still JSON-RPC-healthy). - SetupWizard.tsx / Dashboard.tsx: account/delegate-key lookups used JSON-RPC-shaped getObject/getDynamicFieldObject calls (RpcError: INVALID_ARGUMENT under gRPC). Rewritten for gRPC's flatter .json shape and base64-encoded public_key (both verified live against real testnet objects, not guessed from docs). - useSponsoredTransaction.ts: the direct-sign fallback reused the same Transaction object already .build()'d with onlyTransactionKind:true (no sender attached), causing wrong-sender execution (ENotOwner on-chain, reproduced across two different wallets). Now rebuilds via Transaction.fromKind(). Also added an execute() override since dapp-kit's default calls the JSON-RPC-only client.executeTransactionBlock(), which doesn't exist on SuiGrpcClient (core API only has executeTransaction(), different response shape). - vite.config.ts: dev-only proxy for /api, /health, /version, /config, /sponsor to the real dev backend — its CORS allowlist has no localhost entry, so local testing needs a same-origin proxy (no path rewrite, so signed-request signatures stay valid). --- apps/app/src/App.tsx | 19 ++++- apps/app/src/hooks/useSponsoredTransaction.ts | 54 +++++++++++- apps/app/src/pages/Dashboard.tsx | 78 ++++++++--------- apps/app/src/pages/SetupWizard.tsx | 85 +++++++++---------- apps/app/vite.config.ts | 21 +++++ 5 files changed, 168 insertions(+), 89 deletions(-) diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 5e0e0586..686c4cad 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' }, }) +// TEMPORARY LOCAL PATCH (not for upstream): testnet's public JSON-RPC endpoint +// is currently returning HTTP 404 (confirmed live), which breaks every +// useSuiClient() consumer app-wide (account lookups, tx build, sign+execute). +// SuiClientProvider's createClient override lets us swap in a gRPC client for +// testnet only, against the same host, while mainnet (still JSON-RPC-healthy) +// is untouched. A real fix belongs in the shared network config alongside the +// sidecar's PR #355 migration, not as a local override like this. +function createClientForNetwork(name: string, cfg: any) { + if (name === 'testnet') { + return new SuiGrpcClient({ network: 'testnet', baseUrl: 'https://fullnode.testnet.sui.io' }) 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/hooks/useSponsoredTransaction.ts b/apps/app/src/hooks/useSponsoredTransaction.ts index 0851b6b6..d225fd9b 100644 --- a/apps/app/src/hooks/useSponsoredTransaction.ts +++ b/apps/app/src/hooks/useSponsoredTransaction.ts @@ -17,19 +17,59 @@ import { useCurrentAccount, useSignTransaction, useSignAndExecuteTransaction, us import { Transaction } from '@mysten/sui/transactions' import { config } from '../config' +// dapp-kit's default useSignAndExecuteTransaction() execute step calls the +// legacy client.executeTransactionBlock(), which only exists on +// SuiJsonRpcClient — SuiGrpcClient's core API only has executeTransaction(), +// with a different request/response shape (union {Transaction:{digest}} | +// {FailedTransaction:{digest}}, same union already handled for the sidecar's +// direct-sign fallback). Detect at runtime and branch so this works under +// either client the app's SuiClientProvider hands out per network. +async function executeTransactionCompat( + client: any, + { bytes, signature }: { bytes: string; signature: string }, +) { + if (typeof client.executeTransactionBlock === 'function') { + const res = await client.executeTransactionBlock({ + transactionBlock: bytes, + signature, + options: { showRawEffects: true }, + }) + return { digest: res.digest, rawEffects: res.rawEffects } + } + + const txBytes = Uint8Array.from(atob(bytes), (c) => c.charCodeAt(0)) + const res = await client.executeTransaction({ transaction: txBytes, signatures: [signature] }) + const digest = res?.Transaction?.digest ?? res?.FailedTransaction?.digest + if (!digest) throw new Error('executeTransaction: could not resolve digest from result') + return { digest } +} + 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 +116,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/Dashboard.tsx b/apps/app/src/pages/Dashboard.tsx index f9750a66..0f2b3f60 100644 --- a/apps/app/src/pages/Dashboard.tsx +++ b/apps/app/src/pages/Dashboard.tsx @@ -26,13 +26,6 @@ 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' function DelegateKeyCtaIcon(props: SVGProps) { return ( @@ -149,6 +142,24 @@ function bytesToHex(bytes: Uint8Array | number[]): string { return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('') } +// gRPC's `.json` object representation is flatter than JSON-RPC's `.fields` +// shape (verified live against real testnet objects while fixing the same +// bug in SetupWizard.tsx) — accounts.id directly, no nested +// accounts.fields.id.id, and delegate key public_key is a base64 string, not +// a number[]. suiClient.getObject/getDynamicFieldObject below used the +// JSON-RPC-only method shapes, which throw RpcError: INVALID_ARGUMENT once +// the app's testnet client is gRPC (see App.tsx createClientForNetwork). +function addressToBytes32(address: string): Uint8Array { + const clean = address.replace(/^0x/i, '').padStart(64, '0') + return new Uint8Array( + Array.from({ length: clean.length / 2 }, (_, i) => parseInt(clean.slice(i * 2, i * 2 + 2), 16)) + ) +} + +function base64ToBytes(b64: string): number[] { + return Array.from(atob(b64), (c) => c.charCodeAt(0)) +} + function hexToByteArray(hex: string): number[] { const normalized = hex.startsWith('0x') ? hex.slice(2) : hex if (!/^[0-9a-fA-F]{64}$/.test(normalized)) { @@ -327,20 +338,20 @@ export default function Dashboard({ setAccountLookupComplete(false) setLoadingAccount(true) try { - const registryObj = await suiClient.getObject({ - id: config.memwalRegistryId, - options: { showContent: true }, + const registryRes = await (suiClient as any).getObject({ + objectId: config.memwalRegistryId, + include: { json: 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 json = registryRes.object.json as { accounts?: { id?: string } } | null + const tableId = json?.accounts?.id + if (tableId) { + const dynFieldRes = await (suiClient as any).getDynamicField({ + parentId: tableId, + name: { type: 'address', bcs: addressToBytes32(address) }, + }) + const valueBytes = dynFieldRes.dynamicField.value.bcs + if (valueBytes && valueBytes.length === 32) { + setResolvedAccountObjectId('0x' + bytesToHex(valueBytes)) } } } catch (err) { @@ -421,24 +432,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 res = await (suiClient as any).getObject({ objectId: effectiveAccountObjectId, include: { json: true } }) + const json = res.object.json as { delegate_keys?: { public_key?: string; sui_address?: string; label?: string; created_at?: string }[] } | null + if (json?.delegate_keys) { + const parsed: OnChainDelegateKey[] = json.delegate_keys.map((f) => ({ + publicKey: bytesToHex(f.public_key ? base64ToBytes(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..e6fa41dc 100644 --- a/apps/app/src/pages/SetupWizard.tsx +++ b/apps/app/src/pages/SetupWizard.tsx @@ -22,13 +22,6 @@ 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' type Step = 'intro' | 'import-key' | 'generating' | 'show-key' | 'onchain' | 'done' | 'error' @@ -60,55 +53,57 @@ function hexToBytes(hex: string): Uint8Array { ) } +function addressToBytes32(address: string): Uint8Array { + const clean = address.replace(/^0x/i, '').padStart(64, '0') + return hexToBytes(clean) +} + +// App-wide client is now gRPC for testnet (see App.tsx createClientForNetwork). +// gRPC's `.json` representation is flatter than JSON-RPC's `.fields` shape +// (verified live against the real testnet registry object: `accounts.id` +// directly, no nested `accounts.fields.id.id`, and delegate key `public_key` +// is a base64 string, not a `number[]`) — do not reuse getMoveFields' +// JSON-RPC-shaped assumptions here. +function base64ToBytes(b64: string): number[] { + return Array.from(atob(b64), (c) => c.charCodeAt(0)) +} + async function getAccountObjectId(suiClient: ReturnType, ownerAddress: string): Promise { try { - const registryObj = await suiClient.getObject({ - id: config.memwalRegistryId, - options: { showContent: true }, + const registryRes = await (suiClient as any).getObject({ + objectId: config.memwalRegistryId, + include: { json: 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 { + const json = registryRes.object.json as { accounts?: { id?: string } } | null + const tableId = json?.accounts?.id + if (!tableId) return null + + const dynFieldRes = await (suiClient as any).getDynamicField({ + parentId: tableId, + name: { type: 'address', bcs: addressToBytes32(ownerAddress) }, + }) + const valueBytes = dynFieldRes.dynamicField.value.bcs + if (!valueBytes || valueBytes.length !== 32) return null + return '0x' + bytesToHex(valueBytes) + } catch (err) { + console.warn('[getAccountObjectId/grpc-patch] lookup failed, treating as no-account', err) return null } - - return null } +type GrpcAccountJson = { delegate_keys?: { public_key?: string }[] } + 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 res = await (suiClient as any).getObject({ objectId: accountId, include: { json: true } }) + const json = res.object.json as GrpcAccountJson | 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 res = await (suiClient as any).getObject({ objectId: accountId, include: { json: true } }) + const json = res.object.json as GrpcAccountJson | null + if (json?.delegate_keys) { + return json.delegate_keys.map((k) => bytesToHex(k.public_key ? base64ToBytes(k.public_key) : [])) } return [] diff --git a/apps/app/vite.config.ts b/apps/app/vite.config.ts index fa273a9f..c459da6e 100644 --- a/apps/app/vite.config.ts +++ b/apps/app/vite.config.ts @@ -3,6 +3,18 @@ import react from '@vitejs/plugin-react' import wasm from 'vite-plugin-wasm' import topLevelAwait from 'vite-plugin-top-level-await' +// TEMPORARY LOCAL PATCH (not for upstream): the real dev backend's CORS +// allowlist (ALLOWED_ORIGINS) has no localhost entry, so the memwal SDK's +// direct browser calls to relayer.dev.memwal.ai (/health, /version, /api/*, +// /sponsor) are blocked. Proxy them server-side through Vite's dev server — +// no path rewrite, so signed-request signatures (which sign method+path+body, +// not host) stay valid. +const DEV_BACKEND = 'https://relayer.dev.memwal.ai' +const proxyConfig = { + changeOrigin: true, + secure: true, +} + // https://vite.dev/config/ export default defineConfig({ plugins: [react(), wasm(), topLevelAwait()], @@ -12,4 +24,13 @@ export default defineConfig({ optimizeDeps: { include: ['@mysten/seal', '@mysten/sui/transactions', '@mysten/sui/client'], }, + server: { + proxy: { + '/api': { target: DEV_BACKEND, ...proxyConfig }, + '/health': { target: DEV_BACKEND, ...proxyConfig }, + '/version': { target: DEV_BACKEND, ...proxyConfig }, + '/config': { target: DEV_BACKEND, ...proxyConfig }, + '/sponsor': { target: DEV_BACKEND, ...proxyConfig }, + }, + }, }) From 9460e5c72006d6a7de5c12807e778e1603cb1194 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Thu, 9 Jul 2026 15:12:02 +0700 Subject: [PATCH 05/11] fix(server): migrate delegate-key verification off JSON-RPC to gRPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real, live incident: testnet's public JSON-RPC endpoint (fullnode.testnet.sui.io) already returns HTTP 404 today, ahead of the documented 2026-07-31 sunset. verify_delegate_key_onchain (storage/sui.rs) called this endpoint directly via raw sui_getObject JSON-RPC for every signed request's account resolution. Any failure there — including a dead endpoint — gets mapped to a uniform 401 by auth.rs's constant_time_reject() (deliberate: prevents timing oracles between "wrong key" and "account not found"), so the real cause was indistinguishable from bad credentials. In practice this means relayer.dev.memwal.ai currently cannot authenticate any signed request on testnet, regardless of whether the caller's account/delegate key is correct. This is the same JSON-RPC sunset PR #355 addresses for the sidecar's Walrus write path — just in the Rust backend's auth path, which #355 didn't touch. - Config: add SUI_GRPC_URL (opt-in, mirrors the sidecar/web-app pattern — empty keeps the existing JSON-RPC behavior unchanged). - storage/sui.rs: verify_delegate_key_onchain now branches to a gRPC implementation (LedgerService.GetObject via the sui-rpc crate) when SUI_GRPC_URL is set. gRPC's object.json representation is flatter than JSON-RPC's parsed .fields shape and encodes delegate key public_key as base64 (not a byte array) — verified against real, live testnet objects with a real registered delegate key (not guessed from docs, which for this crate were largely unhelpful — see the two live #[ignore] tests). - find_account_by_delegate_key (registry-scan fallback, used only when the caller sends no x-account-id hint) stays JSON-RPC-only: gRPC has no single-key dynamic-field lookup, only paginated ListDynamicFields, and modern SDKs always send the account-id hint, which resolve_account's Strategy 2 checks first — that's the path this fix actually needed to cover for the live incident. Verified: cargo check clean, cargo clippy clean (no new warnings), full test suite passes (294/299 — the 5 failures are pre-existing, unrelated jobs.rs tests needing a local Postgres connection, not present in this environment). The two new #[ignore] tests hit real testnet gRPC and pass. --- services/server/Cargo.lock | 272 ++++++++++++++++++++++++- services/server/Cargo.toml | 9 +- services/server/src/auth.rs | 2 + services/server/src/routes/remember.rs | 1 + services/server/src/storage/sui.rs | 194 +++++++++++++++++- services/server/src/types.rs | 10 + 6 files changed, 481 insertions(+), 7 deletions(-) diff --git a/services/server/Cargo.lock b/services/server/Cargo.lock index bb68e8d3..c6f11f85 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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dedc5658c6ecb3bdb5ef5f3295bb9253f42dcf3fd1402c03f6b1f7659c3c4a9" +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/src/auth.rs b/services/server/src/auth.rs index 6500472d..87360326 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.config.sui_grpc_url.as_deref(), &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.config.sui_grpc_url.as_deref(), exact_account_id, pk_bytes, ) 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..6b6f806d 100644 --- a/services/server/src/storage/sui.rs +++ b/services/server/src/storage/sui.rs @@ -1,18 +1,27 @@ +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_url` is set (opt-in, mirrors the sidecar's +/// SUI_GRPC_URL 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. /// /// 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_url: Option<&str>, account_object_id: &str, public_key_bytes: &[u8], ) -> Result { + if let Some(grpc_url) = grpc_url { + return verify_delegate_key_onchain_grpc(grpc_url, account_object_id, public_key_bytes) + .await; + } + // Build JSON-RPC request let body = serde_json::json!({ "jsonrpc": "2.0", @@ -130,6 +139,135 @@ 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( + grpc_url: &str, + account_object_id: &str, + public_key_bytes: &[u8], +) -> Result { + let mut client = sui_rpc::Client::new(grpc_url) + .map_err(|e| OnchainVerifyError::RpcError(format!("gRPC client init failed: {}", e)))?; + + 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 +433,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 +781,47 @@ 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 result = + verify_delegate_key_onchain_grpc("https://fullnode.testnet.sui.io", 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 result = verify_delegate_key_onchain_grpc( + "https://fullnode.testnet.sui.io", + 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..ee48c588 100644 --- a/services/server/src/types.rs +++ b/services/server/src/types.rs @@ -186,6 +186,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 +263,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(), From bd3261023ebb03628e8fa60e10e9a700eba47308 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Thu, 9 Jul 2026 15:46:48 +0700 Subject: [PATCH 06/11] fix(app): make Sui client shape detection network-agnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review of the prior frontend fix (e89788c) found it introduced a real regression: SetupWizard.tsx and Dashboard.tsx were rewritten to call gRPC's getObject/getDynamicField shapes unconditionally, but App.tsx's createClientForNetwork only swaps testnet to SuiGrpcClient — mainnet still gets SuiJsonRpcClient. Those two files would have broken on mainnet (wrong getObject params, and getDynamicField doesn't exist on SuiJsonRpcClient at all). Separately, ConnectMcp.tsx was missed entirely and still used the original JSON-RPC-only shape, breaking on testnet (the default network) — the exact bug class this whole fix line was meant to close, just in a fourth file nobody looked at. Replaced all three call sites with shared helpers in the new utils/suiClientCompat.ts that duck-type the client (`typeof client.getDynamicField === 'function'`) and branch accordingly, instead of assuming one transport. fetchObjectJson's JSON-RPC branch recursively unwraps Move's {fields: {...}} struct envelopes so both transports return the same flat shape to callers — matches gRPC's .json representation at every nesting level, not just the top one. publicKeyToHex handles both delegate-key encodings (JSON-RPC's number[], gRPC's base64 string). Verified: tsc --noEmit clean, dev server HMR-reloaded all three files with no runtime errors. --- apps/app/src/pages/ConnectMcp.tsx | 20 +----- apps/app/src/pages/Dashboard.tsx | 43 ++---------- apps/app/src/pages/SetupWizard.tsx | 44 ++----------- apps/app/src/utils/suiClientCompat.ts | 95 +++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 91 deletions(-) create mode 100644 apps/app/src/utils/suiClientCompat.ts 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 0f2b3f60..191aa173 100644 --- a/apps/app/src/pages/Dashboard.tsx +++ b/apps/app/src/pages/Dashboard.tsx @@ -26,6 +26,7 @@ import { Card } from '../components/Card' import { SecretValueInput } from '../components/SecretValueInput' import { config } from '../config' import { getAnalyticsErrorType, trackEvent } from '../utils/analytics' +import { fetchAccountIdForOwner, fetchObjectJson, publicKeyToHex } from '../utils/suiClientCompat' function DelegateKeyCtaIcon(props: SVGProps) { return ( @@ -142,24 +143,6 @@ function bytesToHex(bytes: Uint8Array | number[]): string { return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('') } -// gRPC's `.json` object representation is flatter than JSON-RPC's `.fields` -// shape (verified live against real testnet objects while fixing the same -// bug in SetupWizard.tsx) — accounts.id directly, no nested -// accounts.fields.id.id, and delegate key public_key is a base64 string, not -// a number[]. suiClient.getObject/getDynamicFieldObject below used the -// JSON-RPC-only method shapes, which throw RpcError: INVALID_ARGUMENT once -// the app's testnet client is gRPC (see App.tsx createClientForNetwork). -function addressToBytes32(address: string): Uint8Array { - const clean = address.replace(/^0x/i, '').padStart(64, '0') - return new Uint8Array( - Array.from({ length: clean.length / 2 }, (_, i) => parseInt(clean.slice(i * 2, i * 2 + 2), 16)) - ) -} - -function base64ToBytes(b64: string): number[] { - return Array.from(atob(b64), (c) => c.charCodeAt(0)) -} - function hexToByteArray(hex: string): number[] { const normalized = hex.startsWith('0x') ? hex.slice(2) : hex if (!/^[0-9a-fA-F]{64}$/.test(normalized)) { @@ -338,21 +321,9 @@ export default function Dashboard({ setAccountLookupComplete(false) setLoadingAccount(true) try { - const registryRes = await (suiClient as any).getObject({ - objectId: config.memwalRegistryId, - include: { json: true }, - }) - const json = registryRes.object.json as { accounts?: { id?: string } } | null - const tableId = json?.accounts?.id - if (tableId) { - const dynFieldRes = await (suiClient as any).getDynamicField({ - parentId: tableId, - name: { type: 'address', bcs: addressToBytes32(address) }, - }) - const valueBytes = dynFieldRes.dynamicField.value.bcs - if (valueBytes && valueBytes.length === 32) { - setResolvedAccountObjectId('0x' + bytesToHex(valueBytes)) - } + const accountId = await fetchAccountIdForOwner(suiClient, config.memwalRegistryId, address) + if (accountId) { + setResolvedAccountObjectId(accountId) } } catch (err) { console.error('Failed to fetch account object ID:', err) @@ -432,11 +403,11 @@ export default function Dashboard({ if (!effectiveAccountObjectId) return setLoadingKeys(true) try { - const res = await (suiClient as any).getObject({ objectId: effectiveAccountObjectId, include: { json: true } }) - const json = res.object.json as { delegate_keys?: { public_key?: string; sui_address?: string; label?: string; created_at?: string }[] } | null + 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: bytesToHex(f.public_key ? base64ToBytes(f.public_key) : []), + publicKey: publicKeyToHex(f.public_key), suiAddress: f.sui_address ?? '', label: f.label ?? '', createdAt: Number(f.created_at ?? 0), diff --git a/apps/app/src/pages/SetupWizard.tsx b/apps/app/src/pages/SetupWizard.tsx index e6fa41dc..0a5ef2bd 100644 --- a/apps/app/src/pages/SetupWizard.tsx +++ b/apps/app/src/pages/SetupWizard.tsx @@ -22,6 +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 { fetchAccountIdForOwner, fetchObjectJson, publicKeyToHex } from '../utils/suiClientCompat' type Step = 'intro' | 'import-key' | 'generating' | 'show-key' | 'onchain' | 'done' | 'error' @@ -53,57 +54,26 @@ function hexToBytes(hex: string): Uint8Array { ) } -function addressToBytes32(address: string): Uint8Array { - const clean = address.replace(/^0x/i, '').padStart(64, '0') - return hexToBytes(clean) -} - -// App-wide client is now gRPC for testnet (see App.tsx createClientForNetwork). -// gRPC's `.json` representation is flatter than JSON-RPC's `.fields` shape -// (verified live against the real testnet registry object: `accounts.id` -// directly, no nested `accounts.fields.id.id`, and delegate key `public_key` -// is a base64 string, not a `number[]`) — do not reuse getMoveFields' -// JSON-RPC-shaped assumptions here. -function base64ToBytes(b64: string): number[] { - return Array.from(atob(b64), (c) => c.charCodeAt(0)) -} - async function getAccountObjectId(suiClient: ReturnType, ownerAddress: string): Promise { try { - const registryRes = await (suiClient as any).getObject({ - objectId: config.memwalRegistryId, - include: { json: true }, - }) - const json = registryRes.object.json as { accounts?: { id?: string } } | null - const tableId = json?.accounts?.id - if (!tableId) return null - - const dynFieldRes = await (suiClient as any).getDynamicField({ - parentId: tableId, - name: { type: 'address', bcs: addressToBytes32(ownerAddress) }, - }) - const valueBytes = dynFieldRes.dynamicField.value.bcs - if (!valueBytes || valueBytes.length !== 32) return null - return '0x' + bytesToHex(valueBytes) + return await fetchAccountIdForOwner(suiClient, config.memwalRegistryId, ownerAddress) } catch (err) { - console.warn('[getAccountObjectId/grpc-patch] lookup failed, treating as no-account', err) + console.warn('[getAccountObjectId] lookup failed, treating as no-account', err) return null } } -type GrpcAccountJson = { delegate_keys?: { public_key?: string }[] } +type AccountJson = { delegate_keys?: { public_key?: unknown }[] } async function getDelegateKeyCount(suiClient: ReturnType, accountId: string): Promise { - const res = await (suiClient as any).getObject({ objectId: accountId, include: { json: true } }) - const json = res.object.json as GrpcAccountJson | null + const json = await fetchObjectJson(suiClient, accountId) as AccountJson | null return json?.delegate_keys?.length ?? 0 } async function getRegisteredDelegatePublicKeys(suiClient: ReturnType, accountId: string): Promise { - const res = await (suiClient as any).getObject({ objectId: accountId, include: { json: true } }) - const json = res.object.json as GrpcAccountJson | null + const json = await fetchObjectJson(suiClient, accountId) as AccountJson | null if (json?.delegate_keys) { - return json.delegate_keys.map((k) => bytesToHex(k.public_key ? base64ToBytes(k.public_key) : [])) + 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..4db4a02c --- /dev/null +++ b/apps/app/src/utils/suiClientCompat.ts @@ -0,0 +1,95 @@ +/** + * Sui client compatibility layer — App.tsx's SuiClientProvider hands out a + * SuiGrpcClient for testnet (SUI_GRPC_URL opt-in, testnet's public JSON-RPC + * endpoint currently returns 404) and a SuiJsonRpcClient for mainnet/other + * networks. The two have different getObject/dynamic-field method shapes — + * these helpers branch on which one is actually present so callers work + * correctly under either, instead of assuming one shape unconditionally. + */ + +function isGrpcClient(suiClient: unknown): boolean { + return typeof (suiClient as { getDynamicField?: unknown })?.getDynamicField === 'function' +} + +function hexToBytes32(address: string): Uint8Array { + const clean = address.replace(/^0x/i, '').padStart(64, '0') + return new Uint8Array( + Array.from({ length: clean.length / 2 }, (_, i) => parseInt(clean.slice(i * 2, i * 2 + 2), 16)) + ) +} + +function bytesToHex(bytes: Uint8Array | number[]): string { + return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('') +} + +function base64ToBytes(b64: string): number[] { + return Array.from(atob(b64), (c) => c.charCodeAt(0)) +} + +// 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 +} + +/** Resolve a MemWalAccount object ID for `ownerAddress` via the registry's Table. */ +export async function fetchAccountIdForOwner( + suiClient: unknown, + registryId: string, + ownerAddress: string, +): Promise { + const registryJson = await fetchObjectJson(suiClient, registryId) + const tableId = (registryJson?.accounts as { id?: string } | undefined)?.id + if (!tableId) return null + + if (isGrpcClient(suiClient)) { + const dynFieldRes = await (suiClient as any).getDynamicField({ + parentId: tableId, + name: { type: 'address', bcs: hexToBytes32(ownerAddress) }, + }) + const valueBytes = dynFieldRes?.dynamicField?.value?.bcs + if (!valueBytes || valueBytes.length !== 32) return null + return '0x' + bytesToHex(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 bytesToHex(base64ToBytes(publicKey)) + if (Array.isArray(publicKey)) return bytesToHex(publicKey as number[]) + return '' +} From 09056c5e40003e3ca7ff619c74cc23480f52b2ae Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Thu, 9 Jul 2026 19:35:16 +0700 Subject: [PATCH 07/11] fix(app): make gRPC client and dev proxy config-driven, not hardcoded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were explicitly marked "TEMPORARY LOCAL PATCH (not for upstream)" — resolving that before this could reasonably be considered for merge. - config.ts / App.tsx: VITE_SUI_GRPC_URL (opt-in, empty keeps JSON-RPC unchanged) replaces the hardcoded testnet-only fullnode URL. createClientForNetwork now applies to whichever network is actually configured (config.suiNetwork), not a hardcoded 'testnet' string. - vite.config.ts: DEV_BACKEND_PROXY_TARGET (opt-in, unset by default — no proxy configured, matching upstream's actual behavior) replaces the hardcoded relayer.dev.memwal.ai target. Uses loadEnv() since vite.config.ts needs .env.local values in its own Node-side execution, not just the client bundle. Verified: tsc --noEmit clean, dev server boots clean with the new env-driven config (.env.local updated to set VITE_SUI_GRPC_URL explicitly). --- apps/app/src/App.tsx | 18 ++++++------ apps/app/src/config.ts | 4 +++ apps/app/vite.config.ts | 63 ++++++++++++++++++++++------------------- 3 files changed, 47 insertions(+), 38 deletions(-) diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 686c4cad..57ca2941 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -41,16 +41,16 @@ const { networkConfig } = createNetworkConfig({ mainnet: { url: getJsonRpcFullnodeUrl('mainnet'), network: 'mainnet' }, }) -// TEMPORARY LOCAL PATCH (not for upstream): testnet's public JSON-RPC endpoint -// is currently returning HTTP 404 (confirmed live), which breaks every -// useSuiClient() consumer app-wide (account lookups, tx build, sign+execute). -// SuiClientProvider's createClient override lets us swap in a gRPC client for -// testnet only, against the same host, while mainnet (still JSON-RPC-healthy) -// is untouched. A real fix belongs in the shared network config alongside the -// sidecar's PR #355 migration, not as a local override like this. +// 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 === 'testnet') { - return new SuiGrpcClient({ network: 'testnet', baseUrl: 'https://fullnode.testnet.sui.io' }) as unknown as SuiJsonRpcClient + if (name === config.suiNetwork && config.suiGrpcUrl) { + return new SuiGrpcClient({ network: name, baseUrl: config.suiGrpcUrl }) as unknown as SuiJsonRpcClient } return new SuiJsonRpcClient(cfg) } 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/vite.config.ts b/apps/app/vite.config.ts index c459da6e..aaea15f8 100644 --- a/apps/app/vite.config.ts +++ b/apps/app/vite.config.ts @@ -1,36 +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' -// TEMPORARY LOCAL PATCH (not for upstream): the real dev backend's CORS -// allowlist (ALLOWED_ORIGINS) has no localhost entry, so the memwal SDK's -// direct browser calls to relayer.dev.memwal.ai (/health, /version, /api/*, -// /sponsor) are blocked. Proxy them server-side through Vite's dev server — -// no path rewrite, so signed-request signatures (which sign method+path+body, -// not host) stay valid. -const DEV_BACKEND = 'https://relayer.dev.memwal.ai' -const proxyConfig = { - changeOrigin: true, - secure: true, -} - // 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'], - }, - server: { - proxy: { - '/api': { target: DEV_BACKEND, ...proxyConfig }, - '/health': { target: DEV_BACKEND, ...proxyConfig }, - '/version': { target: DEV_BACKEND, ...proxyConfig }, - '/config': { target: DEV_BACKEND, ...proxyConfig }, - '/sponsor': { target: DEV_BACKEND, ...proxyConfig }, +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, }, - }, + } }) From 5f7775fc29c92870989ce0c2470cc539e1721c17 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Thu, 9 Jul 2026 19:46:10 +0700 Subject: [PATCH 08/11] refactor: apply /simplify review findings across the gRPC fix commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four parallel cleanup reviews (reuse, simplification, efficiency, altitude) over the fix commits; net -21 lines. Fixes applied: - [efficiency, HIGH] services/server: sui_rpc::Client was constructed on every auth verification — sui_rpc's Client::new parses the OS root-cert store and opens a fresh TLS channel per call, on the per-request auth hot path, the opposite of how the JSON-RPC path reuses the pooled reqwest::Client. Now built once at startup into AppState (fails fast on a bad SUI_GRPC_URL) and cloned per request — clones share the tonic channel. Both live testnet gRPC tests still pass against the refactored path. - [efficiency] suiClientCompat.fetchAccountIdForOwner: the registry's inner Table ID is an immutable on-chain constant but was re-fetched on every account lookup — now memoized per registryId, halving round trips on repeat lookups. - [reuse] suiClientCompat: dropped all hand-rolled hex/base64 encoders for toHex/fromHex/fromBase64/normalizeSuiAddress from @mysten/sui/utils (already a dependency). - [reuse+altitude] useSponsoredTransaction: executeTransactionCompat moved into suiClientCompat next to the other transport-compat helpers, so the app has one shared isGrpcClient discriminator instead of two divergent method-presence heuristics in two files, and one base64 decode instead of a second inline atob copy. - [simplification] deleted apps/app/src/utils/suiFields.ts — dead after the compat refactor removed its last importer, and its typed shapes had already drifted from the real on-chain encodings (public_key: number[] only, while gRPC returns base64 strings). - [review nits] fixed the stale "testnet-only" header comment in suiClientCompat; publicKeyToHex now warns on an unrecognized encoding instead of silently returning ''. Skipped (reviewed, deliberately not changed): macro-consolidating the four 3-line grpc_value_as_* accessors (idiomatic as-is); a transport trait for verify_delegate_key_onchain (no such convention in storage/*, and the registry-scan fallback genuinely needs per-call-site transport choice); page-local bytesToHex/hexToBytes copies still in live use for key derivation (pre-existing, out of diff scope). Verified: cargo check --tests clean, clippy no new warnings, live gRPC tests pass, tsc --noEmit clean, dev server HMR-reloads without errors. --- apps/app/src/hooks/useSponsoredTransaction.ts | 28 +------ apps/app/src/utils/suiClientCompat.ts | 83 ++++++++++++------- apps/app/src/utils/suiFields.ts | 39 --------- services/server/src/auth.rs | 4 +- services/server/src/main.rs | 12 +++ services/server/src/storage/sui.rs | 40 +++++---- services/server/src/types.rs | 7 ++ 7 files changed, 96 insertions(+), 117 deletions(-) delete mode 100644 apps/app/src/utils/suiFields.ts diff --git a/apps/app/src/hooks/useSponsoredTransaction.ts b/apps/app/src/hooks/useSponsoredTransaction.ts index d225fd9b..cdf88372 100644 --- a/apps/app/src/hooks/useSponsoredTransaction.ts +++ b/apps/app/src/hooks/useSponsoredTransaction.ts @@ -16,33 +16,7 @@ import { useCurrentAccount, useSignTransaction, useSignAndExecuteTransaction, useSuiClient } from '@mysten/dapp-kit' import { Transaction } from '@mysten/sui/transactions' import { config } from '../config' - -// dapp-kit's default useSignAndExecuteTransaction() execute step calls the -// legacy client.executeTransactionBlock(), which only exists on -// SuiJsonRpcClient — SuiGrpcClient's core API only has executeTransaction(), -// with a different request/response shape (union {Transaction:{digest}} | -// {FailedTransaction:{digest}}, same union already handled for the sidecar's -// direct-sign fallback). Detect at runtime and branch so this works under -// either client the app's SuiClientProvider hands out per network. -async function executeTransactionCompat( - client: any, - { bytes, signature }: { bytes: string; signature: string }, -) { - if (typeof client.executeTransactionBlock === 'function') { - const res = await client.executeTransactionBlock({ - transactionBlock: bytes, - signature, - options: { showRawEffects: true }, - }) - return { digest: res.digest, rawEffects: res.rawEffects } - } - - const txBytes = Uint8Array.from(atob(bytes), (c) => c.charCodeAt(0)) - const res = await client.executeTransaction({ transaction: txBytes, signatures: [signature] }) - const digest = res?.Transaction?.digest ?? res?.FailedTransaction?.digest - if (!digest) throw new Error('executeTransaction: could not resolve digest from result') - return { digest } -} +import { executeTransactionCompat } from '../utils/suiClientCompat' export function useSponsoredTransaction() { const currentAccount = useCurrentAccount() diff --git a/apps/app/src/utils/suiClientCompat.ts b/apps/app/src/utils/suiClientCompat.ts index 4db4a02c..9166a669 100644 --- a/apps/app/src/utils/suiClientCompat.ts +++ b/apps/app/src/utils/suiClientCompat.ts @@ -1,29 +1,21 @@ /** * Sui client compatibility layer — App.tsx's SuiClientProvider hands out a - * SuiGrpcClient for testnet (SUI_GRPC_URL opt-in, testnet's public JSON-RPC - * endpoint currently returns 404) and a SuiJsonRpcClient for mainnet/other - * networks. The two have different getObject/dynamic-field method shapes — - * these helpers branch on which one is actually present so callers work - * correctly under either, instead of assuming one shape unconditionally. + * 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. */ -function isGrpcClient(suiClient: unknown): boolean { - return typeof (suiClient as { getDynamicField?: unknown })?.getDynamicField === 'function' -} +import { fromBase64, fromHex, normalizeSuiAddress, toHex } from '@mysten/sui/utils' -function hexToBytes32(address: string): Uint8Array { - const clean = address.replace(/^0x/i, '').padStart(64, '0') - return new Uint8Array( - Array.from({ length: clean.length / 2 }, (_, i) => parseInt(clean.slice(i * 2, i * 2 + 2), 16)) - ) -} - -function bytesToHex(bytes: Uint8Array | number[]): string { - return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('') -} - -function base64ToBytes(b64: string): number[] { - return Array.from(atob(b64), (c) => c.charCodeAt(0)) +/** + * 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 @@ -57,24 +49,32 @@ export async function fetchObjectJson(suiClient: unknown, objectId: string): Pro 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 { - const registryJson = await fetchObjectJson(suiClient, registryId) - const tableId = (registryJson?.accounts as { id?: string } | undefined)?.id - if (!tableId) return null + 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: hexToBytes32(ownerAddress) }, + name: { type: 'address', bcs: fromHex(normalizeSuiAddress(ownerAddress)) }, }) const valueBytes = dynFieldRes?.dynamicField?.value?.bcs if (!valueBytes || valueBytes.length !== 32) return null - return '0x' + bytesToHex(valueBytes) + return '0x' + toHex(valueBytes) } const dynField = await (suiClient as any).getDynamicFieldObject({ @@ -89,7 +89,34 @@ export async function fetchAccountIdForOwner( /** 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 bytesToHex(base64ToBytes(publicKey)) - if (Array.isArray(publicKey)) return bytesToHex(publicKey as number[]) + 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/services/server/src/auth.rs b/services/server/src/auth.rs index 87360326..feade17a 100644 --- a/services/server/src/auth.rs +++ b/services/server/src/auth.rs @@ -295,7 +295,7 @@ async fn resolve_account( match verify_delegate_key_onchain( &state.http_client, &state.config.sui_rpc_url, - state.config.sui_grpc_url.as_deref(), + state.sui_grpc_client.as_ref(), &cached_account_id, pk_bytes, ) @@ -332,7 +332,7 @@ async fn resolve_account( let owner = verify_delegate_key_onchain( &state.http_client, &state.config.sui_rpc_url, - state.config.sui_grpc_url.as_deref(), + 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/storage/sui.rs b/services/server/src/storage/sui.rs index 6b6f806d..b851cba1 100644 --- a/services/server/src/storage/sui.rs +++ b/services/server/src/storage/sui.rs @@ -4,22 +4,28 @@ use serde::Deserialize; /// Verify that a given public key is registered as a delegate key /// in the onchain MemWalAccount object. /// -/// Routes through gRPC when `grpc_url` is set (opt-in, mirrors the sidecar's -/// SUI_GRPC_URL 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. +/// 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_url: Option<&str>, + grpc_client: Option<&sui_rpc::Client>, account_object_id: &str, public_key_bytes: &[u8], ) -> Result { - if let Some(grpc_url) = grpc_url { - return verify_delegate_key_onchain_grpc(grpc_url, account_object_id, public_key_bytes) - .await; + 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 @@ -181,13 +187,10 @@ fn grpc_value_as_list(v: &prost_types::Value) -> Option<&[prost_types::Value]> { /// 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( - grpc_url: &str, + mut client: sui_rpc::Client, account_object_id: &str, public_key_bytes: &[u8], ) -> Result { - let mut client = sui_rpc::Client::new(grpc_url) - .map_err(|e| OnchainVerifyError::RpcError(format!("gRPC client init failed: {}", e)))?; - let address: sui_sdk_types::Address = account_object_id .parse() .map_err(|e| OnchainVerifyError::RpcError(format!("invalid object id: {}", e)))?; @@ -797,9 +800,8 @@ mod tests { let account_id = "0xfba86e31b07ce36748ffe46de494bd4a2fa0058a5851ec4006141abcc5498fe2"; let wrong_key = [0u8; 32]; - let result = - verify_delegate_key_onchain_grpc("https://fullnode.testnet.sui.io", account_id, &wrong_key) - .await; + 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 @@ -816,12 +818,8 @@ mod tests { 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 result = verify_delegate_key_onchain_grpc( - "https://fullnode.testnet.sui.io", - fake_id, - &[0u8; 32], - ) - .await; + 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 ee48c588..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, From a837e96aec4e485291298f52e05e041522641cb7 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Thu, 9 Jul 2026 22:37:40 +0700 Subject: [PATCH 09/11] fix(server): pin roaring to 0.11.3 (0.11.4 requires rustc 1.90, image has 1.88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Railway build failure: sui-sdk-types (transitive via sui-rpc) pulled in roaring 0.11.4, which requires rustc >=1.90.0. The Dockerfile pins rust:1.88-bookworm, so the build failed at `cargo build --release` before reaching any of our own code: error: rustc 1.88.0 is not supported by the following package: roaring@0.11.4 requires rustc 1.90.0 0.11.3 requires rustc 1.82.0 (compatible) and is otherwise identical for our purposes (roaring is a transitive dep neither our code nor sui-rpc/ sui-sdk-types code paths we hit exercise directly). Not verifiable via local `docker build` on this machine: the pinned linux/amd64 base image is emulated via QEMU on Apple Silicon, and QEMU itself segfaults on rustc invocation before any dependency compiles (pre-existing environment issue, unrelated to this fix — same crash occurs on the pre-fix Cargo.lock too). `cargo update -p roaring --precise 0.11.3` + `cargo check` locally (rustc 1.92) confirms the resolution is valid; the actual rustc-1.88 constraint can only be verified by Railway's real amd64 build. --- services/server/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/server/Cargo.lock b/services/server/Cargo.lock index c6f11f85..98e0d121 100644 --- a/services/server/Cargo.lock +++ b/services/server/Cargo.lock @@ -2091,9 +2091,9 @@ dependencies = [ [[package]] name = "roaring" -version = "0.11.4" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dedc5658c6ecb3bdb5ef5f3295bb9253f42dcf3fd1402c03f6b1f7659c3c4a9" +checksum = "8ba9ce64a8f45d7fc86358410bb1a82e8c987504c0d4900e9141d69a9f26c885" dependencies = [ "bytemuck", "byteorder", From c7f4b93375a3e41ca980a83df779f5302439d2b6 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Thu, 9 Jul 2026 23:07:07 +0700 Subject: [PATCH 10/11] fix(app): pass VITE_SUI_GRPC_URL through as a Docker build arg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dockerfile only declared ARG/ENV for a fixed list of VITE_ vars, so Railway's VITE_SUI_GRPC_URL service variable never reached the Vite build — the bundle always baked in an empty value and silently fell back to the dead JSON-RPC client. --- apps/app/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) 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 From 01a05e159d77d5eaeabad6a3a0171e831a3ef9cb Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Fri, 10 Jul 2026 15:08:13 +0700 Subject: [PATCH 11/11] fix(sidecar): migrate blob query/restore path off JSON-RPC to gRPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The restore path still called suix_queryTransactionBlocks and getDynamicFieldObject over JSON-RPC, which testnet no longer serves — POST /api/restore 500'd with 'returned non-JSON (404)'. The stage-2 GraphQL note is obsolete: the current SDK's gRPC client covers this with listOwnedObjects (server-side type filter + json content) and getDynamicField. With SUI_GRPC_URL set, one paginated listOwnedObjects call replaces both the transaction scan and multiGetObjects, and blob metadata is BCS-decoded from getDynamicField (Metadata = VecMap). Unset, the original JSON-RPC path still runs — same reversible opt-in as the write path. Verified against live testnet gRPC: real account returns all blobs with correct memwal_* metadata; namespace filtering, full-scan and empty-owner paths OK; 78/78 unit tests pass. --- services/server/scripts/sidecar/clients.ts | 6 +- .../scripts/sidecar/routes/walrus-query.ts | 149 ++++++++++++++---- 2 files changed, 119 insertions(+), 36 deletions(-) diff --git a/services/server/scripts/sidecar/clients.ts b/services/server/scripts/sidecar/clients.ts index a1b0308d..6a893380 100644 --- a/services/server/scripts/sidecar/clients.ts +++ b/services/server/scripts/sidecar/clients.ts @@ -25,9 +25,9 @@ import { } from "./config.js"; import { shortAddress } from "./util.js"; -// JSON-RPC client. Still required by the blob query/restore path, which uses -// index methods (getOwnedObjects, getDynamicFieldObject, suix_queryTransaction- -// Blocks) that gRPC does not expose — migrating that path to GraphQL is stage 2. +// 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, diff --git a/services/server/scripts/sidecar/routes/walrus-query.ts b/services/server/scripts/sidecar/routes/walrus-query.ts index 40b1e8a2..3b210548 100644 --- a/services/server/scripts/sidecar/routes/walrus-query.ts +++ b/services/server/scripts/sidecar/routes/walrus-query.ts @@ -4,12 +4,15 @@ */ import express, { type Express } from "express"; +import { bcs } from "@mysten/sui/bcs"; import { JSON_LIMIT_METADATA, WALRUS_PACKAGE_ID } from "../config.js"; -// Query/restore path uses JSON-RPC index methods (getOwnedObjects, -// getDynamicFieldObject, suix_queryTransactionBlocks) that gRPC does not expose, -// so it stays on the JSON-RPC client even when the write path moves to gRPC. -// Migrating this path to GraphQL is stage 2 before the 2026-07-31 sunset. -import { suiJsonRpcClient, 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"; @@ -162,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 { @@ -179,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( @@ -215,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; @@ -237,28 +337,11 @@ export function registerWalrusQueryRoute(app: Express): void { let blobAgentId = ""; try { - const dynField = await withRpcRetry( - `[query-blobs] getDynamicField ${obj.objectId}`, - () => suiJsonRpcClient.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