Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/app/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
19 changes: 17 additions & 2 deletions apps/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -40,6 +41,20 @@ const { networkConfig } = createNetworkConfig({
mainnet: { url: getJsonRpcFullnodeUrl('mainnet'), network: 'mainnet' },
})

// Opt-in gRPC client for the active network (VITE_SUI_GRPC_URL), mirroring the
// sidecar's SUI_GRPC_URL migration (services/server/scripts/sidecar/config.ts)
// for the same JSON-RPC sunset (2026-07-31; testnet's public JSON-RPC endpoint
// already returns 404 today). Empty keeps the existing JSON-RPC client
// unchanged. Every useSuiClient() consumer (account lookups, tx build,
// sign+execute) must handle both client shapes — see utils/suiClientCompat.ts
// and useSponsoredTransaction.ts's executeTransactionCompat.
function createClientForNetwork(name: string, cfg: any) {
if (name === config.suiNetwork && config.suiGrpcUrl) {
return new SuiGrpcClient({ network: name, baseUrl: config.suiGrpcUrl }) as unknown as SuiJsonRpcClient
}
return new SuiJsonRpcClient(cfg)
}

const queryClient = new QueryClient()

// ============================================================
Expand Down Expand Up @@ -282,7 +297,7 @@ export default function App() {
<BrowserRouter>
<AnalyticsTracker />
<QueryClientProvider client={queryClient}>
<SuiClientProvider networks={networkConfig} defaultNetwork={config.suiNetwork}>
<SuiClientProvider networks={networkConfig} defaultNetwork={config.suiNetwork} createClient={createClientForNetwork}>
<RegisterEnokiWallets />
<WalletProvider autoConnect>
<DelegateKeyProvider>
Expand Down
4 changes: 4 additions & 0 deletions apps/app/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
28 changes: 24 additions & 4 deletions apps/app/src/hooks/useSponsoredTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,34 @@
import { useCurrentAccount, useSignTransaction, useSignAndExecuteTransaction, useSuiClient } from '@mysten/dapp-kit'
import { Transaction } from '@mysten/sui/transactions'
import { config } from '../config'
import { executeTransactionCompat } from '../utils/suiClientCompat'

export function useSponsoredTransaction() {
const currentAccount = useCurrentAccount()
const suiClient = useSuiClient()
const { mutateAsync: signTransaction } = useSignTransaction()
const { mutateAsync: directSignAndExecute } = useSignAndExecuteTransaction()
const { mutateAsync: directSignAndExecute } = useSignAndExecuteTransaction({
execute: (args) => executeTransactionCompat(suiClient, args),
})

const mutateAsync = async ({ transaction }: { transaction: Transaction }): Promise<{ digest: string }> => {
const sender = currentAccount?.address
if (!sender) throw new Error('No wallet connected')

// Track the already-built TransactionKind bytes so the catch block can
// rebuild a FRESH Transaction (Transaction.fromKind) instead of reusing
// `transaction` itself. `transaction.build({onlyTransactionKind:true})`
// resolves/caches the plan with no sender attached (transaction-kind
// bytes exclude sender by design); reusing that same already-built
// object in directSignAndExecute below does not correctly re-resolve
// against the real signer, which silently executed as ENotOwner in
// testing (assert! account.owner == ctx.sender() in add_delegate_key)
// instead of the connected wallet's actual address.
let kindBytes: Uint8Array | undefined

try {
// 1. Build TransactionKind bytes (without gas data)
const kindBytes = await transaction.build({
kindBytes = await transaction.build({
client: suiClient,
onlyTransactionKind: true,
})
Expand Down Expand Up @@ -76,9 +90,15 @@ export function useSponsoredTransaction() {
console.log(`[sponsored-tx] success, digest=${result.digest}`)
return { digest: result.digest }
} catch (err) {
// Fallback: try direct signing if sponsor fails
// Fallback: try direct signing if sponsor fails.
// Rebuild a fresh Transaction from the TransactionKind bytes (if we
// got that far) instead of reusing the original `transaction` — it
// was already built with onlyTransactionKind:true (no sender), and
// reusing that resolved/cached plan here does not correctly
// re-resolve ownership against the actual connected signer.
console.warn('[sponsored-tx] sponsor failed, falling back to direct signing:', err)
const result = await directSignAndExecute({ transaction })
const fallbackTx = kindBytes ? Transaction.fromKind(kindBytes) : transaction
const result = await directSignAndExecute({ transaction: fallbackTx })
return { digest: result.digest }
}
}
Expand Down
20 changes: 2 additions & 18 deletions apps/app/src/pages/ConnectMcp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -65,26 +65,10 @@ async function resolveAccountId(
ownerAddress: string,
): Promise<string | null> {
try {
const registryObj = await suiClient.getObject({
id: config.memwalRegistryId,
options: { showContent: true },
})
const fields = getMoveFields<RegistryObjectFields>(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<DynamicFieldObjectFields>(dynField?.data?.content)
if (dynFields?.value) return dynFields.value
}
}
return await fetchAccountIdForOwner(suiClient, config.memwalRegistryId, ownerAddress)
} catch {
return null
}
return null
}

interface McpCallbackPayload {
Expand Down
53 changes: 13 additions & 40 deletions apps/app/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,7 @@ import { Card } from '../components/Card'
import { SecretValueInput } from '../components/SecretValueInput'
import { config } from '../config'
import { getAnalyticsErrorType, trackEvent } from '../utils/analytics'
import {
getDelegateKeyFields,
getMoveFields,
type AccountObjectFields,
type DynamicFieldObjectFields,
type RegistryObjectFields,
} from '../utils/suiFields'
import { fetchAccountIdForOwner, fetchObjectJson, publicKeyToHex } from '../utils/suiClientCompat'

function DelegateKeyCtaIcon(props: SVGProps<SVGSVGElement>) {
return (
Expand Down Expand Up @@ -327,21 +321,9 @@ export default function Dashboard({
setAccountLookupComplete(false)
setLoadingAccount(true)
try {
const registryObj = await suiClient.getObject({
id: config.memwalRegistryId,
options: { showContent: true },
})
const fields = getMoveFields<RegistryObjectFields>(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<DynamicFieldObjectFields>(dynField?.data?.content)
if (dynFields?.value) setResolvedAccountObjectId(dynFields.value)
}
const accountId = await fetchAccountIdForOwner(suiClient, config.memwalRegistryId, address)
if (accountId) {
setResolvedAccountObjectId(accountId)
}
} catch (err) {
console.error('Failed to fetch account object ID:', err)
Expand Down Expand Up @@ -421,24 +403,15 @@ export default function Dashboard({
if (!effectiveAccountObjectId) return
setLoadingKeys(true)
try {
const obj = await suiClient.getObject({
id: effectiveAccountObjectId,
options: { showContent: true },
})
const fields = getMoveFields<AccountObjectFields>(obj?.data?.content)
if (fields) {
const keys = fields.delegate_keys ?? []
const parsed: OnChainDelegateKey[] = keys.map((k) => {
const f = getDelegateKeyFields(k)
const pkBytes: number[] = f.public_key ?? []
const pkHex = pkBytes.map((b: number) => b.toString(16).padStart(2, '0')).join('')
return {
publicKey: pkHex,
suiAddress: f.sui_address ?? '',
label: f.label ?? '',
createdAt: Number(f.created_at ?? 0),
}
})
const json = await fetchObjectJson(suiClient, effectiveAccountObjectId) as
{ delegate_keys?: { public_key?: unknown; sui_address?: string; label?: string; created_at?: string }[] } | null
if (json?.delegate_keys) {
const parsed: OnChainDelegateKey[] = json.delegate_keys.map((f) => ({
publicKey: publicKeyToHex(f.public_key),
suiAddress: f.sui_address ?? '',
label: f.label ?? '',
createdAt: Number(f.created_at ?? 0),
}))
setOnChainKeys(parsed)
} else {
setOnChainKeys([])
Expand Down
57 changes: 11 additions & 46 deletions apps/app/src/pages/SetupWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,7 @@ import { Link, useNavigate } from 'react-router-dom'
import { LogOut, Copy, TriangleAlert } from 'lucide-react'
import { config } from '../config'
import { getAnalyticsErrorType, trackEvent } from '../utils/analytics'
import {
getDelegateKeyFields,
getMoveFields,
type AccountObjectFields,
type DynamicFieldObjectFields,
type RegistryObjectFields,
} from '../utils/suiFields'
import { fetchAccountIdForOwner, fetchObjectJson, publicKeyToHex } from '../utils/suiClientCompat'

type Step = 'intro' | 'import-key' | 'generating' | 'show-key' | 'onchain' | 'done' | 'error'

Expand Down Expand Up @@ -62,53 +56,24 @@ function hexToBytes(hex: string): Uint8Array {

async function getAccountObjectId(suiClient: ReturnType<typeof useSuiClient>, ownerAddress: string): Promise<string | null> {
try {
const registryObj = await suiClient.getObject({
id: config.memwalRegistryId,
options: { showContent: true },
})
const fields = getMoveFields<RegistryObjectFields>(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<DynamicFieldObjectFields>(dynField?.data?.content)
if (dynFields?.value) return dynFields.value
}
}
} catch {
return await fetchAccountIdForOwner(suiClient, config.memwalRegistryId, ownerAddress)
} catch (err) {
console.warn('[getAccountObjectId] lookup failed, treating as no-account', err)
return null
}

return null
}

type AccountJson = { delegate_keys?: { public_key?: unknown }[] }

async function getDelegateKeyCount(suiClient: ReturnType<typeof useSuiClient>, accountId: string): Promise<number> {
const obj = await suiClient.getObject({
id: accountId,
options: { showContent: true },
})
const fields = getMoveFields<AccountObjectFields>(obj?.data?.content)
if (fields) return (fields.delegate_keys ?? []).length

return 0
const json = await fetchObjectJson(suiClient, accountId) as AccountJson | null
return json?.delegate_keys?.length ?? 0
}

async function getRegisteredDelegatePublicKeys(suiClient: ReturnType<typeof useSuiClient>, accountId: string): Promise<string[]> {
const obj = await suiClient.getObject({
id: accountId,
options: { showContent: true },
})
const fields = getMoveFields<AccountObjectFields>(obj?.data?.content)
if (fields) {
const keys = fields.delegate_keys ?? []
return keys.map((k) => {
const f = getDelegateKeyFields(k)
const pkBytes: number[] = f.public_key ?? []
return bytesToHex(pkBytes)
})
const json = await fetchObjectJson(suiClient, accountId) as AccountJson | null
if (json?.delegate_keys) {
return json.delegate_keys.map((k) => publicKeyToHex(k.public_key))
}

return []
Expand Down
Loading
Loading