Skip to content

Latest commit

 

History

History
266 lines (210 loc) · 11.6 KB

File metadata and controls

266 lines (210 loc) · 11.6 KB

Stack notes — what's load-bearing and why

This file captures the gotchas we hit getting a Neo N3 + neow3j + Vite

  • neon-js stack to ship green. Many of these are invisible until they bite — the workarounds look superfluous in code review but were each one debug session of pain. Read this before upgrading versions or trimming "unused" config.

Contract / neow3j

JDK version: 17 or 21, not 25

Gradle 8.7 (what this repo's wrapper pins) does not support JDK 25. If your java -version shows 25, point Gradle at JDK 17 or 21 explicitly:

JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 ./gradlew :contract:test

Or upgrade Gradle (and re-test the neow3j-gradle-plugin compatibility).

sourceCompatibility = 1.8 for src/main, but not src/test

The neow3j devpack compiler reads .class files and translates to NEF bytecode. Java 9+ class-file features (e.g. Module-Info, nest-mates, StringConcatFactory.makeConcatWithConstants for string +) will either silently produce wrong bytecode or fail compilation. So contract sources are locked at 1.8.

This is per source set, not per project. The root build.gradle.kts overrides compileTestJava to Java 17:

project(":contract") {
    extensions.configure<JavaPluginExtension> {
        sourceCompatibility = JavaVersion.VERSION_1_8
        targetCompatibility = JavaVersion.VERSION_1_8
    }
    tasks.named<JavaCompile>("compileTestJava") {
        sourceCompatibility = "17"
        targetCompatibility = "17"
    }
}

So tests can use var, switch expressions, records, text blocks, etc. Don't drop the override — without it, compileTestJava inherits the 1.8 default and you'll get cryptic "cannot find symbol: class var" errors.

A common confusion: this sourceCompatibility is about bytecode features the compiler emits, not the JDK that runs the build. You can (and should) build with JDK 17 or 21 — Gradle 8.7 doesn't support 25. The runtime JDK and the source level are independent knobs.

neow3jVersion pinned in gradle.properties

Anyone deploying this contract should compile with the same neow3j version, or the resulting NEF will hash differently — breaking any "verified bytecode" claim. Lock the version, document the bump in your release notes.

Test flow

  • @ContractTest(contracts = {...}) deploys every listed contract at test boot. The genesis multi-sig signs the deploys.
  • ext.getDeployedContract(SomeContract.class) returns a SmartContract handle.
  • neow3j.allowTransmissionOnFault() is mandatory if you assert on abort-paths — without it, FAULTed txs are rejected before mining and you can't read their fault message.
  • TestHelper.assertAborted(tx, "expected msg", neow3j) reads the fault message from the application log. The string is whatever Helper.abort(...) was called with.
  • Genesis multi-sig as owner: when a contract uses Runtime.getCallingScriptHash() in @OnDeployment (or accepts no deploy data), the multi-sig hash is the owner. Calls that need that signer must build the tx with addMultiSigWitness — see CounterContractTest.increment_byOwner_….
  • Chain-time slippage: a tx mines ~1 block after you submit, so any state computed inside the tx is now+1s from your chainTimeSec() read. Read post-tx values after the tx confirms, not before.

UI / Vite / neon-js

vite-plugin-node-polyfills configuration

nodePolyfills({
  protocolImports: true,
  globals: { Buffer: true, global: true, process: true },
  include: ['buffer', 'process', 'stream', 'util', 'crypto', 'events', 'string_decoder'],
})

neon-js → ripemd160 → readable-stream needs the full Node stream polyfill stack, not just the globals. protocolImports: true is what intercepts import 'node:stream' style imports from deep deps. The include list is minimal-but-complete — drop one and you'll get a runtime crash inside _stream_writable.js at module init.

process.version substitution

define: {
  'process.browser': 'true',
  'process.version': JSON.stringify('v22.0.0'),
}

readable-stream@2.x does process.version.slice(0, 5) at module init. The bundled process polyfill leaves version as undefined, so the slice throws and the entire app fails to load. Substituting a string makes the slice a no-op and the !process.browser check short-circuits true. Apply the same substitution in optimizeDeps.esbuildOptions.define — Vite's top-level define only touches user source; pre-bundled deps go through esbuild separately, and that's where readable-stream lives.

manualChunks is not optional

Without splitting, a default Vite build bundles @reown/appkit + @walletconnect/* + @cityofzion/neon-js into one chunk that exceeds 1.5 MB (the CI bundle-size budget). The manualChunks function in vite.config.ts peels them into:

chunk contents ~size
wallet-connect @reown/appkit + @walletconnect/* ~870 KB
wallet-evm wagmi + viem (deps of appkit) ~260 KB
crypto-vendor elliptic, bn.js, ripemd160, scrypt, sha.js, … ~630 KB
neon @cityofzion/* (excluding neon-core) ~1 MB
react-vendor react, react-dom, scheduler ~165 KB
(main) your app code <100 KB

Don't drop the crypto-vendor rule — without it, neon-core re-merges with all of its crypto dep tree and the chunk breaks 1.5 MB again.

output.intro: 'var exports = {};' — the "exports is not defined" trap

randomfill (reached via crypto-browserify, the crypto polyfill, the moment your code touches neon-js's wallet module — key generation, NEP-2, address↔scripthash) has a conditional exports.randomFill = …. Once vite-plugin-node-polyfills injects its shims into that module, @rollup/plugin-commonjs stops rewriting that one assignment to its synthetic exports object — it stays as a bare exports.randomFill. The dev server is fine (esbuild prebundles randomfill cleanly); only vite build produces the broken chunk, which then throws "exports is not defined" at chunk-init and the whole app is a blank page. This is exactly the class of bug the Playwright suite exists to catch — a green tsc -b + vite build will happily ship it.

The fix is the one-liner output.intro: 'var exports = {};': it declares a module-local exports in every emitted chunk, so the stray write becomes a harmless no-op. (Nothing reads crypto.randomFill.) Keep it even if the starter's placeholder contract never touches wallet — your fork will.

Localnet RPC proxy

neo-cli's RpcServer does not emit CORS headers, so a browser at http://localhost:5173 can't fetch('http://localhost:10332'). The /__rpc proxy entry in vite.config.ts routes RPC through Vite, keeping it same-origin. rpc.ts uses ${origin}/__rpc as the localnet URL. This is dev-only — production deploys go straight to a public RPC.


Wallet connection

Don't auto-reconnect to NeoLine on page load

NeoLine's dAPI.getAccount() always shows a permission popup, even for already-authorized origins. Auto-rehydrating on mount produces a popup-on-page-load — and if the user closes it, the promise sometimes hangs forever, leaving the UI stuck on "Connecting…".

Solution: only auto-rehydrate WalletConnect (AppKit's session restore is silent). NeoLine always requires an explicit click. See connection.tsx.

'neo3' as never for the AppKit namespace

AppKit's ChainNamespace type doesn't include 'neo3' yet, even though Neo3Adapter works. Cast at the call sites — don't try to extend the type.

NeoLine getAccount returns N-address; the contract wants 0x-hex

NeoLine's signer config requires the account as a 0x-hex scripthash, not the N-prefixed address. neoline-adapter.ts → toScriptHash does this conversion. If you pass an N-address you get Expected a hexstring inside NeoLine's internal neon-js call — confusingly far from the actual problem.


Stack-item decoding (RPC reads)

Every client.invokeFunction(hash, method) returns a StackItemJson array. The handful of helpers in lib/contract.ts cover the cases:

  • Integer: value is a string (BigInt) — parse with BigInt(item.value).
  • Boolean: value is a real bool.
  • ByteString: value is base64 — decode with u.base642hex(...) for hashes (then reverse byte order to convert little-endian on-chain to big-endian display) or atob(...) for strings.
  • Array (used for structs): value is StackItemJson[] — index by field order matching the Java @Struct declaration.
  • Any with value: null: the contract returned null.

There is no schema; you decode by hand using the contract's known field order.


Testing strategy (UI)

Two layers, picked deliberately:

  • vitestsrc/lib/*.test.ts. Pure unit tests with the RPC layer mocked (vi.hoisted + vi.mock('./rpc', …)vi.mock is hoisted above the file, so the fake fns must come from vi.hoisted or you hit a TDZ error). Covers stack-item decoding (getCount, getOwner, asHash160), contractExists' swallow-everything-as-false behaviour, waitForTx's poll loop, and the shape of the invocation increment() hands the wallet. No chain, no browser, runs in <2 s.

  • playwrighte2e/. Drives the built bundle (vite preview on dist/, not the dev server) in a real browser. The point isn't UI coverage — it's that the polyfill / define / manualChunks config in vite.config.ts only fails at runtime, never at build time, so the bundle-size budget alone won't catch a busted process.version substitution. Each spec also asserts zero uncaught pageerrors. The read path is exercised against e2e/mock-rpc.ts — a ~90-line Node http server that answers getcontractstate + invokefunction — wired in via the app's ?rpc= override.

There is intentionally no wallet-driven e2e. NeoLine is a browser extension whose popup you'd have to drive via --load-extension + a pre-imported account fixture (version-coupled, brittle); WalletConnect needs a headless wallet speaking the WC v2 protocol on the other end. The only thing that buys over the increment() unit test is "the wallet handshake is wired up", and the cost/flake isn't worth it for a starter. If you do want it later: inject a fake window.NEOLineN3 via page.addInitScript whose invoke actually signs with a funded localnet key and returns a real txid, then assert the on-chain state moved.

tsconfig: test/e2e files are excluded from tsconfig.app.json (they'd trip noUnusedLocals with test scaffolding and don't belong in the app build) and type-checked via the standalone tsconfig.test.json, which npm run typecheck runs after tsc -b.


CI

The bundle-size budget step (.github/workflows/ci.yml) is a forcing function — it catches accidental regressions in chunking before they land. Tune MAX_BYTES upward only as a last resort; usually the right fix is a new entry in manualChunks.

The ui job runs, in order: npm run test (vitest) right after install → tsc (typecheck, including the test config) → npm run buildplaywright install --with-deps chromiumplaywright test (which serves the dist/ from the build step) → bundle-size budget. The Playwright HTML report is uploaded as an artifact even on failure. The contract job uses Temurin 17, which is the version neow3jVersion=3.22.1 was tested against — if you bump neow3j, validate the JDK version still works.