Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .changeset/evm2-call-execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"ox": minor
---

Added `ox/evm`, an EVM backed by `alloy-rs/evm2` compiled to WebAssembly, with read-only transaction execution.
Added `ox/evm`, an EVM backed by `alloy-rs/evm2` compiled to WebAssembly, with read-only transaction execution; every operation reads its inputs when it is submitted, so mutating them afterwards cannot change what runs.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Snapshot pending state before queueing commitSource

With an asynchronous database, this new guarantee is still false for Evm.commitSource: src/evm/Evm.ts:1253-1255 calls PendingState.changes(state) only when the queued callback runs, while PendingState.insertAccount retains the caller's mutable current and original objects. Mutating either object after commitSource returns but before the queue drains silently changes the state that gets applied; capture a deep snapshot at submission as the other state-bearing operations now do.

AGENTS.md reference: AGENTS.md:L49-L49

Useful? React with 👍 / 👎.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@
"wasm/evm2/LICENSE-APACHE",
"wasm/evm2/LICENSE-MIT",
"wasm/evm2/NOTICE.md",
"wasm/evm2/THIRD-PARTY-LICENSES.md",
"wasm/vendor/c-kzg-4844/LICENSE",
"wasm/vendor/c-kzg-4844/blst/LICENSE",
"wasm/vendor/mldsa-native/LICENSE"
Expand Down
144 changes: 139 additions & 5 deletions scripts/wasm/build-evm2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { resolveWasmOpt, root, toolchain as pinned } from './toolchain.js'
const crate = path.join(root, 'wasm/evm2')
const out = 'src/wasm/internal/evm2.wasm.ts'
const notice = 'wasm/evm2/NOTICE.md'
const thirdParty = 'wasm/evm2/THIRD-PARTY-LICENSES.md'
const target = 'wasm32-unknown-unknown'

/** Where the container drops the artifact, kept out of cargo's own target dir. */
Expand Down Expand Up @@ -137,6 +138,7 @@ function compile(revision: string): Compiled {
artifact: path.join(crate, `target/${target}/release/ox_evm2.wasm`),
licenses: hostLicenses(revision),
metadata: cargo(metadata),
texts: hostTexts(home),
rustc: child_process
.execFileSync('rustc', ['--version'], { cwd: crate, encoding: 'utf8' })
.trim(),
Expand Down Expand Up @@ -173,6 +175,20 @@ function compile(revision: string): Compiled {
`cargo ${metadata.join(' ')} > /out/metadata.json`,
'rustc --version > /out/rustc.txt',
`cp ${licenseDir}/LICENSE-MIT ${licenseDir}/LICENSE-APACHE /out/`,
// Every crate's own license text, keyed by directory name. The artifact
// is redistributed as compiled code, so an SPDX identifier alone does not
// satisfy the BSD, Zlib, and MIT terms it links. Copied wholesale here
// and filtered to what actually links when the notice is generated.
'mkdir -p /out/licenses',
'for dir in "$CARGO_HOME"/registry/src/*/*/ "$CARGO_HOME"/git/checkouts/*/*/ "$CARGO_HOME"/git/checkouts/*/*/*/; do' +
' [ -d "$dir" ] || continue;' +
' case "$dir" in *"/git/checkouts/"*) name="git-$(basename "$dir")";;' +
' *) name=$(basename "$dir");; esac;' +
' for file in "$dir"LICENSE* "$dir"COPYING* "$dir"UNLICENSE* "$dir"NOTICE*; do' +
' [ -f "$file" ] || continue;' +
' mkdir -p "/out/licenses/$name";' +
' cp "$file" "/out/licenses/$name/";' +
' done; done',
// The mounted directory is root-owned inside the container; hand it back
// so the host can read and replace it.
'chmod -R a+rwX /out',
Expand All @@ -191,6 +207,7 @@ function compile(revision: string): Compiled {
},
metadata: read('metadata.json'),
rustc: read('rustc.txt').trim(),
texts: licenseTexts(path.join(buildDir, 'licenses')),
}
}

Expand All @@ -209,12 +226,11 @@ function assertLockfile(revision: string) {
}

/**
* Builds the attribution notice from the resolved dependency graph.
* Returns the crates the artifact links, sorted and excluding the root.
*
* Generating it is what keeps it honest: a dependency added, removed, or
* relicensed shows up as a diff instead of going unrecorded.
* Shared by the notice and the license file so the two describe one set.
*/
function attribution(revision: string, raw: string) {
function linked(raw: string) {
type Package = {
id: string
license?: string | null
Expand Down Expand Up @@ -267,6 +283,121 @@ function attribution(revision: string, raw: string) {

// A crate resolved from git rather than crates.io is a fork, which is an
// attribution fact and not only a build detail.
return packages
}

/** Standard licenses whose text ships beside the notice. */
const standardLicenses = new Map([
['Apache-2.0', 'LICENSE-APACHE'],
['MIT', 'LICENSE-MIT'],
])

/** Collects license texts from the host Cargo cache, for the native build. */
function hostTexts(home: string): Map<string, string> {
const texts = new Map<string, string>()
const roots = [
{ git: false, path: path.join(home, 'registry', 'src') },
{ git: true, path: path.join(home, 'git', 'checkouts') },
]
for (const root of roots) {
if (!fs.existsSync(root.path)) continue
for (const outer of fs.readdirSync(root.path)) {
const directory = path.join(root.path, outer)
if (!fs.statSync(directory).isDirectory()) continue
for (const crate of fs.readdirSync(directory)) {
const source = path.join(directory, crate)
if (!fs.statSync(source).isDirectory()) continue
const files = fs
.readdirSync(source)
.filter((file) => /^(LICENSE|COPYING|UNLICENSE|NOTICE)/.test(file))
.sort()
.map(
(file) =>
`<!-- ${file} -->\n\n${fs.readFileSync(path.join(source, file), 'utf8').trim()}`,
)
if (files.length)
texts.set(root.git ? `git-${crate}` : crate, files.join('\n\n'))
}
}
}
return texts
}

/** Reads every collected license text, keyed by `name-version` directory. */
function licenseTexts(directory: string): Map<string, string> {
const texts = new Map<string, string>()
if (!fs.existsSync(directory)) return texts
for (const crate of fs.readdirSync(directory)) {
const files = fs
.readdirSync(path.join(directory, crate))
.sort()
.map((file) => {
const body = fs.readFileSync(path.join(directory, crate, file), 'utf8')
return `<!-- ${file} -->\n\n${body.trim()}`
})
if (files.length) texts.set(crate, files.join('\n\n'))
}
return texts
}

/**
* Builds the third-party license file from the crates the artifact links.
*
* The artifact ships as compiled code, so an SPDX identifier is not enough: the
* BSD, Zlib, and MIT terms it links require their copyright and license text to
* travel with the binary.
*/
function thirdPartyLicenses(
metadata: string,
texts: Map<string, string>,
): string {
const packages = linked(metadata)
const sections = packages.map((entry) => {
// A git dependency is stored under its revision rather than its version, so
// it is looked up by the revision its source records.
const revision = entry.source?.match(/[?#]rev=([0-9a-f]+)/)?.[1]
const text =
texts.get(`${entry.name}-${entry.version}`) ??
(revision
? (texts.get(`git-${revision}`) ??
texts.get(`git-${revision.slice(0, 7)}`))
: undefined)
const heading = `## \`${entry.name}\` ${entry.version}\n\nDeclared license: ${entry.license ?? 'see upstream'}.`
if (text) return `${heading}\n\n${text}`

// A crate publishing no file of its own still declares a license, and every
// one that does here is a standard text. Naming it and pointing at the copy
// beside this file distributes the terms without inventing a copyright line
// the crate never stated.
const standard = (entry.license ?? '')
.split(/\s+OR\s+/)
.map((name) => name.trim())
.filter((name) => standardLicenses.has(name))
return standard.length
? `${heading}\n\nThis crate publishes no license file. Its declared terms are the standard ${standard.join(' or ')} text, reproduced in ${standard.map((name) => `\`${standardLicenses.get(name)}\``).join(' and ')} beside this file. Copyright remains the crate authors'.`
: `${heading}\n\nNo license file ships with this crate; see its repository.`
})

return `<!-- Generated by \`pnpm wasm:build --target=evm2\`. Do not edit. -->

# Third-party licenses for \`src/wasm/internal/evm2.wasm.ts\`

The artifact is compiled code linking the crates below. Each one's own license
text is reproduced here, which distributing a binary requires.

${sections.join('\n\n')}
`
}

/**
* Builds the attribution notice from the resolved dependency graph.
*
* Generating it is what keeps it honest: a dependency added, removed, or
* relicensed shows up as a diff instead of going unrecorded.
*/
function attribution(revision: string, raw: string) {
const packages = linked(raw)

const rows = packages.map((entry) => {
const source = entry.source?.startsWith('git+')
? entry.source.replace(/^git\+/, '').replace(/\?rev=[0-9a-f]+#/, ' @ ')
Expand All @@ -284,7 +415,9 @@ The artifact is compiled from \`wasm/evm2\` against
committed beside this file as \`LICENSE-MIT\` and \`LICENSE-APACHE\`.

Everything compiled into the artifact is listed below, with the license each
crate declares.
crate declares. Each crate's own license text is reproduced in
\`THIRD-PARTY-LICENSES.md\` beside this file, which distributing compiled code
requires.

| Crate | Version | License | Source |
| --- | --- | --- | --- |
Expand Down Expand Up @@ -445,6 +578,7 @@ export async function buildEvm2(): Promise<Record<string, string>> {
]),
),
[notice]: attribution(revision, compiled.metadata),
[thirdParty]: thirdPartyLicenses(compiled.metadata, compiled.texts),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Populate license texts for native EVM2 builds

When OX_EVM2_NATIVE=1 selects the documented host-build path, compile() returns no texts property, but this unconditional call passes compiled.texts to thirdPartyLicenses, which immediately calls texts.get(...). The native build therefore crashes after completing the expensive Rust/WASM compilation instead of producing its outputs; collect the host Cargo-cache license texts in that branch and include them in Compiled.

AGENTS.md reference: AGENTS.md:L138-L138

Useful? React with 👍 / 👎.

[out]: template(bytes, {
features,
gzip,
Expand Down
7 changes: 6 additions & 1 deletion src/evm/BlockState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,9 @@ export type BlockState = codec.BlockState
* {@link ox#Evm.(startBlockState:function)} until
* {@link ox#Evm.(takeBlockState:function)}, which consumes it.
*/
export type Token = bigint & { readonly '~block'?: true }
export type Token = {
/** @internal */
readonly '~engine': unknown
/** @internal */
readonly '~id': bigint
}
26 changes: 0 additions & 26 deletions src/evm/Database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ export function fromMemory(options: fromMemory.Options = {}): Database {
const code = account.code
? Uint8Array.from(Bytes.from(account.code))
: undefined
if (code) assertCode(address, code)
accounts.set(address.toLowerCase(), {
balance: account.balance ?? 0n,
code,
Expand Down Expand Up @@ -196,31 +195,6 @@ export function fromAsync(source: async.Async): Async {
}
}

// Rejects code the engine would refuse to classify. `0xef01`-prefixed code is an
// EIP-7702 delegation designator: exactly 23 bytes with a zero version byte.
// Checking here fails at the account that declared it rather than as an opaque
// read failure during execution.
function assertCode(address: string, code: Bytes.Bytes) {
if (code[0] !== 0xef || code[1] !== 0x01) return
if (code.length === 23 && code[2] === 0x00) return
throw new InvalidDesignatorError({ address, length: code.length })
}

/** Thrown when an account's delegation designator is malformed. */
export class InvalidDesignatorError extends Errors.BaseError {
override readonly name = 'Database.InvalidDesignatorError'

constructor({ address, length }: { address: string; length: number }) {
super('An account declared a malformed delegation designator.', {
metaMessages: [
`Account: ${address}`,
`Length: ${length} bytes, expected 23`,
'Code beginning `0xef01` is an EIP-7702 designator: `0xef0100` and a 20-byte address.',
],
})
}
}

/** Thrown when a `BLOCKHASH` the chain retains was never seeded. */
export class MissingBlockHashError extends Errors.BaseError {
override readonly name = 'Database.MissingBlockHashError'
Expand Down
Loading
Loading