Skip to content
Open
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
14 changes: 13 additions & 1 deletion chat-app/.env.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Sui Network
VITE_SUI_NETWORK=testnet
VITE_SUI_RPC_URL=https://fullnode.testnet.sui.io:443
VITE_SUI_GRAPHQL_URL=https://sui-testnet.mystenlabs.com/graphql
VITE_SUI_GRAPHQL_URL=https://graphql.testnet.sui.io/graphql

# Package IDs (only needed for localnet/devnet; auto-detected for testnet/mainnet)
# VITE_PERMISSIONED_GROUPS_ORIGINAL_PACKAGE_ID=0x...
Expand All @@ -21,3 +21,15 @@ VITE_WALRUS_EPOCHS=5

# Seal Key Servers (threshold encryption)
# VITE_SEAL_KEY_SERVER_OBJECT_IDS=0x...,0x...
# Only set when some key server object IDs are committee mode.
# VITE_SEAL_COMMITTEE_SERVER_OBJECT_IDS=0x...,0x...
VITE_SEAL_AGGREGATOR_URL=
VITE_SEAL_COMMITTEE_SERVER_OBJECT_IDS=
# Optional: rewrite on-chain key server URL in browser (useful when on-chain URL is localhost).
VITE_SEAL_URL_OVERRIDE_FROM=http://localhost:2024
VITE_SEAL_URL_OVERRIDE_TO=

# Enoki
VITE_ENOKI_PUBLIC_KEY=
VITE_ENOKI_GOOGLE_CLIENT_ID=
VITE_ENOKI_REDIRECT_URL=
2 changes: 1 addition & 1 deletion chat-app/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Sui Messaging Chat — Reference Application
# Mizu Messaging Chat Demo — Reference Application

## 1. Overview

Expand Down
2 changes: 1 addition & 1 deletion chat-app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sui Messaging Chat</title>
<title>Mizu Messaging Chat Demo</title>
</head>
<body>
<div id="root"></div>
Expand Down
1 change: 1 addition & 0 deletions chat-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"@mysten/bcs": "^2.0.3",
"@mysten/dapp-kit": "^1.0.4",
"@mysten/sui-stack-messaging": "link:../ts-sdks/packages/sui-stack-messaging",
"@mysten/enoki": "^1.0.4",
"@mysten/sui-groups": "^0.0.1",
"@mysten/seal": "^1.1.1",
"@mysten/sui": "^2.13.2",
Expand Down
2,290 changes: 1,748 additions & 542 deletions chat-app/pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion chat-app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ function App() {
{/* Header */}
<header className="flex items-center justify-between border-b border-secondary-200 bg-white px-6 py-3 dark:border-secondary-700 dark:bg-secondary-800">
<h1 className="text-lg font-semibold text-primary-600 dark:text-primary-400">
Sui Messaging Chat
Mizu Messaging Chat Demo
</h1>
<ConnectButton />
</header>
Expand Down
34 changes: 34 additions & 0 deletions chat-app/src/components/enoki/RegisterEnokiWallets.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useEffect } from 'react';
import { registerEnokiWallets } from '@mysten/enoki';
import { SuiGrpcClient } from '@mysten/sui/grpc';
import { getEnokiConfig } from '../../lib/enoki-config';

let hasRegisteredEnokiWallets = false;

export function RegisterEnokiWallets() {
useEffect(() => {
if (hasRegisteredEnokiWallets) return;

const config = getEnokiConfig();
if (!config.enabled) {
console.info(`[enoki] Skip registration: ${config.reason}`);
return;
}

registerEnokiWallets({
apiKey: config.apiKey,
client: new SuiGrpcClient({ baseUrl: config.rpcUrl, network: config.network }),
network: config.network,
providers: {
google: {
clientId: config.googleClientId,
redirectUrl: config.redirectUrl,
},
},
});

hasRegisteredEnokiWallets = true;
}, []);

return null;
}
61 changes: 56 additions & 5 deletions chat-app/src/contexts/MessagingClientContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,42 @@ const WALRUS_PUBLISHER_URL =
const WALRUS_AGGREGATOR_URL =
import.meta.env.VITE_WALRUS_AGGREGATOR_URL || '';
const WALRUS_EPOCHS = Number(import.meta.env.VITE_WALRUS_EPOCHS) || 1;
const SEAL_URL_OVERRIDE_FROM =
import.meta.env.VITE_SEAL_URL_OVERRIDE_FROM || 'http://localhost:2024';
const SEAL_URL_OVERRIDE_TO =
import.meta.env.VITE_SEAL_URL_OVERRIDE_TO || '';

function installSealFetchUrlOverride() {
if (!SEAL_URL_OVERRIDE_TO) return;

const g = globalThis as typeof globalThis & {
__sealFetchRewriteInstalled?: boolean;
};
if (g.__sealFetchRewriteInstalled) return;

const originalFetch = globalThis.fetch.bind(globalThis);
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
const requestUrl =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;

if (requestUrl.startsWith(SEAL_URL_OVERRIDE_FROM)) {
const rewrittenUrl = `${SEAL_URL_OVERRIDE_TO}${requestUrl.slice(SEAL_URL_OVERRIDE_FROM.length)}`;
if (input instanceof Request) {
return originalFetch(new Request(rewrittenUrl, input), init);
}
return originalFetch(rewrittenUrl, init);
}

return originalFetch(input, init);
}) as typeof fetch;
g.__sealFetchRewriteInstalled = true;
}

installSealFetchUrlOverride();

// Package config overrides (optional — auto-detected from network if not set)
function parsePackageConfig() {
Expand All @@ -52,13 +88,27 @@ function parsePackageConfig() {
}

// Seal key server object IDs (comma-separated in env)
function parseSealServerConfigs(): { objectId: string; weight: number }[] {
function parseSealServerConfigs(): { objectId: string; weight: number; aggregatorUrl?: string }[] {
const ids = import.meta.env.VITE_SEAL_KEY_SERVER_OBJECT_IDS;
if (!ids) return [];
return ids.split(',').map((id: string) => ({
objectId: id.trim(),
weight: 1,
}));
const aggregatorUrl = import.meta.env.VITE_SEAL_AGGREGATOR_URL?.trim();
const committeeServerIds = new Set(
(import.meta.env.VITE_SEAL_COMMITTEE_SERVER_OBJECT_IDS || '')
.split(',')
.map((id: string) => id.trim())
.filter(Boolean),
);
return ids
.split(',')
.map((id: string) => id.trim())
.filter(Boolean)
.map((objectId: string) => ({
objectId,
weight: 1,
...(aggregatorUrl && committeeServerIds.has(objectId)
? { aggregatorUrl }
: {}),
}));
}

// Singleton GraphQL client (does not depend on wallet)
Expand Down Expand Up @@ -113,6 +163,7 @@ export function MessagingClientProvider({
serverConfigs: sealServerConfigs,
},
encryption: {
sealThreshold: 1,
sessionKey: {
address: account.address,
onSign: async (message: Uint8Array) => {
Expand Down
2 changes: 1 addition & 1 deletion chat-app/src/index.css
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
@import 'tailwindcss';

/*
* Custom theme for Sui Messaging Chat.
* Custom theme for Mizu Messaging Chat Demo.
* Tailwind v4 uses CSS-native @theme instead of tailwind.config.js.
* Dark mode: use `class` strategy via `@variant dark (&:where(.dark, .dark *))`.
*/
Expand Down
15 changes: 7 additions & 8 deletions chat-app/src/lib/dapp-kit-signer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
* Adapter that wraps dapp-kit's signPersonalMessage into a Signer-compatible object
* for use with the messaging SDK's relayer transport.
*
* Supports all Sui wallet types (Ed25519, Secp256k1, Secp256r1, zkLogin, multisig)
* by lazily extracting the public key from the first signature when the wallet
* doesn't expose publicKey upfront.
* Lazily extracts the public key from the first signature when the wallet
* doesn't expose publicKey upfront, which is useful for wallet adapters that
* derive the signing identity dynamically, including zkLogin-backed wallets.
*/
import { Signer, parseSerializedSignature } from '@mysten/sui/cryptography';
import { Signer, parseSerializedSignature, SIGNATURE_FLAG_TO_SCHEME } from '@mysten/sui/cryptography';
import type { PublicKey, SignatureScheme } from '@mysten/sui/cryptography';
import { publicKeyFromRawBytes, publicKeyFromSuiBytes } from '@mysten/sui/verify';
import { toBase64 } from '@mysten/sui/utils';
Expand Down Expand Up @@ -65,10 +65,9 @@ export class DappKitSigner extends Signer {
if (!this.#publicKey) {
return 'ED25519'; // default until first signature resolves it
}
const flag = this.#publicKey.flag();
if (flag === 0x00) return 'ED25519';
if (flag === 0x01) return 'Secp256k1';
return 'Secp256r1';
return SIGNATURE_FLAG_TO_SCHEME[
this.#publicKey.flag() as keyof typeof SIGNATURE_FLAG_TO_SCHEME
] ?? 'ED25519';
}

getPublicKey(): PublicKey {
Expand Down
83 changes: 83 additions & 0 deletions chat-app/src/lib/enoki-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { EnokiNetwork } from '@mysten/enoki';

type EnabledEnokiConfig = {
enabled: true;
apiKey: string;
googleClientId: string;
redirectUrl: string;
rpcUrl: string;
network: EnokiNetwork;
};

type DisabledEnokiConfig = {
enabled: false;
reason: string;
};

export type EnokiConfig = EnabledEnokiConfig | DisabledEnokiConfig;

function readEnv(name: string): string {
const value = import.meta.env[name];
return typeof value === 'string' ? value.trim() : '';
}

function getNetwork(): EnokiNetwork {
const configuredNetwork = readEnv('VITE_SUI_NETWORK');
if (
configuredNetwork === 'mainnet' ||
configuredNetwork === 'testnet' ||
configuredNetwork === 'devnet'
) {
return configuredNetwork;
}
return 'testnet';
}

function isValidUrl(url: string): boolean {
try {
new URL(url);
return true;
} catch {
return false;
}
}

export function getEnokiConfig(): EnokiConfig {
const apiKey = readEnv('VITE_ENOKI_PUBLIC_KEY');
const googleClientId = readEnv('VITE_ENOKI_GOOGLE_CLIENT_ID');
const redirectUrl = readEnv('VITE_ENOKI_REDIRECT_URL');
const rpcUrl = readEnv('VITE_SUI_RPC_URL');

if (!apiKey || !googleClientId || !redirectUrl || !rpcUrl) {
return {
enabled: false,
reason:
'Missing one or more Enoki env vars: VITE_ENOKI_PUBLIC_KEY, VITE_ENOKI_GOOGLE_CLIENT_ID, VITE_ENOKI_REDIRECT_URL, VITE_SUI_RPC_URL.',
};
}

if (!isValidUrl(redirectUrl)) {
return {
enabled: false,
reason: 'VITE_ENOKI_REDIRECT_URL is not a valid URL.',
};
}

if (!isValidUrl(rpcUrl)) {
return {
enabled: false,
reason: 'VITE_SUI_RPC_URL is not a valid URL.',
};
}

const network = getNetwork();

return {
enabled: true,
apiKey,
googleClientId,
redirectUrl,
rpcUrl,
network,
};
}
2 changes: 2 additions & 0 deletions chat-app/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { SuiClientProvider, WalletProvider } from '@mysten/dapp-kit';
import { networkConfig } from './lib/network-config';
import { MessagingClientProvider } from './contexts/MessagingClientContext';
import { ErrorBoundary } from './components/ErrorBoundary';
import { RegisterEnokiWallets } from './components/enoki/RegisterEnokiWallets';
import App from './App';
import '@mysten/dapp-kit/dist/index.css';
import './index.css';
Expand All @@ -16,6 +17,7 @@ createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<SuiClientProvider networks={networkConfig} defaultNetwork="testnet">
<WalletProvider autoConnect>
<RegisterEnokiWallets />
<MessagingClientProvider>
<ErrorBoundary>
<App />
Expand Down
1 change: 1 addition & 0 deletions relayer/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ REQUEST_TTL_SECONDS=900

# Sui Configuration (required)
SUI_RPC_URL=https://fullnode.testnet.sui.io:443
SUI_GRAPHQL_URL=https://graphql.testnet.sui.io/graphql
# Testnet: 0xba8a26d42bc8b5e5caf4dac2a0f7544128d5dd9b4614af88eec1311ade11de79
# Mainnet: 0x541840ae7df705d1c6329c22415ed61f9140a18b79b13c1c9dc7415b115c1ba8
GROUPS_PACKAGE_ID=
Expand Down
1 change: 1 addition & 0 deletions relayer/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ axum = "0.7"
tokio = { version = "1.43.0", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", features = ["preserve_order"] }
base64 = "0.21"
tracing = "0.1"
tracing-subscriber = "0.3"
uuid = { version = "1.0", features = ["v4", "serde"] }
Expand Down
2 changes: 1 addition & 1 deletion relayer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@ COPY --from=builder /app/target/release/messaging-relayer ./messaging-relayer
EXPOSE 3000

HEALTHCHECK --interval=10s --timeout=5s --retries=3 --start-period=5s \
CMD curl -f http://localhost:3000/health_check || exit 1
CMD curl -f http://localhost:3000/relayer/health_check || exit 1

CMD ["./messaging-relayer"]
11 changes: 7 additions & 4 deletions relayer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ All authenticated requests must include:

| Header | Description |
|--------|-------------|
| `X-Signature` | Hex-encoded 64-byte raw signature |
| `X-Signature` | Hex-encoded signature bytes. Keypair schemes use raw 64-byte signatures; zkLogin uses the serialized authenticator bytes |
| `X-Public-Key` | Hex-encoded bytes: `flag_byte \|\| public_key_bytes` (first byte identifies the scheme) |

Bodyless requests (GET, DELETE) also require:
Expand All @@ -68,11 +68,11 @@ When a request arrives, the auth middleware runs the following steps in order. I

1. **Validate timestamp** — The timestamp (from body or header) must be within the configured TTL window (default 5 minutes). This prevents replay attacks where an attacker resubmits a previously captured request.

2. **Decode public key** — The `X-Public-Key` header is hex-decoded. The first byte is the scheme flag (`0x00` = Ed25519, `0x01` = Secp256k1, `0x02` = Secp256r1). The remaining bytes are the raw public key. If the flag is unrecognized or the key length doesn't match the scheme, the request is rejected.
2. **Decode public key** — The `X-Public-Key` header is hex-decoded. The first byte is the scheme flag (`0x00` = Ed25519, `0x01` = Secp256k1, `0x02` = Secp256r1, `0x05` = zkLogin). The remaining bytes are the raw public key or public identifier. If the flag is unrecognized or the bytes do not match the scheme format, the request is rejected.

3. **Decode signature** — The `X-Signature` header is hex-decoded into 64 raw signature bytes.
3. **Decode signature** — The `X-Signature` header is hex-decoded into scheme-specific signature bytes.

4. **Verify signature** — The signature is verified against the signed message using the public key and the detected scheme. This uses `sui_crypto`'s `UserSignatureVerifier` with `PersonalMessage` wrapping (the same format Sui wallets use). If the signature doesn't match, the request is rejected.
4. **Verify signature** — The signature is verified against the signed message using the public key and the detected scheme. Keypair schemes use `sui_crypto`'s `UserSignatureVerifier` with `PersonalMessage` wrapping. zkLogin signatures are verified against the Sui GraphQL API. If the signature doesn't match, the request is rejected.

5. **Derive Sui address** — The sender's Sui address is derived from the public key by computing `Blake2b-256(flag_byte || public_key_bytes)`. This is how Sui maps public keys to addresses.

Expand Down Expand Up @@ -468,6 +468,7 @@ All configuration is loaded from environment variables. The relayer also support
| `MEMBERSHIP_STORE_TYPE` | `memory` | No | Membership cache backend — currently only `memory` is implemented |
| `SUI_RPC_URL` | — | **Yes** | Sui fullnode gRPC URL for checkpoint subscription (e.g., `https://fullnode.testnet.sui.io:443`) |
| `GROUPS_PACKAGE_ID` | — | **Yes** | Groups SDK package ID deployed on Sui (e.g., `0xabc123...`) |
| `SUI_GRAPHQL_URL` | — | No | Sui GraphQL endpoint used for zkLogin signature verification (required only for zkLogin auth, e.g., `https://graphql.testnet.sui.io/graphql`) |
| `WALRUS_PUBLISHER_URL` | `https://publisher.walrus-testnet.walrus.space` | No | Walrus publisher endpoint for storing blobs/quilts |
| `WALRUS_AGGREGATOR_URL` | `https://aggregator.walrus-testnet.walrus.space` | No | Walrus aggregator endpoint for reading blobs |
| `WALRUS_STORAGE_EPOCHS` | `5` | No | Number of Walrus epochs to persist stored data |
Expand Down Expand Up @@ -523,6 +524,7 @@ src/
# Create a .env file with required variables
cat > .env << 'EOF'
SUI_RPC_URL=https://fullnode.testnet.sui.io:443
SUI_GRAPHQL_URL=https://graphql.testnet.sui.io/graphql
GROUPS_PACKAGE_ID=0x...your_package_id...
EOF

Expand Down Expand Up @@ -589,6 +591,7 @@ docker run -p 3000:3000 --env-file .env messaging-relayer
# Or pass env vars directly
docker run -p 3000:3000 \
-e SUI_RPC_URL=https://fullnode.testnet.sui.io:443 \
-e SUI_GRAPHQL_URL=https://graphql.testnet.sui.io/graphql \
-e GROUPS_PACKAGE_ID=0x... \
messaging-relayer
```
Expand Down
2 changes: 1 addition & 1 deletion relayer/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ services:
environment:
- RUST_LOG=${RUST_LOG:-messaging_relayer=info}
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${PORT:-3000}/health_check"]
test: ["CMD", "curl", "-f", "http://localhost:${PORT:-3000}/relayer/health_check"]
interval: 10s
timeout: 5s
retries: 3
Expand Down
Loading