diff --git a/.github/workflows/cdt.yml b/.github/workflows/cdt.yml new file mode 100644 index 0000000..6ea57f7 --- /dev/null +++ b/.github/workflows/cdt.yml @@ -0,0 +1,84 @@ +name: CDT + +# Builds examples/cdt with AntelopeIO CDT and runs it against VeRT. CDT ships +# Linux x86_64 packages only, so this is the supported way to exercise the +# suite. The blanc-built example suites are not run here, since blanc has to be +# installed separately. + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +env: + # v4.1.0 is the first release that exports the wasm memory VeRT reads. + CDT_VERSION: 4.1.1 + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 11 + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + + - name: Install AntelopeIO CDT + run: | + sudo apt-get update + + # CDT publishes its package from an older Ubuntu than the runner + # image, so its clang needs an ncurses release that newer images no + # longer package. + if ! sudo apt-get install -y libtinfo5; then + pool=http://archive.ubuntu.com/ubuntu/pool/universe/n/ncurses + deb=$(curl -fsSL "$pool/" | grep -o 'libtinfo5_[^"]*_amd64\.deb' | sort -uV | tail -1) + curl -fsSL -O "$pool/$deb" + sudo apt-get install -y "./$deb" + fi + + curl -fsSL -O "https://github.com/AntelopeIO/cdt/releases/download/v${CDT_VERSION}/cdt_${CDT_VERSION}-1_amd64.deb" + sudo apt-get install -y "./cdt_${CDT_VERSION}-1_amd64.deb" + + - run: pnpm install --frozen-lockfile + + # The examples resolve @proton/vert through dist, so build the package first. + - run: pnpm run build + + - run: pnpm run lint + + - name: Test library + run: pnpm exec mocha + + - name: Build examples/cdt with cdt-cpp + run: pnpm --filter examples run build:cdt + + - name: Report the exports cdt-cpp produced + run: | + node -e ' + const fs = require("fs"); + const module_ = new WebAssembly.Module(fs.readFileSync("examples/cdt/cdt.wasm")); + console.log(WebAssembly.Module.exports(module_)); + ' + + - name: Test examples/cdt + run: pnpm --filter examples run test:cdt + + # Lets contributors without a Linux toolchain download a CDT build and run + # the suite locally. + - uses: actions/upload-artifact@v4 + if: always() + with: + name: cdt-contract + path: | + examples/cdt/cdt.wasm + examples/cdt/cdt.abi + if-no-files-found: warn diff --git a/README.md b/README.md index 72e202f..6c0c346 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,41 @@ The focus of VeRT is on the better compatibility than the performance, so it can ## Requirement -- WebAssembly binary with the exported memory ([blanc](https://github.com/haderech/blanc) v0.9.2 or higher) +- WebAssembly binary with the exported memory, built with either [AntelopeIO CDT](https://github.com/AntelopeIO/cdt) v4.1.0 or higher, or [blanc](https://github.com/haderech/blanc) v0.9.2 or higher - JavaScript runtime with WebAssembly BigInt support (nodejs v16 or higher) +## Contract toolchains + +VeRT runs an action by calling the `apply` export of the contract binary, and reads contract state +by looking straight into the module's linear memory. Both of those have to be exported from the +WebAssembly binary, and that requirement is what ties VeRT to a particular toolchain version: +neither AntelopeIO CDT before v4.1.0 nor the original `eosio.cdt` exports the memory, so a binary +built with them is rejected with a message asking for a rebuild. + +Apart from the memory export, the two supported toolchains are interchangeable as far as VeRT is +concerned. Both compile the same C++ contract sources against the same host API, and both emit the +`.abi` next to the `.wasm`. + +| | AntelopeIO CDT | blanc | +| ------------------------------ | ------------------------------ | ---------------------------------------------------- | +| Compiler | `cdt-cpp` | `blanc++` | +| Minimum version VeRT can load | v4.1.0 | v0.9.2 | +| Exports in the built binary | `apply`, `memory` | `apply`, `memory`, `__heap_base`, `__data_end` | +| Example suite | [examples/cdt](./examples/cdt) | [examples/foo](./examples/foo), and the other folders | + +The extra exports blanc emits are unused by VeRT, which is why AntelopeIO CDT became usable the +moment it started exporting the memory: v4.1.0 links contracts with `--only-export *:memory` in +addition to the `--only-export apply:function` it already used. + +Two differences are worth keeping in mind when writing tests against an AntelopeIO CDT build: + +- CDT defaults to ABI version `eosio::abi/1.2`, so actions that return a value are declared as + `action_results` in the ABI. VeRT captures the serialized value per action in + `blockchain.actionTraces[n].returnValue`, but does not resolve the result type from the ABI, so + decode it with an explicit type. +- VeRT implements the Antelope host API, excluding the BLS intrinsics added in CDT v4.x. A contract + that calls them fails to instantiate with a `LinkError` naming the missing `env` import. + ## Installation ```shell @@ -35,6 +67,30 @@ pnpm install pnpm run test ``` +This runs the library tests, followed by the example suites. +[src/proton/tests/cdt.spec.ts](./src/proton/tests/cdt.spec.ts) covers the one case no installed +toolchain produces any more: a contract built before CDT v4.1.0, which leaves its memory +unexported. + +Example binaries are not checked in, so build them first. Each suite is built by the toolchain it +demonstrates, which means `pnpm --filter examples run build` needs both compilers on the `PATH`; +build a single suite instead if you only have one of them installed: + +```shell +# examples/cdt, built with cdt-cpp; its suite is skipped while the binary is missing. +# When invoking cdt-cpp by hand, pass the source path without a leading ./ — with one, +# cdt-cpp loses track of the dispatcher it generates and the link aborts. +pnpm --filter examples run build:cdt + +# examples/foo, built with blanc++ +pnpm --filter examples run build:foo +``` + +CDT is distributed as a Linux x86_64 package only, so the +[CDT workflow](./.github/workflows/cdt.yml) builds `examples/cdt` on a runner and uploads the +resulting `cdt.wasm` and `cdt.abi`. Dropping those two files into `examples/cdt` is enough to run +the suite on a machine without the toolchain. + ## License [MIT](./LICENSE) diff --git a/examples/cdt/cdt.cpp b/examples/cdt/cdt.cpp new file mode 100644 index 0000000..3d37255 --- /dev/null +++ b/examples/cdt/cdt.cpp @@ -0,0 +1,56 @@ +#include + +#include + +using namespace eosio; + +class [[eosio::contract]] cdt : public contract { +public: + using contract::contract; + + struct [[eosio::table]] data { + name owner; + int64_t value; + + uint64_t primary_key() const { return owner.value; } + uint64_t by_value() const { return (uint64_t)value; } + }; + + typedef multi_index<"data"_n, data, + indexed_by<"byvalue"_n, const_mem_fun> + > data_index; + + [[eosio::action]] + void store(name owner, int64_t value) + { + require_auth(owner); + + // const char* messages are reported through eosio_assert + check(value >= 0, "require non-negative value"); + // std::string messages are reported through eosio_assert_message + check(value <= 100, std::string("value is out of range")); + + data_index di(get_self(), get_self().value); + auto it = di.find(owner.value); + + if (it == di.end()) { + di.emplace(owner, [&](auto& d) { + d.owner = owner; + d.value = value; + }); + } else { + di.modify(it, same_payer, [&](auto& d) { + d.value = value; + }); + } + } + + // Action return values are serialized with set_action_return_value and + // declared as action_results in the ABI, which CDT emits at version 1.2. + [[eosio::action]] + int64_t sum(int64_t a, int64_t b) + { + print(a + b); + return a + b; + } +}; diff --git a/examples/cdt/cdt.spec.ts b/examples/cdt/cdt.spec.ts new file mode 100644 index 0000000..2c1633b --- /dev/null +++ b/examples/cdt/cdt.spec.ts @@ -0,0 +1,102 @@ +import fs from "fs"; +import path from "path"; +import { expect } from "chai"; +import { Int64, Name, Serializer } from "@greymass/eosio" +import { Account, Blockchain, expectToThrow, nameToBigInt, protonAssert, protonAssertMessage } from "@proton/vert"; + +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const wasmPath = path.join(__dirname, 'cdt.wasm') +const abiPath = path.join(__dirname, 'cdt.abi') +const isBuilt = fs.existsSync(wasmPath) && fs.existsSync(abiPath) + +const contractName = Name.from('cdt') +const scope = nameToBigInt(contractName) +const alice = nameToBigInt(Name.from('alice')) + +describe('cdt_test', function () { + let blockchain: Blockchain + let cdt: Account + + before(function () { + if (!isBuilt) { + console.warn(`\n cdt.wasm/cdt.abi are missing.` + + `\n Run 'pnpm run build:cdt' with AntelopeIO CDT v4.1.0 or higher to run these tests.\n`) + this.skip() + } + + blockchain = new Blockchain() + cdt = blockchain.createAccount({ + name: contractName, + wasm: fs.readFileSync(wasmPath), + abi: fs.readFileSync(abiPath, 'utf8') + }) + blockchain.createAccounts('alice', 'bob') + }) + + beforeEach(() => { + blockchain.resetTables() + }); + + it('store value', async () => { + await cdt.actions.store(['alice', 7]).send('alice@active'); + + expect(cdt.tables.data(scope).getTableRow(alice)).to.be.deep.equal({ + owner: 'alice', + value: 7 + }) + }); + + it('update stored value', async () => { + await cdt.actions.store(['alice', 7]).send('alice@active'); + await cdt.actions.store(['alice', 8]).send('alice@active'); + + expect(cdt.tables.data(scope).getTableRows()).to.be.deep.equal([{ + owner: 'alice', + value: 8 + }]) + }); + + it('charge the ram payer given to emplace', async () => { + await cdt.actions.store(['alice', 7]).send('alice@active'); + + const storage = blockchain.getStorage() as Record + const [row] = storage.cdt.data.cdt + + expect(row.primaryKey).to.be.equal(alice) + expect(row.payer).to.be.equal('alice') + }); + + it('require authorization', async () => { + await expectToThrow( + cdt.actions.store(['alice', 7]).send('bob@active'), + 'missing required authority alice' + ) + }); + + it('reject a value asserted with a const char* message', async () => { + await expectToThrow( + cdt.actions.store(['alice', -1]).send('alice@active'), + protonAssert('require non-negative value') + ) + }); + + it('reject a value asserted with a std::string message', async () => { + await expectToThrow( + cdt.actions.store(['alice', 101]).send('alice@active'), + protonAssertMessage('value is out of range') + ) + }); + + it('read an action return value', async () => { + await cdt.actions.sum([2, 3]).send(); + + expect(blockchain.console).to.be.equal('5') + + const [trace] = blockchain.actionTraces + expect(Serializer.decode({ data: trace.returnValue, type: Int64 }).toNumber()).to.be.equal(5) + }); +}); diff --git a/examples/package.json b/examples/package.json index 3a2d67d..dc2fea0 100644 --- a/examples/package.json +++ b/examples/package.json @@ -9,11 +9,13 @@ "build:foo": "blanc++ ./foo/foo.cpp -o ./foo/foo.wasm", "build:timer": "blanc++ ./timer/timer.cpp -o ./timer/timer.wasm", "build:fixtures": "blanc++ ./fixtures/fixtures.cpp -o ./fixtures/fixtures.wasm", + "build:cdt": "cdt-cpp cdt/cdt.cpp -o cdt/cdt.wasm", "build": "run-p build:*", "test:foo": "mocha ./foo/foo.spec.ts", "test:inline": "mocha ./inline/inline.spec.ts -r ts-node/register", "test:timer": "mocha ./timer/timer.spec.ts -r ts-node/register", "test:fixtures": "mocha ./fixtures/fixtures.spec.ts", + "test:cdt": "mocha ./cdt/cdt.spec.ts", "test": "run-p test:*" }, "author": "Jeeyong Um ", diff --git a/src/proton/tests/cdt.spec.ts b/src/proton/tests/cdt.spec.ts new file mode 100644 index 0000000..0422a0b --- /dev/null +++ b/src/proton/tests/cdt.spec.ts @@ -0,0 +1,110 @@ +import fs from "fs"; +import { expect } from "chai"; +import { Blockchain } from "../blockchain"; +import { VM } from "../vm"; +import Buffer from "../../buffer"; + +/** + * AntelopeIO CDT exports the wasm memory VeRT reads only since v4.1.0; earlier + * releases and the original eosio.cdt leave it unexported. A current toolchain + * is covered by the CDT workflow, which builds examples/cdt and runs it, so + * what is left to pin down here is the older output, reproduced by stripping + * the memory export from the committed blanc binary. + */ + +const EXPORT_SECTION = 7 +const MEMORY_EXPORT = 2 + +const wasm = fs.readFileSync('contracts/eosio.token/eosio.token.wasm') + +function readVarUInt(bytes: Uint8Array, offset: number): [value: number, next: number] { + let value = 0 + let shift = 0 + let cursor = offset + let byte: number + + do { + byte = bytes[cursor++] + value |= (byte & 0x7f) << shift + shift += 7 + } while (byte & 0x80) + + return [value >>> 0, cursor] +} + +function writeVarUInt(value: number): Uint8Array { + const bytes: number[] = [] + + do { + const byte = value & 0x7f + value >>>= 7 + bytes.push(value ? byte | 0x80 : byte) + } while (value) + + return new Uint8Array(bytes) +} + +function eachSection(wasm: Uint8Array, visit: (id: number, body: Uint8Array) => void) { + let cursor = 8 + + while (cursor < wasm.length) { + const id = wasm[cursor++] + const [size, bodyStart] = readVarUInt(wasm, cursor) + visit(id, wasm.subarray(bodyStart, bodyStart + size)) + cursor = bodyStart + size + } +} + +/** + * Rebuilds a wasm binary with its export section reduced to the entries + * accepted by `keep`, which lets a single binary stand in for the output of + * different contract toolchains. + */ +function keepExports(wasm: Uint8Array, keep: (name: string, kind: number) => boolean): Uint8Array { + const chunks: Uint8Array[] = [wasm.subarray(0, 8)] + + eachSection(wasm, (id, body) => { + if (id !== EXPORT_SECTION) { + chunks.push(new Uint8Array([id]), writeVarUInt(body.length), body) + return + } + + const kept: Uint8Array[] = [] + let [count, cursor] = readVarUInt(body, 0) + + while (count--) { + const entryStart = cursor + const [length, nameStart] = readVarUInt(body, cursor) + const name = Buffer.from_(body.slice(nameStart, nameStart + length)).toString() + const kind = body[nameStart + length]; + [, cursor] = readVarUInt(body, nameStart + length + 1) + + if (keep(name, kind)) { + kept.push(body.subarray(entryStart, cursor)) + } + } + + const section = Buffer.concat([writeVarUInt(kept.length), ...kept]) + chunks.push(new Uint8Array([id]), writeVarUInt(section.length), section) + }) + + return Buffer.concat(chunks) +} + +const legacyCdtWasm = keepExports(wasm, (_, kind) => kind !== MEMORY_EXPORT) + +describe('antelope cdt', () => { + it('reports a rebuild is needed when memory is not exported', async () => { + const vm = VM.from(legacyCdtWasm, new Blockchain()) + + let message = '' + try { + await vm.ready + } catch (e) { + message = (e as Error).message + } + + expect(message).to.contain('does not export its memory') + expect(message).to.contain('AntelopeIO CDT v4.1.0 or higher') + }) +}) diff --git a/src/vert.ts b/src/vert.ts index 0cfc559..7eca8f1 100644 --- a/src/vert.ts +++ b/src/vert.ts @@ -30,7 +30,14 @@ export class Vert { const { module, instance } = await WebAssembly.instantiate(bytes as BufferSource, imports) this.module = module; this.instance = instance; - this._memory = new Memory(this.instance.exports.memory as WebAssembly.Memory); + const memory = this.instance.exports.memory + if (!(memory instanceof WebAssembly.Memory)) { + throw new Error( + 'contract wasm does not export its memory, so its state cannot be read. ' + + 'Rebuild it with AntelopeIO CDT v4.1.0 or higher, or blanc v0.9.2 or higher.' + ) + } + this._memory = new Memory(memory); } this.ready = getReady(); }