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
103 changes: 102 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,89 @@ jobs:
retention-days: 14
if-no-files-found: ignore

noter-e2e:
name: Noter / Playwright E2E
runs-on: ubuntu-latest
timeout-minutes: 25

services:
postgres:
image: postgres:17
env:
POSTGRES_USER: noter
POSTGRES_PASSWORD: noter_secret
POSTGRES_DB: noter
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U noter"
--health-interval 5s
--health-timeout 5s
--health-retries 10

env:
DATABASE_URL: postgresql://noter:noter_secret@localhost:5432/noter
NEXT_PUBLIC_APP_URL: http://localhost:3002
PORT: "3002"
NODE_ENV: test
# NEXT_PUBLIC_* Enoki/Sui vars are inlined at build time — placeholders
# are fine here since the e2e suite authenticates via delegate key, not
# the Google/Enoki popup flow (that needs a real OAuth session and stays
# a manual check, same as researcher's live-Walrus canary in #680).
NEXT_PUBLIC_ENOKI_API_KEY: ci-placeholder-not-used-tests-use-delegate-key
NEXT_PUBLIC_GOOGLE_CLIENT_ID: ci-placeholder-not-used-tests-use-delegate-key
NEXT_PUBLIC_SUI_NETWORK: testnet
NEXT_PUBLIC_MEMWAL_PACKAGE_ID: "0xcf6ad755a1cdff7217865c796778fabe5aa399cb0cf2eba986f4b582047229c6"
NEXT_PUBLIC_MEMWAL_REGISTRY_ID: "0xe80f2feec1c139616a86c9f71210152e2a7ca552b20841f2e192f99f75864437"
NEXT_PUBLIC_MEMWAL_SERVER_URL: https://relayer.dev.memwal.ai
# MEMWAL_E2E_PRIVATE_KEY / MEMWAL_E2E_ACCOUNT_ID intentionally unset:
# the memory-write specs that need real relayer credentials self-skip
# via test.skip() when absent. CI validates auth + note CRUD
# deterministically; the real remember round-trip against the relayer
# stays a manual check, same caveat #680 documents for researcher.

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: pnpm

- name: Install deps
run: pnpm install --frozen-lockfile

- name: Build SDK (workspace dep of noter)
run: pnpm build:sdk

- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: pw-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pw-${{ runner.os }}-

- name: Install Playwright (Chromium + OS deps)
run: pnpm --filter @memwal/noter playwright:install

- name: Run Playwright E2E
run: pnpm --filter @memwal/noter test:e2e

- name: Upload Playwright report + traces
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report-noter
path: |
apps/noter/playwright-report
apps/noter/test-results
retention-days: 14
if-no-files-found: ignore

server-e2e:
name: Server / E2E
runs-on: ubuntu-latest
Expand Down Expand Up @@ -364,10 +447,20 @@ jobs:
run: pnpm exec next build

noter-checks:
name: Noter / Unit tests
name: Noter / Unit tests + Build
runs-on: ubuntu-latest
timeout-minutes: 20

env:
# Dummy DB — next build type-checks route handlers but doesn't connect.
DATABASE_URL: postgresql://dummy:dummy@localhost:5432/dummy
NEXT_PUBLIC_ENOKI_API_KEY: ci-placeholder-build-only
NEXT_PUBLIC_GOOGLE_CLIENT_ID: ci-placeholder-build-only
NEXT_PUBLIC_SUI_NETWORK: testnet
NEXT_PUBLIC_MEMWAL_PACKAGE_ID: "0xcf6ad755a1cdff7217865c796778fabe5aa399cb0cf2eba986f4b582047229c6"
NEXT_PUBLIC_MEMWAL_REGISTRY_ID: "0xe80f2feec1c139616a86c9f71210152e2a7ca552b20841f2e192f99f75864437"
NEXT_PUBLIC_MEMWAL_SERVER_URL: https://relayer.dev.memwal.ai

steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -393,6 +486,14 @@ jobs:
- name: Unit tests (vitest)
run: pnpm --filter @memwal/noter test:unit

- name: Type-check (tsc --noEmit)
working-directory: apps/noter
run: pnpm exec tsc --noEmit

- name: Build (Next.js, type-check inclusive)
working-directory: apps/noter
run: pnpm exec next build

server-checks:
name: Server / Clippy + Unit tests
runs-on: ubuntu-latest
Expand Down
4 changes: 4 additions & 0 deletions apps/noter/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,7 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

# playwright
/test-results/
/playwright-report/
72 changes: 34 additions & 38 deletions apps/noter/app/components/enoki-login-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,19 @@ import {
useCurrentAccount,
useSignPersonalMessage,
useSignTransaction,
useSuiClient,
} from "@mysten/dapp-kit";
import { isEnokiWallet } from "@mysten/enoki";
import { bcs } from "@mysten/sui/bcs";
import type { SuiGrpcClient } from "@mysten/sui/grpc";
import { Transaction } from "@mysten/sui/transactions";
import { createSponsorAuthorization } from "@mysten-incubation/memwal";
import { Loader2 } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { enokiConfig } from "@/lib/enoki/config";
import { useAuth } from "@/feature/auth";
import { trpc } from "@/shared/lib/trpc/client";
import { getSuiGrpcClient } from "@/lib/sui/grpc-client";
import { AccountCreatedBcs, AccountRegistryBcs } from "@/lib/sui/account-bcs";

type Step =
| "idle"
Expand Down Expand Up @@ -62,14 +65,14 @@ function uint8ArrayToBase64(bytes: Uint8Array): string {
async function sponsoredSignAndExecute(
transaction: Transaction,
sender: string,
suiClient: ReturnType<typeof useSuiClient>,
suiClient: SuiGrpcClient,
signTransaction: (args: {
transaction: Transaction;
transaction: Transaction | string;
}) => Promise<{ signature: string }>,
signPersonalMessage: (message: Uint8Array) => Promise<{ signature: string }>,
): Promise<{ digest: string }> {
const kindBytes = await transaction.build({
client: suiClient as any,
client: suiClient,
onlyTransactionKind: true,
});
const authorization = await createSponsorAuthorization(
Expand All @@ -95,7 +98,13 @@ async function sponsoredSignAndExecute(

const sponsored = await sponsorRes.json();
const sponsoredTx = Transaction.from(sponsored.bytes);
const { signature } = await signTransaction({ transaction: sponsoredTx });
// dapp-kit's useSignTransaction resolves move-call ABIs via the ambient
// client from SuiClientProvider, which is JSON-RPC (deprecated, no longer
// CORS-enabled for browser origins). Pre-serializing with our gRPC client
// and handing off the resulting string short-circuits that internal
// resolution — dapp-kit passes a string through as-is.
const sponsoredTxJson = await sponsoredTx.toJSON({ client: suiClient });
const { signature } = await signTransaction({ transaction: sponsoredTxJson });

const execRes = await fetch(
`${enokiConfig.memwalServerUrl}/sponsor/execute`,
Expand All @@ -118,7 +127,7 @@ export function EnokiLoginCard() {
const wallets = useWallets();
const { mutateAsync: connect } = useConnectWallet();
const currentAccount = useCurrentAccount();
const suiClient = useSuiClient();
const suiClient = getSuiGrpcClient();
const { mutateAsync: signTransaction } = useSignTransaction();
const { mutateAsync: signPersonalMessage } = useSignPersonalMessage();
const { connectEnoki } = useAuth();
Expand Down Expand Up @@ -197,36 +206,24 @@ export function EnokiLoginCard() {
let knownAccountId: string | null = null;

try {
const registryObj = await suiClient.getObject({
id: enokiConfig.memwalRegistryId,
options: { showContent: true },
const registryRes = await suiClient.getObject({
objectId: enokiConfig.memwalRegistryId,
include: { content: true },
});
if (
registryObj?.data?.content &&
"fields" in registryObj.data.content
) {
const fields = registryObj.data.content.fields as any;
const tableId = fields?.accounts?.fields?.id?.id;
if (tableId) {
const dynField = await suiClient.getDynamicFieldObject({
parentId: tableId,
name: { type: "address", value: address },
});
if (
dynField?.data?.content &&
"fields" in dynField.data.content
) {
knownAccountId = (dynField.data.content.fields as any)
.value as string;
}
}
if (registryRes.object.content) {
const registry = AccountRegistryBcs.parse(registryRes.object.content);
const dynField = await suiClient.getDynamicField({
parentId: registry.accounts.id,
name: { type: "address", bcs: bcs.Address.serialize(address).toBytes() },
});
knownAccountId = bcs.Address.parse(dynField.dynamicField.value.bcs);
}
} catch {
// Dynamic field not found → no account yet
}

const pubKeyBytes = Array.from(publicKeyRaw);
const sign = (args: { transaction: Transaction }) =>
const sign = (args: { transaction: Transaction | string }) =>
signTransaction(args);

if (knownAccountId) {
Expand Down Expand Up @@ -267,18 +264,17 @@ export function EnokiLoginCard() {
);
await suiClient.waitForTransaction({ digest: createResult.digest });

const txDetails = await suiClient.getTransactionBlock({
const txResult = await suiClient.getTransaction({
digest: createResult.digest,
options: { showObjectChanges: true },
include: { events: true },
});
const createdObj = txDetails.objectChanges?.find(
(c) =>
c.type === "created" &&
"objectType" in c &&
c.objectType.includes("MemWalAccount"),
const txDetails =
txResult.$kind === "Transaction" ? txResult.Transaction : txResult.FailedTransaction;
const createdEvent = txDetails.events?.find((e) =>
e.eventType.endsWith("::account::AccountCreated"),
);
if (createdObj && "objectId" in createdObj) {
knownAccountId = createdObj.objectId;
if (createdEvent) {
knownAccountId = AccountCreatedBcs.parse(createdEvent.bcs).account_id;
}

if (!knownAccountId) {
Expand Down
21 changes: 15 additions & 6 deletions apps/noter/app/components/sui-providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,31 @@ import {
createNetworkConfig,
SuiClientProvider,
WalletProvider,
useSuiClientContext,
} from "@mysten/dapp-kit";
import { isEnokiNetwork, registerEnokiWallets } from "@mysten/enoki";
import { getJsonRpcFullnodeUrl } from "@mysten/sui/jsonRpc";
import { enokiConfig } from "@/lib/enoki/config";
import { getSuiGrpcClient } from "@/lib/sui/grpc-client";

const { networkConfig } = createNetworkConfig({
testnet: { url: getJsonRpcFullnodeUrl("testnet"), network: "testnet" },
mainnet: { url: getJsonRpcFullnodeUrl("mainnet"), network: "mainnet" },
});

/** Registers Enoki wallets (Google OAuth) with dapp-kit on mount. No-op if env vars are missing. */
/**
* Registers Enoki wallets (Google OAuth) with dapp-kit on mount. No-op if env
* vars are missing.
*
* Uses a standalone SuiGrpcClient rather than SuiClientProvider's client:
* dapp-kit's SuiClientProvider is hard-typed to SuiJsonRpcClient (even in the
* latest published version), and Sui's public JSON-RPC fullnodes no longer
* serve JSON-RPC — so useSuiClientContext()'s client can't be used here.
* Enoki's `client` option accepts the same ClientWithCoreApi interface a
* gRPC client satisfies, so this is otherwise a drop-in swap.
*/
function RegisterEnokiWallets() {
const { client, network } = useSuiClientContext();

useEffect(() => {
const network = enokiConfig.suiNetwork;
if (!isEnokiNetwork(network)) return;
if (!enokiConfig.enokiApiKey || !enokiConfig.googleClientId) return;

Expand All @@ -29,12 +38,12 @@ function RegisterEnokiWallets() {
providers: {
google: { clientId: enokiConfig.googleClientId },
},
client,
client: getSuiGrpcClient(),
network,
});

return unregister;
}, [client, network]);
}, []);

return null;
}
Expand Down
7 changes: 7 additions & 0 deletions apps/noter/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const isProductionEnvironment = process.env.NODE_ENV === "production";
export const isDevelopmentEnvironment = process.env.NODE_ENV === "development";
export const isTestEnvironment = Boolean(
process.env.PLAYWRIGHT_TEST_BASE_URL ||
process.env.PLAYWRIGHT ||
process.env.CI_PLAYWRIGHT
);
63 changes: 63 additions & 0 deletions apps/noter/lib/sui/account-bcs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* BCS schemas for reading `memwal::account`'s on-chain structs. gRPC
* object/dynamic-field/event reads return raw BCS bytes (unlike JSON-RPC's
* parsed `.fields`), so these are needed to decode on-chain state.
*
* These schemas intentionally decode only the leading fields this app reads
* (id, accounts / id, owner, delegate_keys, active) and rely on the BCS
* parser stopping there rather than erroring on trailing bytes. That's safe
* against the package this app is currently configured against, but NOT
* against services/contract/sources/account.move as it reads today —
* that source has already grown migration/import fields on AccountRegistry
* (migration_finalized, pinned_allowlist_root, expected/imported counters,
* version) and MemWalAccount (admin_quarantined, legacy_account_id, and
* more) that aren't modeled here at all. Verified correct against the
* live, currently-deployed bytecode as of this change (decoded delegate key
* bytes matched a known-good derived public key; a registry dynamic-field
* lookup round-tripped to the expected account id) — but that means the
* source has moved ahead of what's published, not that these schemas are
* future-proof. If/when that contract version is published to the package
* this app points at, these schemas need the new fields added (in order)
* or reads will silently decode wrong instead of erroring.
*/
import { bcs } from "@mysten/sui/bcs";

/** memwal::account::DelegateKey */
export const DelegateKeyBcs = bcs.struct("DelegateKey", {
public_key: bcs.vector(bcs.U8),
sui_address: bcs.Address,
label: bcs.String,
created_at: bcs.U64,
});

/** memwal::account::MemWalAccount */
export const MemWalAccountBcs = bcs.struct("MemWalAccount", {
id: bcs.Address,
owner: bcs.Address,
delegate_keys: bcs.vector(DelegateKeyBcs),
created_at: bcs.U64,
active: bcs.Bool,
});

/**
* sui::table::Table<K, V> — framework struct, not defined in account.move,
* but referenced by AccountRegistry.accounts: Table<address, ID>. Table's
* own layout is `{ id: UID, size: u64 }`; entries live as dynamic fields on
* `id`, not inlined in this struct.
*/
export const TableBcs = bcs.struct("Table", {
id: bcs.Address,
size: bcs.U64,
});

/** memwal::account::AccountRegistry */
export const AccountRegistryBcs = bcs.struct("AccountRegistry", {
id: bcs.Address,
accounts: TableBcs,
});

/** memwal::account::AccountCreated (event) */
export const AccountCreatedBcs = bcs.struct("AccountCreated", {
account_id: bcs.Address,
owner: bcs.Address,
});
Loading
Loading