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.
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:testOr upgrade Gradle (and re-test the neow3j-gradle-plugin compatibility).
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.
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.
@ContractTest(contracts = {...})deploys every listed contract at test boot. The genesis multi-sig signs the deploys.ext.getDeployedContract(SomeContract.class)returns aSmartContracthandle.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 whateverHelper.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 withaddMultiSigWitness— seeCounterContractTest.increment_byOwner_…. - Chain-time slippage: a tx mines ~1 block after you submit, so any
state computed inside the tx is
now+1sfrom yourchainTimeSec()read. Read post-tx values after the tx confirms, not before.
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.
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.
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.
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.
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.
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.
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'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.
Every client.invokeFunction(hash, method) returns a StackItemJson
array. The handful of helpers in lib/contract.ts cover the cases:
- Integer:
valueis a string (BigInt) — parse withBigInt(item.value). - Boolean:
valueis a real bool. - ByteString:
valueis base64 — decode withu.base642hex(...)for hashes (then reverse byte order to convert little-endian on-chain to big-endian display) oratob(...)for strings. - Array (used for structs):
valueisStackItemJson[]— index by field order matching the Java@Structdeclaration. - Any with
value: null: the contract returned null.
There is no schema; you decode by hand using the contract's known field order.
Two layers, picked deliberately:
-
vitest—src/lib/*.test.ts. Pure unit tests with the RPC layer mocked (vi.hoisted+vi.mock('./rpc', …)—vi.mockis hoisted above the file, so the fake fns must come fromvi.hoistedor 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 invocationincrement()hands the wallet. No chain, no browser, runs in <2 s. -
playwright—e2e/. Drives the built bundle (vite previewondist/, not the dev server) in a real browser. The point isn't UI coverage — it's that the polyfill /define/manualChunksconfig invite.config.tsonly fails at runtime, never at build time, so the bundle-size budget alone won't catch a bustedprocess.versionsubstitution. Each spec also asserts zero uncaughtpageerrors. The read path is exercised againste2e/mock-rpc.ts— a ~90-line Nodehttpserver that answersgetcontractstate+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.
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 build →
playwright install --with-deps chromium → playwright 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.