@scallop-io/sui-move-bytecode-verifier
v0.1.0
Published
Tool to verify Sui Move on-chain packages
Readme
Sui Move Bytecode Verifier
A CLI tool to verify Sui Move bytecodes across different sources: local directories, GitHub repositories, on-chain packages, and publish/upgrade transactions. It compiles source, fetches bytecodes, and compares them byte-for-byte.
Prerequisites
- Node.js >= 18
- Sui CLI installed (install guide)
- Git
- (Optional) suiup for automatic compiler version switching
Setup
git clone <this-repo>
cd contract-verifier
npm installBuild (optional)
npm run buildThis produces dist/index.js which can be run directly or linked globally:
npm link
sui-move-bytecode-verifier --helpUsage
The verify command compares bytecodes from any two sources. Specify exactly two of: --local, --repo, --on-chain, --tx-bytes.
1. Local vs On-chain
Check if your local Move source code matches an on-chain package:
npx tsx src/index.ts verify \
--local ./my-package \
--on-chain 0x1234...abcd \
--network mainnet2. Local vs GitHub
Check if your local code matches a specific version in a GitHub repo:
npx tsx src/index.ts verify \
--local ./my-package \
--repo https://github.com/example-org/protocol \
--ref v1.0.03. GitHub vs On-chain
Verify that a GitHub repo's source matches the on-chain deployed package:
npx tsx src/index.ts verify \
--repo https://github.com/example-org/protocol \
--ref v1.0.0 \
--on-chain 0x1234...abcd \
--network mainnet4. Transaction bytes vs GitHub
Verify that the module bytes in a publish/upgrade transaction match a GitHub repo. This lets you check what you're about to sign before submitting:
# From a base64 file (e.g. saved from sui client or SDK)
npx tsx src/index.ts verify \
--tx-bytes ./unsigned-tx.base64 \
--repo https://github.com/example-org/protocol \
--ref v1.0.0
# Or pass base64 inline
npx tsx src/index.ts verify \
--tx-bytes "AAAC..." \
--repo https://github.com/example-org/protocol \
--ref v1.0.0The --tx-bytes flag accepts either a file path (containing base64-encoded transaction bytes) or an inline base64 string. The tool parses the BCS-encoded TransactionData, finds the Publish or Upgrade command, and extracts the module bytecodes for comparison.
Source flags
| Flag | Description |
|---|---|
| --local <path> | Local Move package directory |
| -r, --repo <url> | GitHub repository URL (use with --ref and optionally --path) |
| --on-chain <id> | On-chain Sui package object ID (0x...) |
| --tx-bytes <base64\|file> | Transaction bytes: inline base64 or path to a file containing base64 |
Other options
| Option | Description | Default |
|---|---|---|
| --ref <ref> | Git ref for --repo: branch, tag, or commit hash | main |
| --path <path> | Path to Move package within repo | auto-detect |
| -n, --network <network> | mainnet, testnet, or devnet | mainnet |
| --rpc-url <url> | Custom RPC endpoint (overrides --network) | |
| --sui-version <version> | Override Sui CLI version for compilation | from Move.lock |
| --no-version-check | Skip compiler version matching | |
| -o, --output <format> | table or json | table |
| -v, --verbose | Show build logs and byte diff details | |
| --no-cache | Skip cache, always re-clone and re-build | |
| --cache-dir <dir> | Custom cache directory | ~/.cache/sui-verify |
Batch verification
Verify multiple packages at once using a YAML config file:
npx tsx src/index.ts verify-batch --config packages.yamlpackages.yaml:
network: mainnet
packages:
- id: "0x1234...abcd"
repo: "https://github.com/example-org/protocol"
ref: "v1.0.0"
path: "contracts/core"
- id: "0x5678...ef01"
repo: "https://github.com/example-org/token"
ref: "main"Cache management
Cloned repositories are cached to speed up repeated verifications. Cached repos automatically expire after 7 days — on the next run, expired caches are purged and the repo is re-cloned fresh. On-chain bytecode is cached indefinitely since Sui packages are immutable.
Use --no-cache to bypass the cache entirely for a single run.
# Show cache size and location
npx tsx src/index.ts cache info
# Clear all cached data
npx tsx src/index.ts cache cleanHow it works
Overview
The tool resolves each of the two sources into a set of module bytecodes, then compares them:
| Source | How it resolves |
|---|---|
| --local | Builds the local Move package via sui move build, collects .mv files |
| --repo | Clones the GitHub repo at the specified ref, then builds like --local |
| --on-chain | Fetches the package object from Sui RPC, extracts each module's bytecode |
| --tx-bytes | Parses BCS transaction bytes locally, finds the Publish/Upgrade command, extracts module bytes |
When one of the sources is --on-chain, address substitution is applied: the compiled bytecode's zero-address placeholder is replaced with the real package address before comparison (matching Sui CLI's verify-source behavior).
Steps for a typical GitHub-vs-on-chain verification:
- Fetch on-chain bytecode via Sui RPC
- Clone repo at the specified ref (cached, 7-day TTL)
- Detect compiler version from
Move.lock, switch viasuiupif needed - Prepare source — rewrite self-address to
0x0inMove.toml - Build via
sui move build, collect.mvfiles - Address substitution & compare — substitute real address into compiled bytecode's
address_identifierstable, compare byte-for-byte - Report with per-module match/mismatch classification
How bytecode comparison works (in detail)
The comparison is not just a size check — it is a full byte-for-byte comparison of the entire bytecode content for every module. The approach follows the same logic as the official Sui CLI's verify-source command. Here is exactly what happens:
The address problem
When you compile a Move package locally, the self-address in Move.toml is set to "0x0". This means the compiled .mv bytecode files contain 0x0000...0000 (32 zero bytes) in the module's address_identifiers table where the package references its own address.
When that same package is published on-chain, Sui replaces the zero-address placeholder with the actual package object ID. So the on-chain bytecode contains the real address (e.g. 0xabcd...1234) in those same positions.
If we compared them directly, they would always mismatch — even if the source code is identical.
The address substitution step
To solve this, the tool substitutes the real package address into the compiled bytecode before comparing — the same approach the official Sui CLI uses:
- Parse the compiled
.mvbytecode binary header to locate theaddress_identifierstable. Move bytecode has a well-defined binary layout:- 4 bytes magic (
0xA11CEB0B) - 4 bytes version (u32 little-endian)
- Table count (ULEB128)
- Table headers: each has a kind byte + offset + count (ULEB128-encoded)
- Table data sections follow
- 4 bytes magic (
- Find the table with kind
5(ADDRESS_IDENTIFIERS). Each entry is exactly 32 bytes (anAccountAddress). - For each entry in that table that is the zero-address (
0x00...00), replace it with the real package address. - The result is a "substituted" compiled bytecode that should now be identical to the on-chain bytecode.
Compiled bytecode: [..., address_identifiers: [0x0000...0000, 0x0000...0002], ...]
^^^^^^^^^^^^^
self-address = 0x0 → substitute real address
vvvvvvvvvvvvv
After substitution: [..., address_identifiers: [0xabcd...1234, 0x0000...0002], ...]
On-chain bytecode: [..., address_identifiers: [0xabcd...1234, 0x0000...0002], ...]
✔ exact matchThis is more precise than doing a blanket find-and-replace across the entire bytecode. It only touches entries in the address_identifiers table, so it will never accidentally replace bytes that happen to be zero elsewhere in the bytecode (e.g. in instruction operands or string data).
Why this matches the Sui CLI approach
The official Sui CLI's verify-source (implemented in the sui-source-validation crate) does the same thing:
- Compiles local source (self-address =
0x0) - Deserializes the compiled bytecode into a
CompiledModulestruct - Locates the self-address entry in the
address_identifierstable - Substitutes
0x0→ real package address - Compares the result against the on-chain deserialized
CompiledModuleusing structural equality
Our tool does the equivalent in TypeScript: we parse the binary header to find the address_identifiers table and perform the same substitution, then compare the raw bytes directly. The Sui CLI operates on deserialized Rust structs; we operate on the raw binary — but since Move bytecode serialization is deterministic, the result is the same.
The comparison
After address substitution, the tool compares module-by-module:
Module name matching — Check that both sides have the exact same set of module names. If the on-chain package has a module
lendingbut the compiled source does not (or vice versa), that is a mismatch.Byte-for-byte comparison — For each module present in both, compare the substituted compiled bytes against the on-chain bytes at every single byte position. Both the length and content must be identical. Even a single byte difference means the module does not match.
Diff reporting — If a module does not match, the tool records the byte offsets where differences occur (up to 20). With
--verbose, these are shown to help diagnose the cause (e.g. a different compiler version typically produces differences throughout the bytecode, while a source code change produces localized differences).
What this proves
If all modules pass the byte-for-byte comparison:
- The on-chain bytecode was compiled from the exact same source code at the given git ref.
- Using the same compiler version and same dependencies.
- No code was added, removed, or modified between the source and what was published.
If any module fails:
- The source code may have been modified after the commit that was published.
- A different compiler version may have been used (different versions produce different bytecode even from identical source).
- Dependencies may have changed (different dependency versions produce different bytecode).
Why only the self-address is substituted
Dependency addresses (e.g. 0x2 for Sui Framework) are the same in both compiled and on-chain bytecode — they are real published addresses specified in Move.toml. Only the package's own address differs between compilation (0x0) and on-chain (real address), so that is the only address that needs substitution. The tool specifically targets zero-address entries in the address_identifiers table and leaves all other addresses untouched.
CI/CD integration
The CLI uses exit codes for automation:
| Exit code | Meaning |
|---|---|
| 0 | All modules verified |
| 1 | One or more modules failed verification |
| 2 | Error (invalid input, network failure, build failure) |
Use --output json for machine-readable output:
npx tsx src/index.ts verify 0x1234...abcd \
--repo https://github.com/example-org/protocol \
--ref v1.0.0 \
--output jsonPrivate repositories
Set the GITHUB_TOKEN environment variable to clone private repos:
GITHUB_TOKEN=ghp_xxx npx tsx src/index.ts verify ...Troubleshooting
Compiler version mismatch
If verification fails, the most common cause is a compiler version mismatch. The tool reads the expected version from Move.lock and attempts to switch automatically via suiup. If that fails:
# Install the required version manually
suiup install [email protected]
# Or skip version checking (may produce false negatives)
npx tsx src/index.ts verify ... --no-version-checkMultiple Move packages in repository
If the repo contains multiple Move.toml files, specify which package to verify:
npx tsx src/index.ts verify ... --path contracts/coreBuild failures
Ensure all dependencies in Move.toml are accessible. Local path dependencies must be contained within the repository. Git dependencies must point to valid URLs and revisions.
Using as a library
Install as a dependency in your project:
npm install sui-move-bytecode-verifierHigh-level: full verification
import { verifyPackage } from "sui-move-bytecode-verifier";
const result = await verifyPackage({
sourceA: { kind: "repo", url: "https://github.com/org/repo", ref: "v1.0.0" },
sourceB: { kind: "onchain", packageId: "0xabc..." },
network: "mainnet",
noVersionCheck: false,
verbose: false,
noCache: false,
cacheDir: "/tmp/sui-verify-cache",
});
console.log(result.verified); // true or false
console.log(result.comparison.moduleResults); // per-module detailsLow-level: individual steps
Use the building blocks directly for custom pipelines:
import {
createSuiClient,
fetchOnChainPackage,
buildPackage,
compareModules,
extractTxModules,
prepareForVerification,
parseMoveLock,
} from "sui-move-bytecode-verifier";
// Fetch on-chain bytecodes
const client = createSuiClient("mainnet");
const onChainPkg = await fetchOnChainPackage("0xabc...", client);
// Build a local package
prepareForVerification("./my-pkg/Move.toml", "0xabc...");
const buildResult = await buildPackage("./my-pkg");
// Compare
const result = compareModules(
buildResult.modules,
onChainPkg.moduleMap,
"0xabc...",
);
console.log(result.match);
for (const mod of result.moduleResults) {
console.log(`${mod.name}: ${mod.mismatchKind}`);
}
// Extract modules from unsigned transaction bytes
const txModules = extractTxModules(base64TxBytes);
// Parse Move.lock for compiler version
const lockInfo = parseMoveLock("./my-pkg/Move.lock");
console.log(lockInfo?.compilerVersion);Exported API
| Export | Description |
|---|---|
| verifyPackage(options) | High-level: resolve two sources, compare, return result |
| fetchOnChainPackage(id, client) | Fetch package bytecodes from Sui RPC |
| extractTxModules(base64Bytes) | Extract module bytecodes from BCS transaction bytes |
| buildPackage(path, suiBinary?) | Run sui move build and collect .mv files |
| compareModules(a, b, packageId?) | Compare two module maps with mismatch classification |
| cloneAndCheckout(url, ref, cacheDir, noCache) | Clone a git repo with caching |
| parseMoveToml(path) | Parse a Move.toml file |
| prepareForVerification(path, packageId) | Rewrite self-address to 0x0 for compilation |
| parseMoveLock(path) | Parse Move.lock for compiler version |
| createSuiClient(network, rpcUrl?) | Create a SuiClient for a network |
| ensureSuiVersion(version, noCheck) | Match sui CLI version via suiup |
| setVerbose(enabled) | Enable/disable verbose logging |
| Error classes | VerifierError, NetworkError, BuildError, ConfigError, etc. |
Development
# Run tests
npm test
# Type check
npm run typecheck
# Build library + CLI
npm run build
# Run CLI in dev mode
npx tsx src/index.ts verify --help