Compare commits

..

8 Commits

Author SHA1 Message Date
Mayukha Vadari
84c47bb55e fix clang-tidy issues 2026-07-22 18:54:59 -04:00
Mayukha Vadari
5c64d9f7c0 Merge branch 'develop' into mvadari/error-message-ter 2026-07-22 18:16:54 -04:00
Mayukha Vadari
b765e78e94 add test 2026-07-22 18:14:55 -04:00
Mayukha Vadari
2c69d58f26 export error reason via submit/simulate 2026-07-22 18:09:19 -04:00
Mayukha Vadari
445574089f migrate preflight errors 2026-07-22 17:57:55 -04:00
Mayukha Vadari
b5bd12dec0 migrate preflights 2026-07-22 17:45:10 -04:00
Mayukha Vadari
2da94043f2 migrate transactors 2026-07-22 17:07:57 -04:00
Mayukha Vadari
0621841912 make TER changes 2026-07-22 17:07:18 -04:00
956 changed files with 34622 additions and 84305 deletions

View File

@@ -75,8 +75,6 @@ Checks: "-*,
# readability-static-accessed-through-instance, # this check is probably unnecessary. It makes the code less readable
# ---
FormatStyle: file
CheckOptions:
bugprone-unsafe-functions.ReportMoreUnsafeFunctions: true
bugprone-unused-return-value.CheckedReturnTypes: ::std::error_code;::std::error_condition;::std::errc
@@ -85,8 +83,6 @@ CheckOptions:
readability-braces-around-statements.ShortStatementLines: 2
readability-identifier-naming.MacroDefinitionCase: UPPER_CASE
readability-identifier-naming.NamespaceCase: lower_case
readability-identifier-naming.InlineNamespaceCase: lower_case
readability-identifier-naming.ClassCase: CamelCase
readability-identifier-naming.StructCase: CamelCase
readability-identifier-naming.UnionCase: CamelCase

View File

@@ -1,32 +1,10 @@
codecov:
require_ci_to_pass: true
# The C++ and Rust uploads land minutes apart; without this gate Codecov
# publishes a near-zero total from whichever one arrives first.
notify:
after_n_builds: 2
wait_for_ci: true
comment:
behavior: default
layout: reach,diff,flags,tree,reach
show_carryforward_flags: true
after_n_builds: 2
# C++ and Rust coverage upload from independent workflows under the `cpp` and
# `rust` flags; carryforward keeps one language's total when only the other reran.
flag_management:
default_rules:
carryforward: true
individual_flags:
- name: cpp
carryforward: true
paths:
- include/
- src/
- name: rust
carryforward: true
paths:
- crates/
show_carryforward_flags: false
coverage:
range: "70..85"

View File

@@ -7,8 +7,6 @@ ignorePaths:
- cmake/**
- LICENSE.md
- .clang-tidy
- src/test/app/wasm_fixtures/*.c
- nix/check-tools/*.txt # generated, and full of Nix store hashes
language: en
allowCompoundWords: true # TODO (#6334)
ignoreRandomStrings: true
@@ -32,9 +30,7 @@ ignoreRegExpList:
- ABCDEFGHIJKLMNOPQRSTUVWXYZ
- ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
overrides:
- filename:
- "**/*_test.cpp" # legacy boost.test files
- "src/tests/**/*.cpp" # gtest test files
- filename: "**/*_test.cpp" # all test files
ignoreRegExpList:
- /"[^"]*"/g # double-quoted strings
- /'[^']*'/g # single-quoted strings
@@ -69,9 +65,7 @@ words:
- Btrfs
- Buildx
- canonicality
- cdylib
- canonicalised
- cctools
- changespq
- checkme
- choco
@@ -107,14 +101,12 @@ words:
- deleteme
- demultiplexer
- deserializaton
- desugars
- desync
- desynced
- determ
- disablerepo
- distro
- doxyfile
- dsymutil
- dxrpl
- elgamal
- enabled
@@ -134,19 +126,15 @@ words:
- gcov
- gcovr
- ghead
- gmock
- Gnutella
- godexsoft
- gpgcheck
- gpgkey
- Hinnant
- hotwallet
- hwaddress
- hwrap
- ifndef
- impls
- inequation
- initialiser
- insuf
- insuff
- invasively
@@ -175,8 +163,6 @@ words:
- llection
- LOCALGOOD
- logwstream
- Lombrozo
- lresolv
- lseq
- lsmf
- ltype
@@ -186,6 +172,7 @@ words:
- MPTAMM
- MPTDEX
- Merkle
- Metafuncton
- misprediction
- missingok
- mptbalance
@@ -213,7 +200,6 @@ words:
- nftokens
- nftpage
- nikb
- Nikolaos
- nixfmt
- nixos
- nixpkgs
@@ -230,7 +216,6 @@ words:
- Nyffenegger
- onlatest
- ostr
- otool
- oxalica
- pargs
- partitioner
@@ -256,20 +241,15 @@ words:
- pyparsing
- qalloc
- qbsprofile
- qself
- queuable
- Raphson
- rcflags
- replayer
- repodata
- repomd
- rerandomize
- rerandomization
- rerandomized
- rerandomizes
- rerere
- retargeted
- retargets
- retriable
- RIPD
- ripdtop
@@ -305,7 +285,6 @@ words:
- sles
- soci
- socidb
- Sonatype
- sponsee
- sponsees
- SRPMS
@@ -314,7 +293,6 @@ words:
- STATSDCOLLECTOR
- stissue
- stnum
- stnumber
- stobj
- stobject
- stpath
@@ -326,7 +304,6 @@ words:
- summands
- superpeer
- superpeers
- Swatinem
- takergets
- takerpays
- ters
@@ -355,7 +332,6 @@ words:
- unflatten
- unfund
- unimpair
- unmetered
- unroutable
- unscalable
- unserviced
@@ -376,8 +352,6 @@ words:
- vfalco
- vinnie
- wasmi
- wasmparser
- Werror
- wextra
- wptr
- writeme
@@ -385,16 +359,12 @@ words:
- wthread
- xbridge
- xchain
- xcrun
- xfloat
- ximinez
- XMACRO
- xored
- xrpkuwait
- xrpl
- xrpld
- xrplf
- xxhash
- xxhasher
- zstdio
- CGNAT

6
.envrc
View File

@@ -1,7 +1 @@
watch_file nix/*.nix
# The dev shell derivation includes all of conan/ (see nix/devshell.nix), so any
# change in there has to invalidate direnv's cached environment.
watch_dir conan
use flake

View File

@@ -1,38 +0,0 @@
name: Use cargo artifacts cache
description: >
Cache the cargo build artifacts with rust-cache. Never caches ~/.cargo/bin:
when saving the cache, rust-cache deletes all binaries that were already
present there, which on persistent self-hosted runners wipes the tools
installed by prepare-runner. Harmless on ephemeral runners, but kept
consistent everywhere.
inputs:
workspaces:
description: "Workspaces to cache, as 'workspace -> target' lines."
required: false
default: crates
key:
description: "Additional part of the cache key."
required: false
default: ""
cache-directories:
description: "Additional non-workspace directories to cache."
required: false
default: ""
save-if:
description: "Condition for saving the cache after the job."
required: false
default: "true"
runs:
using: composite
steps:
- name: Use cargo artifacts cache
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
cache-bin: "false"
cache-directories: ${{ inputs.cache-directories }}
key: ${{ inputs.key }}
save-if: ${{ inputs.save-if }}
workspaces: ${{ inputs.workspaces }}

View File

@@ -0,0 +1,44 @@
name: Generate build version number
description: "Generate build version number."
outputs:
version:
description: "The generated build version number."
value: ${{ steps.version.outputs.version }}
runs:
using: composite
steps:
# When a tag is pushed, the version is used as-is.
- name: Generate version for tag event
if: ${{ startsWith(github.ref, 'refs/tags/') }}
shell: bash
env:
VERSION: ${{ github.ref_name }}
run: echo "VERSION=${VERSION}" >>"${GITHUB_ENV}"
# When a tag is not pushed, then the version (e.g. 1.2.3-b0) is extracted
# from the BuildInfo.cpp file and the shortened commit hash appended to it.
# We use a plus sign instead of a hyphen because Conan recipe versions do
# not support two hyphens.
- name: Generate version for non-tag event
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
shell: bash
run: |
echo 'Extracting version from BuildInfo.cpp.'
VERSION="$(cat src/libxrpl/protocol/BuildInfo.cpp | grep "versionString =" | awk -F '"' '{print $2}')"
if [[ -z "${VERSION}" ]]; then
echo 'Unable to extract version from BuildInfo.cpp.'
exit 1
fi
echo 'Appending shortened commit hash to version.'
SHA='${{ github.sha }}'
VERSION="${VERSION}+${SHA:0:7}"
echo "VERSION=${VERSION}" >>"${GITHUB_ENV}"
- name: Output version
id: version
shell: bash
run: echo "version=${VERSION}" >>"${GITHUB_OUTPUT}"

View File

@@ -1,90 +0,0 @@
name: Release info
description: "Derive the version, release channel and package release number for this build."
outputs:
version:
description: "The build version number."
value: ${{ steps.version.outputs.version }}
channel:
description: "The release channel this build belongs to."
value: ${{ steps.channel.outputs.channel }}
pkg_release:
description: "The package release number: 1 for a tag, the run number otherwise."
value: ${{ steps.pkg_release.outputs.pkg_release }}
runs:
using: composite
steps:
# A tag names its own version. Anything else takes it from BuildInfo.cpp and
# appends the commit hash as build metadata, joined with a plus sign because a
# Conan version cannot contain two hyphens.
- name: Determine version
id: version
shell: bash
env:
IS_TAG: ${{ startsWith(github.ref, 'refs/tags/') }}
REF_NAME: ${{ github.ref_name }}
SHA: ${{ github.sha }}
run: |
if [[ "${IS_TAG}" == "true" ]]; then
version="${REF_NAME}"
else
version="$(awk -F'"' '/versionString =/ { print $2 }' src/libxrpl/protocol/BuildInfo.cpp)"
if [[ -z "${version}" ]]; then
echo "Unable to read versionString from BuildInfo.cpp." >&2
exit 1
fi
version="${version}+${SHA:0:7}"
fi
echo "version=${version}" | tee -a "${GITHUB_OUTPUT}"
# Only a tag says how mature a build is: a push is a develop build whatever
# its version, and a non-public codebase keeps its packages to itself.
- name: Determine release channel
id: channel
shell: bash
env:
IS_TAG: ${{ startsWith(github.ref, 'refs/tags/') }}
REF_NAME: ${{ github.ref_name }}
VISIBILITY: ${{ github.event.repository.visibility }}
run: |
pre_release=""
if [[ "${REF_NAME}" == *-* ]]; then
pre_release="${REF_NAME#*-}"
fi
if [[ "${VISIBILITY}" != "public" ]]; then
channel=private
elif [[ "${IS_TAG}" != "true" ]]; then
channel=develop
elif [[ -z "${pre_release}" ]]; then
channel=stable
elif [[ "${pre_release}" =~ ^rc[0-9]+(\+.*)?$ ]]; then
channel=unstable
elif [[ "${pre_release}" =~ ^b(0|[1-9][0-9]*)(\+.*)?$ ]]; then
channel=experimental
else
echo "Unsupported pre-release in tag '${REF_NAME}'. Use bN or rcN." >&2
exit 1
fi
echo "channel=${channel}" | tee -a "${GITHUB_OUTPUT}"
# A tag is packaged once, so its release number is fixed at 1. Develop builds
# repeat the same version, so the run number is what makes each push an
# upgrade rather than a reinstall.
- name: Determine package release
id: pkg_release
shell: bash
env:
IS_TAG: ${{ startsWith(github.ref, 'refs/tags/') }}
RUN_NUMBER: ${{ github.run_number }}
run: |
if [[ "${IS_TAG}" == "true" ]]; then
pkg_release=1
else
pkg_release="${RUN_NUMBER}"
fi
echo "pkg_release=${pkg_release}" | tee -a "${GITHUB_OUTPUT}"

View File

@@ -1,69 +0,0 @@
name: Setup Nix environment
description: "Build the flake's CI environment and put its tools on PATH."
# The environment from nix/ci-env.nix, the same one the Linux CI images bake in
# (see nix/docker). Exported onto PATH rather than entered with `nix develop`:
# the composite actions below run plain `bash` and would escape a dev shell.
runs:
using: composite
steps:
- name: Build the CI environment
id: build
shell: bash
env:
# --out-link doubles as a GC root for the length of the job.
OUT_LINK: ${{ runner.temp }}/xrpld-ci-env
run: |
# --extra-experimental-features: flakes may not be on in the runner's nix.conf.
nix --extra-experimental-features "nix-command flakes" \
build .#default --out-link "${OUT_LINK}" --print-build-logs
echo "path=$(readlink -f "${OUT_LINK}")" >>"${GITHUB_OUTPUT}"
- name: Export the environment
shell: bash
env:
ENV_PATH: ${{ steps.build.outputs.path }}
run: |
echo "${ENV_PATH}/bin" >>"${GITHUB_PATH}"
# Already KEY=VALUE per line. See `darwinEnv` in nix/ci-env.nix.
ENV_FILE="${ENV_PATH}/share/xrpld-ci-env/env"
if [ -f "${ENV_FILE}" ]; then
cat "${ENV_FILE}" >>"${GITHUB_ENV}"
fi
# XrplSanity.cmake otherwise rejects a Nix compiler as one that leaked.
echo "XRPL_DEVSHELL=ci-env" >>"${GITHUB_ENV}"
# Unlike the Linux nix images, macOS needs no SSL_CERT_FILE: it has its
# own trust store, and pinning would break TLS to hosts relying on it.
# Workspace-local, so `cleanup-workspace` clears it, but not the
# `.conan2` prepare-runner hands the system toolchain: that Conan is a
# different version, and the two would migrate each other's cache.
echo "CONAN_HOME=${{ github.workspace }}/.conan2-nix" >>"${GITHUB_ENV}"
# Config, profiles and remote, exactly as the dev shell sets them up on
# entry; the `setup-conan` action is skipped for this toolchain.
- name: Setup Conan
shell: bash
run: ./conan/init.sh
# `Check tools` runs later but swallows failures; a bad export would just
# build with the system toolchain.
- name: Verify the toolchain resolves into the Nix store
shell: bash
run: |
for tool in clang clang++ cmake ninja conan; do
path="$(command -v "${tool}" || true)"
echo "${tool} -> ${path:-<not found>}"
case "${path}" in
/nix/store/*) ;;
*)
echo "::error::${tool} does not resolve into the Nix store"
exit 1
;;
esac
done

View File

@@ -4,8 +4,7 @@ updates:
directories:
- /
- .github/actions/build-deps/
- .github/actions/cargo-cache/
- .github/actions/release-info/
- .github/actions/generate-version/
- .github/actions/set-compiler-env/
- .github/actions/setup-conan/
schedule:
@@ -16,23 +15,3 @@ updates:
commit-message:
prefix: "ci: [DEPENDABOT] "
target-branch: develop
groups:
github-actions:
patterns:
- "*"
- package-ecosystem: cargo
directory: /crates
schedule:
interval: weekly
day: monday
time: "04:00"
timezone: Etc/GMT
commit-message:
prefix: "chore: [DEPENDABOT] "
target-branch: develop
open-pull-requests-limit: 10
groups:
rust-dependencies:
patterns:
- "*"

View File

@@ -40,18 +40,18 @@ listed later.
| 04 | xrpl/protocol |
| 05 | xrpl/core xrpl/resource xrpl/server |
| 06 | xrpl/ledger xrpl/nodestore xrpl/net |
| 07 | xrpl/shamap xrpl/consensus |
| 07 | xrpl/shamap |
## xrpld Modules (Application Implementation)
| Level / Tier | Module(s) |
| ------------ | --------------------------- |
| 05 | xrpld/conditions |
| 06 | xrpld/core xrpld/peerfinder |
| 07 | xrpld/shamap xrpld/overlay |
| 08 | xrpld/app |
| 09 | xrpld/rpc |
| 10 | xrpld/perflog |
| Level / Tier | Module(s) |
| ------------ | -------------------------------- |
| 05 | xrpld/conditions xrpld/consensus |
| 06 | xrpld/core xrpld/peerfinder |
| 07 | xrpld/shamap xrpld/overlay |
| 08 | xrpld/app |
| 09 | xrpld/rpc |
| 10 | xrpld/perflog |
## Test Modules

View File

@@ -1,6 +1,9 @@
Loop: xrpld.app xrpld.overlay
xrpld.app > xrpld.overlay
Loop: xrpld.app xrpld.peerfinder
xrpld.peerfinder ~= xrpld.app
Loop: xrpld.app xrpld.rpc
xrpld.rpc > xrpld.app

View File

@@ -6,8 +6,6 @@ libxrpl.conditions > xrpl.basics
libxrpl.conditions > xrpl.conditions
libxrpl.config > xrpl.basics
libxrpl.config > xrpl.config
libxrpl.consensus > xrpl.basics
libxrpl.consensus > xrpl.consensus
libxrpl.core > xrpl.basics
libxrpl.core > xrpl.core
libxrpl.core > xrpl.json
@@ -27,9 +25,6 @@ libxrpl.nodestore > xrpl.config
libxrpl.nodestore > xrpl.json
libxrpl.nodestore > xrpl.nodestore
libxrpl.nodestore > xrpl.protocol
libxrpl.peerfinder > xrpl.basics
libxrpl.peerfinder > xrpl.peerfinder
libxrpl.peerfinder > xrpl.protocol
libxrpl.protocol > xrpl.basics
libxrpl.protocol > xrpl.json
libxrpl.protocol > xrpl.protocol
@@ -65,9 +60,9 @@ test.app > test.jtx
test.app > test.unit_test
test.app > xrpl.basics
test.app > xrpl.config
test.app > xrpl.consensus
test.app > xrpl.core
test.app > xrpld.app
test.app > xrpld.consensus
test.app > xrpld.core
test.app > xrpld.overlay
test.app > xrpld.rpc
@@ -88,9 +83,12 @@ test.basics > xrpl.protocol
test.beast > xrpl.basics
test.conditions > xrpl.basics
test.conditions > xrpl.conditions
test.consensus > test.csf
test.consensus > test.jtx
test.consensus > test.unit_test
test.consensus > xrpl.basics
test.consensus > xrpld.app
test.consensus > xrpld.consensus
test.consensus > xrpl.ledger
test.consensus > xrpl.protocol
test.consensus > xrpl.shamap
@@ -105,6 +103,10 @@ test.core > xrpl.json
test.core > xrpl.protocol
test.core > xrpl.rdb
test.core > xrpl.server
test.csf > xrpl.basics
test.csf > xrpld.consensus
test.csf > xrpl.json
test.csf > xrpl.ledger
test.json > test.jtx
test.json > xrpl.json
test.jtx > test.unit_test
@@ -130,9 +132,12 @@ test.ledger > xrpl.json
test.ledger > xrpl.ledger
test.ledger > xrpl.protocol
test.nodestore > test.jtx
test.nodestore > test.unit_test
test.nodestore > xrpl.basics
test.nodestore > xrpl.config
test.nodestore > xrpld.core
test.nodestore > xrpl.nodestore
test.nodestore > xrpl.protocol
test.nodestore > xrpl.rdb
test.overlay > test.jtx
test.overlay > test.unit_test
@@ -141,13 +146,19 @@ test.overlay > xrpl.config
test.overlay > xrpld.app
test.overlay > xrpld.core
test.overlay > xrpld.overlay
test.overlay > xrpld.peerfinder
test.overlay > xrpl.json
test.overlay > xrpl.nodestore
test.overlay > xrpl.peerfinder
test.overlay > xrpl.protocol
test.overlay > xrpl.resource
test.overlay > xrpl.server
test.overlay > xrpl.shamap
test.peerfinder > test.beast
test.peerfinder > test.unit_test
test.peerfinder > xrpl.basics
test.peerfinder > xrpld.core
test.peerfinder > xrpld.peerfinder
test.peerfinder > xrpl.protocol
test.protocol > test.jtx
test.protocol > test.unit_test
test.protocol > xrpl.basics
@@ -181,13 +192,11 @@ test.unit_test > xrpl.basics
test.unit_test > xrpl.protocol
tests.libxrpl > xrpl.basics
tests.libxrpl > xrpl.config
tests.libxrpl > xrpl.consensus
tests.libxrpl > xrpl.core
tests.libxrpl > xrpl.json
tests.libxrpl > xrpl.ledger
tests.libxrpl > xrpl.net
tests.libxrpl > xrpl.nodestore
tests.libxrpl > xrpl.peerfinder
tests.libxrpl > xrpl.protocol
tests.libxrpl > xrpl.protocol_autogen
tests.libxrpl > xrpl.resource
@@ -197,10 +206,6 @@ tests.libxrpl > xrpl.tx
xrpl.conditions > xrpl.basics
xrpl.conditions > xrpl.protocol
xrpl.config > xrpl.basics
xrpl.consensus > xrpl.basics
xrpl.consensus > xrpl.json
xrpl.consensus > xrpl.ledger
xrpl.consensus > xrpl.protocol
xrpl.core > xrpl.basics
xrpl.core > xrpl.json
xrpl.core > xrpl.protocol
@@ -215,8 +220,6 @@ xrpl.nodestore > xrpl.basics
xrpl.nodestore > xrpl.config
xrpl.nodestore > xrpl.json
xrpl.nodestore > xrpl.protocol
xrpl.peerfinder > xrpl.basics
xrpl.peerfinder > xrpl.protocol
xrpl.protocol > xrpl.basics
xrpl.protocol > xrpl.json
xrpl.protocol_autogen > xrpl.json
@@ -243,20 +246,23 @@ xrpl.tx > xrpl.protocol
xrpld.app > test.unit_test
xrpld.app > xrpl.basics
xrpld.app > xrpl.config
xrpld.app > xrpl.consensus
xrpld.app > xrpl.core
xrpld.app > xrpld.consensus
xrpld.app > xrpld.core
xrpld.app > xrpl.json
xrpld.app > xrpl.ledger
xrpld.app > xrpl.net
xrpld.app > xrpl.nodestore
xrpld.app > xrpl.peerfinder
xrpld.app > xrpl.protocol
xrpld.app > xrpl.rdb
xrpld.app > xrpl.resource
xrpld.app > xrpl.server
xrpld.app > xrpl.shamap
xrpld.app > xrpl.tx
xrpld.consensus > xrpl.basics
xrpld.consensus > xrpl.json
xrpld.consensus > xrpl.ledger
xrpld.consensus > xrpl.protocol
xrpld.core > xrpl.basics
xrpld.core > xrpl.config
xrpld.core > xrpl.core
@@ -265,22 +271,21 @@ xrpld.core > xrpl.protocol
xrpld.core > xrpl.rdb
xrpld.overlay > xrpl.basics
xrpld.overlay > xrpl.config
xrpld.overlay > xrpl.consensus
xrpld.overlay > xrpl.core
xrpld.overlay > xrpld.consensus
xrpld.overlay > xrpld.core
xrpld.overlay > xrpld.peerfinder
xrpld.overlay > xrpl.json
xrpld.overlay > xrpl.ledger
xrpld.overlay > xrpl.peerfinder
xrpld.overlay > xrpl.protocol
xrpld.overlay > xrpl.resource
xrpld.overlay > xrpl.server
xrpld.overlay > xrpl.shamap
xrpld.overlay > xrpl.tx
xrpld.peerfinder > xrpl.basics
xrpld.peerfinder > xrpld.app
xrpld.peerfinder > xrpl.config
xrpld.peerfinder > xrpld.core
xrpld.peerfinder > xrpl.peerfinder
xrpld.peerfinder > xrpl.protocol
xrpld.peerfinder > xrpl.rdb
xrpld.perflog > xrpl.basics
xrpld.perflog > xrpl.config

View File

@@ -49,7 +49,7 @@ ${SED_COMMAND} -i -E 's@ripple/xrpld@XRPLF/rippled@g' BUILD.md
${SED_COMMAND} -i -E 's@XRPLF/xrpld@XRPLF/rippled@g' BUILD.md
${SED_COMMAND} -i -E 's@xrpld \(`xrpld`\)@xrpld@g' BUILD.md
${SED_COMMAND} -i -E 's@XRPLF/xrpld@XRPLF/rippled@g' CONTRIBUTING.md
${SED_COMMAND} -i -E 's@XRPLF/xrpld@XRPLF/rippled@g' docs/install.md
${SED_COMMAND} -i -E 's@XRPLF/xrpld@XRPLF/rippled@g' docs/build/install.md
popd
echo "Processing complete."

View File

@@ -77,8 +77,8 @@ ${SED_COMMAND} -i 's/Ripple integrators/XRPL developers/' README.md
${SED_COMMAND} -i 's/sanitizer-configuration-for-rippled/sanitizer-configuration-for-xrpld/' docs/build/sanitizers.md
${SED_COMMAND} -i 's/rippled/xrpld/g' .github/scripts/levelization/README.md
${SED_COMMAND} -i 's/rippled/xrpld/g' .github/scripts/strategy-matrix/generate.py
${SED_COMMAND} -i 's@/rippled@/xrpld@g' docs/install.md
${SED_COMMAND} -i 's@github.com/XRPLF/xrpld@github.com/XRPLF/rippled@g' docs/install.md
${SED_COMMAND} -i 's@/rippled@/xrpld@g' docs/build/install.md
${SED_COMMAND} -i 's@github.com/XRPLF/xrpld@github.com/XRPLF/rippled@g' docs/build/install.md
${SED_COMMAND} -i 's/rippled/xrpld/g' docs/Doxyfile
${SED_COMMAND} -i 's/ripple_basics/basics/' include/xrpl/basics/CountedObject.h
${SED_COMMAND} -i 's/<ripple/<xrpl/' include/xrpl/protocol/AccountID.h

View File

@@ -46,6 +46,9 @@ for DIRECTORY in "${DIRECTORIES[@]}"; do
done
done
# Special case for NuDBFactory that has ripple twice in the test suite name.
${SED_COMMAND} -i -E 's/(BEAST_DEFINE_TESTSUITE.+)ripple(.+)/\1xrpl\2/g' src/test/nodestore/NuDBFactory_test.cpp
DIRECTORY=$1
find "${DIRECTORY}" -type f -name "*.md" | while read -r FILE; do
echo "Processing file: ${FILE}"

View File

@@ -7,12 +7,7 @@ from pathlib import Path
THIS_DIR = Path(__file__).parent.resolve()
_BASE_CMAKE_ARGS = [
"-Dtests=ON",
"-Dwerr=ON",
"-Dxrpld=ON",
"-Dwextra=ON",
]
_BASE_CMAKE_ARGS = ["-Dtests=ON", "-Dwerr=ON", "-Dxrpld=ON", "-Dwextra=ON"]
# Maps sanitizer names (as used in cmake) to short config-name suffixes.
_SANITIZER_SUFFIX: dict[str, str] = {
@@ -38,10 +33,6 @@ def get_cmake_args(build_type: str, extra_args: str) -> str:
# Every config must declare 'minimal'. Minimal configs form the reduced matrix
# built for pull requests by default; the full matrix adds the rest. Packaging
# configs declare it too, but packaging is gated in the workflow, not by it.
#
# Configs may also opt into 'benchmark' to smoke-run the benchmarks. Note that
# the flag applies to every entry a config expands into, so only set it on
# configs that expand to a single combination.
@dataclasses.dataclass
@@ -52,7 +43,6 @@ class LinuxConfig:
build_type: list[str]
arch: list[str]
minimal: bool
benchmark: bool = False # if true, smoke-run the benchmarks after testing
sanitizers: list[str] = dataclasses.field(default_factory=list)
suffix: str = ""
extra_cmake_args: str = ""
@@ -91,11 +81,7 @@ class PlatformConfig:
build_type: list[str]
minimal: bool
build_only: bool = False # if true, skip tests (e.g. macos/Windows Debug)
benchmark: bool = False # if true, smoke-run the benchmarks after testing
extra_cmake_args: str = ""
# "" is the runner's system compiler, "nix" the flake's CI environment.
# macOS only: Linux always builds in a Nix image, Windows has no Nix.
toolchain: str = ""
def __post_init__(self) -> None:
if isinstance(self.build_type, str):
@@ -139,21 +125,18 @@ class MatrixEntry:
cmake_args: str
cmake_target: str
build_only: bool
benchmark: bool
build_type: str
architecture: Architecture
sanitizers: str
image: str = "" # container image; empty for macOS/Windows (runs natively)
compiler: str = "" # compiler name ("gcc" or "clang"); empty for macOS/Windows
toolchain: str = "" # "nix" for the flake's CI environment; see PlatformConfig
@dataclasses.dataclass
class PackagingEntry:
"""One entry in the generated packaging strategy matrix."""
xrpld_artifact_name: str
validator_keys_artifact_name: str
artifact_name: str
image: str
distro: str # e.g. "debian" or "rhel"; drives package-format-specific steps
@@ -210,7 +193,6 @@ def expand_linux_matrix(linux: LinuxFile, minimal: bool) -> list[MatrixEntry]:
cmake_args=get_cmake_args(build_type, cfg.extra_cmake_args),
cmake_target="all",
build_only=False,
benchmark=cfg.benchmark,
build_type=build_type,
architecture=arch_info,
sanitizers=sanitizer,
@@ -224,23 +206,18 @@ def expand_linux_matrix(linux: LinuxFile, minimal: bool) -> list[MatrixEntry]:
def expand_linux_packaging(linux: LinuxFile) -> list[PackagingEntry]:
"""Generate the packaging matrix from a LinuxFile's package_configs section.
Packaging uses vanilla distro images (debian:bookworm, almalinux:9) instead of
Packaging uses vanilla distro images (debian:bookworm, ubi9, …) instead of
the nix-based build images, because deb/rpm tooling (debhelper, rpm-build)
is taken from the distro's archive rather than from nixpkgs. Each config
entry carries its own 'image'.
The artifact names must match what the build job uploads: one artifact per
binary, each named after the build config.
"""
entries = []
for distro, configs in linux.package_configs.items():
for cfg in configs:
for compiler, build_type in itertools.product(cfg.compiler, cfg.build_type):
config_name = f"{distro}-{compiler}-{build_type.lower()}-amd64"
entries.append(
PackagingEntry(
xrpld_artifact_name=f"xrpld-{config_name}",
validator_keys_artifact_name=f"validator-keys-{config_name}",
artifact_name=f"xrpld-{distro}-{compiler}-{build_type.lower()}-amd64",
image=cfg.image,
distro=distro,
)
@@ -262,20 +239,15 @@ def expand_platform_matrix(pf: PlatformFile, minimal: bool) -> list[MatrixEntry]
if minimal and not cfg.minimal:
continue
for build_type in cfg.build_type:
name = f"{platform_name}-{arch}-{build_type.lower()}"
if cfg.toolchain:
name += f"-{cfg.toolchain}"
entries.append(
MatrixEntry(
config_name=name,
config_name=f"{platform_name}-{arch}-{build_type.lower()}",
cmake_args=get_cmake_args(build_type, cfg.extra_cmake_args),
cmake_target="install" if is_windows else "all",
build_only=cfg.build_only,
benchmark=cfg.benchmark,
build_type=build_type,
architecture=Architecture(platform=pf.platform, runner=pf.runner),
sanitizers="",
toolchain=cfg.toolchain,
)
)
return entries

View File

@@ -1,23 +1,13 @@
{
"image_tag": "sha-a0074f8",
"image_tag": "sha-2e25435",
"configs": {
"ubuntu": [
{
"compiler": ["gcc"],
"build_type": ["Debug"],
"arch": ["amd64"],
"minimal": true,
"suffix": "coverage",
"extra_cmake_args": "-DUNIT_TEST_REFERENCE_FEE=500 -Dcoverage=ON -Dcoverage_format=xml -DCODE_COVERAGE_VERBOSE=ON -DCMAKE_C_FLAGS=-O0 -DCMAKE_CXX_FLAGS=-O0"
},
{
"compiler": ["clang"],
"build_type": ["Release"],
"arch": ["amd64"],
"minimal": true,
"benchmark": true
"minimal": true
},
{
"compiler": ["gcc"],
"build_type": ["Release"],
@@ -39,6 +29,14 @@
"sanitizers": ["address", "undefinedbehavior"]
},
{
"compiler": ["gcc"],
"build_type": ["Debug"],
"arch": ["amd64"],
"minimal": true,
"suffix": "coverage",
"extra_cmake_args": "-DUNIT_TEST_REFERENCE_FEE=500 -Dcoverage=ON -Dcoverage_format=xml -DCODE_COVERAGE_VERBOSE=ON -DCMAKE_C_FLAGS=-O0 -DCMAKE_CXX_FLAGS=-O0"
},
{
"compiler": ["clang"],
"build_type": ["Debug"],
@@ -70,8 +68,7 @@
"compiler": ["gcc"],
"build_type": ["Release"],
"arch": ["amd64"],
"minimal": false,
"extra_cmake_args": "-Dvalidator_keys=ON"
"minimal": false
}
],
@@ -80,8 +77,7 @@
"compiler": ["gcc"],
"build_type": ["Release"],
"arch": ["amd64"],
"minimal": false,
"extra_cmake_args": "-Dvalidator_keys=ON"
"minimal": false
}
]
},
@@ -92,7 +88,7 @@
"build_type": ["Release"],
"arch": ["amd64"],
"minimal": false,
"image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-a6983f8"
"image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-577d745"
}
],
@@ -102,7 +98,7 @@
"build_type": ["Release"],
"arch": ["amd64"],
"minimal": false,
"image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-a6983f8"
"image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-577d745"
}
]
}

View File

@@ -12,19 +12,6 @@
"extra_cmake_args": "-DCMAKE_POLICY_VERSION_MINIMUM=3.5",
"build_only": true,
"minimal": false
},
{
"build_type": "Release",
"extra_cmake_args": "-DCMAKE_POLICY_VERSION_MINIMUM=3.5",
"toolchain": "nix",
"minimal": false
},
{
"build_type": "Debug",
"extra_cmake_args": "-DCMAKE_POLICY_VERSION_MINIMUM=3.5",
"toolchain": "nix",
"build_only": true,
"minimal": false
}
]
}

View File

@@ -13,7 +13,6 @@ on:
- "!nix/docker/README.md"
- "!nix/devshell.nix"
- "bin/check-tools.sh"
- "bin/default-loader-path.sh"
- "bin/install-sanitizer-libs.sh"
pull_request:
paths:
@@ -25,7 +24,6 @@ on:
- "!nix/docker/README.md"
- "!nix/devshell.nix"
- "bin/check-tools.sh"
- "bin/default-loader-path.sh"
- "bin/install-sanitizer-libs.sh"
workflow_dispatch:
@@ -58,7 +56,7 @@ jobs:
base_image: debian:bookworm
- name: rhel
base_image: registry.access.redhat.com/ubi9/ubi:latest
uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@65d5a0bd72be4ecea95cff0673a6e0672ab5243a
uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@ee03d31bcc4501d7599dc1b1ecd7a34af582ad1c
with:
image_name: xrpld/nix-${{ matrix.distro.name }}
dockerfile: nix/docker/Dockerfile

View File

@@ -36,10 +36,9 @@ jobs:
distro:
- name: debian
base_image: debian:bookworm
# AlmaLinux rather than UBI9, which does not ship rpm-sign.
- name: rhel
base_image: almalinux:9
uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@65d5a0bd72be4ecea95cff0673a6e0672ab5243a
base_image: registry.access.redhat.com/ubi9/ubi:latest
uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@ee03d31bcc4501d7599dc1b1ecd7a34af582ad1c
with:
image_name: xrpld/packaging-${{ matrix.distro.name }}
dockerfile: package/Dockerfile

View File

@@ -30,7 +30,7 @@ jobs:
permissions:
contents: read
packages: write
uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@65d5a0bd72be4ecea95cff0673a6e0672ab5243a
uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@ee03d31bcc4501d7599dc1b1ecd7a34af582ad1c
with:
image_name: xrpld/pre-commit
dockerfile: bin/pre-commit/Dockerfile

View File

@@ -1,80 +0,0 @@
name: Cargo audit
on:
schedule:
# 06:32 UTC every Monday.
- cron: "32 6 * * 1"
push:
branches:
- "develop"
- "release/*"
paths:
- "crates/**/Cargo.toml"
- "crates/Cargo.lock"
- ".github/workflows/cargo-audit.yml"
pull_request:
paths:
- "crates/**/Cargo.toml"
- "crates/Cargo.lock"
- ".github/workflows/cargo-audit.yml"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
defaults:
run:
shell: bash
working-directory: crates
permissions:
contents: read
jobs:
audit:
runs-on: ubuntu-latest
container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8
permissions:
contents: read
# Needed to open an issue on scheduled failures.
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Run cargo audit
id: audit
continue-on-error: true
run: |
set -o pipefail
cargo audit | tee /tmp/cargo-audit.txt
- name: Prepare issue body
if: ${{ steps.audit.outcome != 'success' && github.event_name == 'schedule' }}
run: |
{
echo "## \`cargo audit\` found advisories"
echo
echo '```'
cat /tmp/cargo-audit.txt
echo '```'
echo
echo "---"
echo "*This issue was automatically created by the cargo-audit workflow.*"
} >/tmp/cargo-audit-issue.md
- name: Create issue
if: ${{ steps.audit.outcome != 'success' && github.event_name == 'schedule' }}
uses: XRPLF/actions/create-issue@2b8bc36af85b88bca0dd7bfac2e2dc05f94ad712
with:
title: "cargo audit found vulnerabilities"
body_file: /tmp/cargo-audit-issue.md
labels: "Bug,Security"
- name: Fail if advisories were found
if: ${{ steps.audit.outcome != 'success' }}
run: |
echo "cargo audit found advisories!"
cat /tmp/cargo-audit.txt
exit 1

View File

@@ -23,7 +23,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Write PR body to file
env:

View File

@@ -20,4 +20,4 @@ on:
jobs:
check_title:
if: ${{ github.event.pull_request.draft != true }}
uses: XRPLF/actions/.github/workflows/check-pr-title.yml@d7c65e49225a38f6d8010eacf017bb5a98d7476c
uses: XRPLF/actions/.github/workflows/check-pr-title.yml@cba1f0891650baf1a9c88624dc2d72573be2eb81

View File

@@ -1,114 +0,0 @@
# Verifies the committed snapshots of `bin/check-tools.sh` output for each Nix
# environment (see nix/check-tools/). If the environment changes — a new image
# tag, an updated flake.lock, a different tool list — without the matching
# snapshot being regenerated and committed, this workflow fails so the drift is
# caught in review.
#
# To regenerate the snapshots, see nix/check-tools/README.md.
name: Check tools
on:
pull_request:
paths:
- ".github/workflows/check-tools.yml"
- ".github/scripts/strategy-matrix/linux.json"
- "bin/check-tools.sh"
- "nix/**"
- "flake.nix"
- "flake.lock"
- "rust-toolchain.toml"
push:
branches:
- "develop"
paths:
- ".github/workflows/check-tools.yml"
- ".github/scripts/strategy-matrix/linux.json"
- "bin/check-tools.sh"
- "nix/**"
- "flake.nix"
- "flake.lock"
- "rust-toolchain.toml"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
defaults:
run:
shell: bash
jobs:
# The nix-nixos image tag is pinned alongside the build matrix in linux.json,
# so snapshots are checked against the exact image CI builds against.
linux-image-tag:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Read nix image tag
id: tag
run: echo "tag=$(jq -r .image_tag .github/scripts/strategy-matrix/linux.json)" >>"${GITHUB_OUTPUT}"
# One job for all environments; they differ only in whether the tools come
# from the nix-nixos container (Linux) or `nix develop` (macOS).
check-tools:
needs: linux-image-tag
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-latest
snapshot: nix/check-tools/nix-ubuntu-amd64.txt
nix_develop: false
- runner: ubuntu-24.04-arm
snapshot: nix/check-tools/nix-ubuntu-arm64.txt
nix_develop: false
- runner: macos-26-apple-clang-21
snapshot: nix/check-tools/macos.txt
nix_develop: true
runs-on: ${{ matrix.runner }}
# Linux runs inside the pinned nix-nixos image; macOS runs natively and uses
# the flake's dev shell instead (see the run step below).
container: ${{ !matrix.nix_develop && format('ghcr.io/xrplf/xrpld/nix-ubuntu:{0}', needs.linux-image-tag.outputs.tag) || null }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Prepare runner
uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13
with:
enable_ccache: false
- name: Regenerate snapshot
env:
CHECK_TOOLS_SKIP_CLONE: "1"
# check-tools.sh skips some macOS tools when CI is set; the snapshots
# capture the full `nix develop` environment, so unset it here.
CI: ""
run: |
if [ "${{ matrix.nix_develop }}" = "true" ]; then
# `nix develop` prints the dev-shell greeting first; keep only the
# check-tools.sh output (from the "Detected OS:" line onward).
nix --extra-experimental-features "nix-command flakes" develop \
-c bash bin/check-tools.sh | sed -n '/^Detected OS:/,$p' >"${{ matrix.snapshot }}"
else
bash bin/check-tools.sh >"${{ matrix.snapshot }}"
fi
- name: Verify snapshot is up to date
run: |
if ! git diff --exit-code -- "${{ matrix.snapshot }}"; then
echo "::error::${{ matrix.snapshot }} is out of date. Regenerate it (see nix/check-tools/README.md) and commit the result."
exit 1
fi
- name: Upload regenerated snapshot
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: check-tools-${{ runner.os }}-${{ runner.arch }}
path: ${{ matrix.snapshot }}

View File

@@ -52,7 +52,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Determine changed files
# This step checks whether any files have changed that should
# cause the next jobs to run. We do it this way rather than
@@ -77,28 +77,22 @@ jobs:
# Keep the paths below in sync with those in `on-trigger.yml`.
.github/actions/build-deps/**
.github/actions/release-info/**
.github/actions/generate-version/**
.github/actions/setup-conan/**
.github/actions/setup-nix-env/**
.github/scripts/strategy-matrix/**
.github/workflows/reusable-build-test-config.yml
.github/workflows/reusable-build-test.yml
.github/workflows/reusable-check-autogen.yml
.github/workflows/reusable-clang-tidy.yml
.github/workflows/reusable-package.yml
.github/workflows/reusable-rust.yml
.github/workflows/reusable-strategy-matrix.yml
.github/workflows/reusable-test.yml
.github/workflows/reusable-upload-recipe.yml
.clang-tidy
.codecov.yml
bin/check-nix-store-refs.sh
bin/check-tools.sh
bin/default-loader-path.sh
cfg/**
cmake/**
conan/**
crates/**
external/**
include/**
src/**
@@ -106,9 +100,6 @@ jobs:
CMakeLists.txt
conanfile.py
conan.lock
flake.lock
flake.nix
nix/**
LICENSE.md
package/**
README.md
@@ -134,11 +125,6 @@ jobs:
outputs:
go: ${{ steps.go.outputs.go == 'true' }}
check-autogen:
needs: should-run
if: ${{ needs.should-run.outputs.go == 'true' }}
uses: ./.github/workflows/reusable-check-autogen.yml
check-levelization:
needs: should-run
if: ${{ needs.should-run.outputs.go == 'true' }}
@@ -175,13 +161,6 @@ jobs:
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
rust:
needs: should-run
if: ${{ needs.should-run.outputs.go == 'true' }}
uses: ./.github/workflows/reusable-rust.yml
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
package:
needs: [should-run, build-test]
# Packaging consumes the debian/rhel release binaries, which are only built
@@ -220,12 +199,10 @@ jobs:
passed:
if: failure() || cancelled()
needs:
- check-autogen
- check-levelization
- check-rename
- clang-tidy
- build-test
- rust
- package
- upload-recipe
- notify-clio

View File

@@ -1,9 +1,5 @@
# When a versioned tag is pushed, this workflow:
#
# - uploads the libxrpl recipe to the Conan remote
# - builds and tests the release binaries
# - builds the DEB and RPM packages
# - publishes those packages to the XRPLF package repositories
# This workflow uploads the libxrpl recipe to the Conan remote and builds
# release packages when a versioned tag is pushed.
name: Tag
on:
@@ -28,7 +24,7 @@ jobs:
remote_password: ${{ secrets.NEXUS_REMOTE_PASSWORD }}
build-test:
if: ${{ github.repository_owner == 'XRPLF' }}
if: ${{ github.repository == 'XRPLF/rippled' }}
uses: ./.github/workflows/reusable-build-test.yml
strategy:
fail-fast: true
@@ -41,12 +37,6 @@ jobs:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
package:
if: ${{ github.repository_owner == 'XRPLF' }}
if: ${{ github.repository == 'XRPLF/rippled' }}
needs: build-test
uses: ./.github/workflows/reusable-package.yml
with:
publish: true
secrets:
remote_username: ${{ secrets.NEXUS_REMOTE_USERNAME }}
remote_password: ${{ secrets.NEXUS_REMOTE_PASSWORD }}
signing_key: ${{ secrets.NEXUS_PACKAGES_PRIVATE_KEY }}

View File

@@ -15,28 +15,22 @@ on:
# Keep the paths below in sync with those in `on-pr.yml`.
- ".github/actions/build-deps/**"
- ".github/actions/release-info/**"
- ".github/actions/generate-version/**"
- ".github/actions/setup-conan/**"
- ".github/actions/setup-nix-env/**"
- ".github/scripts/strategy-matrix/**"
- ".github/workflows/reusable-build-test-config.yml"
- ".github/workflows/reusable-build-test.yml"
- ".github/workflows/reusable-check-autogen.yml"
- ".github/workflows/reusable-clang-tidy.yml"
- ".github/workflows/reusable-package.yml"
- ".github/workflows/reusable-rust.yml"
- ".github/workflows/reusable-strategy-matrix.yml"
- ".github/workflows/reusable-test.yml"
- ".github/workflows/reusable-upload-recipe.yml"
- ".clang-tidy"
- ".codecov.yml"
- "bin/check-nix-store-refs.sh"
- "bin/check-tools.sh"
- "bin/default-loader-path.sh"
- "cfg/**"
- "cmake/**"
- "conan/**"
- "crates/**"
- "external/**"
- "include/**"
- "src/**"
@@ -44,9 +38,6 @@ on:
- "CMakeLists.txt"
- "conanfile.py"
- "conan.lock"
- "flake.lock"
- "flake.nix"
- "nix/**"
- "LICENSE.md"
- "package/**"
- "README.md"
@@ -75,9 +66,6 @@ defaults:
shell: bash
jobs:
check-autogen:
uses: ./.github/workflows/reusable-check-autogen.yml
clang-tidy:
uses: ./.github/workflows/reusable-clang-tidy.yml
permissions:
@@ -103,11 +91,6 @@ jobs:
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
rust:
uses: ./.github/workflows/reusable-rust.yml
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
upload-recipe:
needs: build-test
# Only run when pushing to the develop branch.
@@ -120,11 +103,3 @@ jobs:
package:
needs: build-test
uses: ./.github/workflows/reusable-package.yml
with:
# Packages are built on every trigger; only develop pushes in XRPLF/rippled
# publish them, matching upload-recipe above.
publish: ${{ github.repository == 'XRPLF/rippled' && github.event_name == 'push' && github.ref == 'refs/heads/develop' }}
secrets:
remote_username: ${{ secrets.NEXUS_REMOTE_USERNAME }}
remote_password: ${{ secrets.NEXUS_REMOTE_PASSWORD }}
signing_key: ${{ secrets.NEXUS_PACKAGES_PRIVATE_KEY }}

View File

@@ -14,7 +14,7 @@ on:
jobs:
# Call the workflow in the XRPLF/actions repo that runs the pre-commit hooks.
run-hooks:
uses: XRPLF/actions/.github/workflows/pre-commit.yml@f1952595d212e86169935135efc66294b4574131
uses: XRPLF/actions/.github/workflows/pre-commit.yml@1bde119a1ab71305ba5d3716e7a82cea1c7bdede
with:
runs_on: ubuntu-latest
container: '{ "image": "ghcr.io/xrplf/xrpld/pre-commit:sha-f56b79f" }'

View File

@@ -41,13 +41,13 @@ env:
jobs:
build:
runs-on: ubuntu-latest
container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8
container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-2e25435
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Prepare runner
uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13
uses: XRPLF/actions/prepare-runner@ad188deb3dae79dc39816e16ddfdad1e06c6fab2
with:
enable_ccache: false

View File

@@ -3,12 +3,6 @@ name: Build and test configuration
on:
workflow_call:
inputs:
benchmark:
description: "Whether to smoke-run the benchmarks after testing."
required: false
type: boolean
default: false
build_only:
description: 'Whether to only build or to build and test the code ("true", "false").'
required: true
@@ -69,12 +63,6 @@ on:
type: string
default: ""
toolchain:
description: 'Where the toolchain comes from ("nix" to build the flake CI environment on the runner, empty for the system one). macOS only: Linux always builds in a Nix image, and Nix has no Windows support.'
required: false
type: string
default: ""
secrets:
CODECOV_TOKEN:
description: "The Codecov token to use for uploading coverage reports."
@@ -112,32 +100,23 @@ jobs:
# header files are copied into separate directories by CMake, which will
# otherwise result in cache misses.
CCACHE_SLOPPINESS: include_file_ctime,include_file_mtime
# Determine if coverage, voidstar and validator-keys should be enabled.
# Determine if coverage and voidstar should be enabled.
COVERAGE_ENABLED: ${{ contains(inputs.cmake_args, '-Dcoverage=ON') }}
VOIDSTAR_ENABLED: ${{ contains(inputs.cmake_args, '-Dvoidstar=ON') }}
VALIDATOR_KEYS_ENABLED: ${{ contains(inputs.cmake_args, '-Dvalidator_keys=ON') }}
SANITIZERS_ENABLED: ${{ inputs.sanitizers != '' }}
# The binaries reusable-package.yml consumes. A private repository skips
# them except on a tag push, which is what produces its release packages.
PACKAGING_ARTIFACTS_ENABLED: ${{ github.event.repository.visibility == 'public' || startsWith(github.ref, 'refs/tags/') }}
steps:
- name: Cleanup workspace (macOS and Windows)
if: ${{ runner.os == 'macOS' || runner.os == 'Windows' }}
uses: XRPLF/actions/cleanup-workspace@c7d9ce5ebb03c752a354889ecd870cadfc2b1cd4
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Prepare runner
uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13
uses: XRPLF/actions/prepare-runner@ad188deb3dae79dc39816e16ddfdad1e06c6fab2
with:
enable_ccache: ${{ inputs.ccache_enabled }}
# Before any step that uses a build tool, composite actions included.
- name: Setup Nix environment
if: ${{ inputs.toolchain == 'nix' }}
uses: ./.github/actions/setup-nix-env
- name: Set ccache log file
if: ${{ inputs.ccache_enabled && runner.debug == '1' }}
run: echo "CCACHE_LOGFILE=${{ runner.temp }}/ccache.log" >>"${GITHUB_ENV}"
@@ -162,22 +141,7 @@ jobs:
with:
compiler: ${{ inputs.compiler }}
- name: Use cargo artifacts cache
uses: ./.github/actions/cargo-cache
with:
cache-directories: ${{ env.BUILD_DIR }}/corrosion
key: ${{ inputs.config_name }}
save-if: ${{ github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/heads/release') }}
# two workspaces here because build artifacts are located in 2 places:
# - crates/target when cargo is called directly
# - build/cargo when cargo is called by cmake
workspaces: |
crates
crates -> ${{ runner.os == 'Windows' && format('../{0}/x64/{1}/cargo', env.BUILD_DIR, inputs.build_type) || format('../{0}/cargo', env.BUILD_DIR) }}
# `setup-nix-env` already did this for the Nix toolchain.
- name: Setup Conan
if: ${{ inputs.toolchain != 'nix' }}
env:
SANITIZERS: ${{ inputs.sanitizers }}
uses: ./.github/actions/setup-conan
@@ -206,9 +170,9 @@ jobs:
..
# Export the sanitizer options before any instrumented binary runs. The
# build step below invokes instrumented dependency tools (protoc, grpc),
# so setting UBSAN_OPTIONS here lets the UBSan suppression list silence
# their diagnostics too, not just at test time.
# protocol code-gen and build steps below invoke instrumented dependency
# tools (protoc, grpc), so setting UBSAN_OPTIONS here lets the UBSan
# suppression list silence their diagnostics too, not just at test time.
# GITHUB_WORKSPACE (not the github.workspace context) is used so the path
# resolves correctly inside the container job.
- name: Set sanitizer options
@@ -226,6 +190,32 @@ jobs:
echo "UBSAN_OPTIONS=include=${SUPP}/runtime-ubsan-options.txt:suppressions=${SUPP}/ubsan.supp" >>${GITHUB_ENV}
echo "LSAN_OPTIONS=include=${SUPP}/runtime-lsan-options.txt:suppressions=${SUPP}/lsan.supp" >>${GITHUB_ENV}
- name: Check protocol autogen files are up-to-date
working-directory: ${{ env.BUILD_DIR }}
env:
MESSAGE: |
The generated protocol wrapper classes are out of date.
This typically happens when the macro files or generator scripts
have changed but the generated files were not regenerated.
To fix this:
1. Run: cmake --build . --target setup_code_gen
2. Run: cmake --build . --target code_gen
3. Commit and push the regenerated files
run: |
set -e
cmake --build . --target setup_code_gen
cmake --build . --target code_gen
DIFF=$(git -C .. status --porcelain -- include/xrpl/protocol_autogen src/tests/libxrpl/protocol_autogen)
if [ -n "${DIFF}" ]; then
echo "::error::Generated protocol files are out of date"
git -C .. diff -- include/xrpl/protocol_autogen src/tests/libxrpl/protocol_autogen
echo "${MESSAGE}"
exit 1
fi
- name: Build the binary
working-directory: ${{ env.BUILD_DIR }}
env:
@@ -241,24 +231,6 @@ jobs:
--target "${CMAKE_TARGET}" \
2>&1 | tee "${GITHUB_WORKSPACE}/build.log"
# Nothing may reference the store, so whole trees are checked - the Conan
# cache included, since what it holds is what gets uploaded and reused.
- name: Check the build output for Nix store references (Nix toolchain)
if: ${{ inputs.toolchain == 'nix' }}
run: ./bin/check-nix-store-refs.sh "${BUILD_DIR}"
- name: Check the Conan cache for Nix store references (Nix toolchain)
if: ${{ inputs.toolchain == 'nix' }}
run: ./bin/check-nix-store-refs.sh "${CONAN_HOME}"
# Only what PatchNixBinary.cmake retargets: the toolchain in the Linux
# images always references the store. Same condition it uses.
- name: Check for Nix store references (Linux)
if: ${{ runner.os == 'Linux' && env.SANITIZERS_ENABLED == 'false' }}
run: |
./bin/check-nix-store-refs.sh "${BUILD_DIR}/xrpld"
./bin/check-nix-store-refs.sh "${BUILD_DIR}/xrpl_tests"
- name: Show ccache statistics
if: ${{ inputs.ccache_enabled }}
run: |
@@ -269,7 +241,7 @@ jobs:
fi
- name: Upload the binary (Linux)
if: ${{ env.PACKAGING_ARTIFACTS_ENABLED == 'true' && runner.os == 'Linux' }}
if: ${{ github.event.repository.visibility == 'public' && runner.os == 'Linux' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: xrpld-${{ inputs.config_name }}
@@ -277,22 +249,6 @@ jobs:
retention-days: 3
if-no-files-found: error
- name: Run the validator-keys tests
if: ${{ env.VALIDATOR_KEYS_ENABLED == 'true' }}
working-directory: ${{ env.BUILD_DIR }}
run: ./validator-keys --unittest
- name: Upload the validator-keys binary
if: ${{ env.PACKAGING_ARTIFACTS_ENABLED == 'true' && env.VALIDATOR_KEYS_ENABLED == 'true' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: validator-keys-${{ inputs.config_name }}
path: |
${{ env.BUILD_DIR }}/validator-keys
${{ env.BUILD_DIR }}/validator-keys-LICENSE
retention-days: 3
if-no-files-found: error
- name: Upload the test binary (Linux)
if: ${{ github.event.repository.visibility == 'public' && runner.os == 'Linux' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -310,7 +266,7 @@ jobs:
./xrpld --definitions | python3 -m json.tool >server_definitions.json
- name: Upload server definitions
if: ${{ github.event.repository.visibility == 'public' && inputs.config_name == 'ubuntu-gcc-debug-amd64-coverage' }}
if: ${{ github.event.repository.visibility == 'public' && inputs.config_name == 'debian-gcc-release-amd64' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: server-definitions
@@ -370,24 +326,13 @@ jobs:
LD_PRELOAD="$PRELOAD" ./xrpld --unittest --unittest-jobs "${BUILD_NPROC}" 2>&1 | tee "${GITHUB_WORKSPACE}/unittest.log"
- name: Run Rust tests
if: ${{ !inputs.build_only }}
working-directory: crates
# `xrpl-wasm-vm-ffi` is left out on Windows: its tests link as an executable, and
# MSVC - unlike the Unix linkers - will not dead-strip the never-called cxx wrappers
# whose C++ shims only the CMake build defines. The other runners cover these tests.
run: cargo nextest run --workspace --all-features --locked --no-tests=warn ${{ runner.os == 'Windows' && '--exclude xrpl-wasm-vm-ffi' || '' }}
# Smoke-run every benchmark module with a single repetition to confirm the
# benchmarks still build and execute. This is a correctness check, not a
# performance measurement, so there is nothing to gain from repeating it
# across configurations: it is opted into by a single config in the
# strategy matrix (see the 'benchmark' flag in the JSON files), which
# keeps it off instrumented builds (sanitizers/coverage/voidstar), where
# it would be slow and meaningless, off Debug builds, where it is much
# slower, and off Windows, where the `install` target does not build them.
# performance measurement, so it is skipped for instrumented builds
# (sanitizers/coverage/voidstar), where it would be slow and meaningless,
# and on Windows, where the `install` target does not build them.
- name: Run the benchmarks
if: ${{ inputs.benchmark }}
if: ${{ !inputs.build_only && runner.os != 'Windows' && env.SANITIZERS_ENABLED == 'false' && env.COVERAGE_ENABLED != 'true' && env.VOIDSTAR_ENABLED != 'true' }}
working-directory: ${{ env.BUILD_DIR }}
run: |
rc=0
@@ -442,14 +387,13 @@ jobs:
--target coverage
- name: Upload coverage report
if: ${{ github.repository_owner == 'XRPLF' && !inputs.build_only && env.COVERAGE_ENABLED == 'true' }}
if: ${{ github.repository == 'XRPLF/rippled' && !inputs.build_only && env.COVERAGE_ENABLED == 'true' }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
disable_search: true
disable_telem: true
fail_ci_if_error: true
files: ${{ env.BUILD_DIR }}/coverage.xml
flags: cpp
plugins: noop
token: ${{ secrets.CODECOV_TOKEN }}
verbose: true

View File

@@ -40,7 +40,6 @@ jobs:
fail-fast: ${{ github.event_name == 'merge_group' }}
matrix: ${{ fromJson(needs.generate-matrix.outputs.matrix) }}
with:
benchmark: ${{ matrix.benchmark }}
build_only: ${{ matrix.build_only }}
build_type: ${{ matrix.build_type }}
ccache_enabled: ${{ inputs.ccache_enabled }}
@@ -51,6 +50,5 @@ jobs:
config_name: ${{ matrix.config_name }}
sanitizers: ${{ matrix.sanitizers }}
compiler: ${{ matrix.compiler || '' }}
toolchain: ${{ matrix.toolchain || '' }}
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -1,76 +0,0 @@
# This workflow checks that the generated protocol wrapper classes are
# up-to-date with the macro files and generator scripts they are produced from,
# see more info in include/xrpl/protocol_autogen/README.md.
name: Check autogen
# This workflow can only be triggered by other workflows.
on: workflow_call
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-autogen
cancel-in-progress: true
defaults:
run:
shell: bash
env:
BUILD_DIR: build/codegen
jobs:
autogen:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
# Code generation is pure Python, so the standalone project below offers
# the same targets as the main build without needing its dependencies or
# a compiler, which keeps this job down to a few seconds.
- name: Configure CMake
run: cmake -S cmake/codegen -B "${BUILD_DIR}"
- name: Install code generation dependencies
run: cmake --build "${BUILD_DIR}" --target setup_code_gen
- name: Generate code
run: cmake --build "${BUILD_DIR}" --target code_gen
- name: Check for differences
env:
MESSAGE: |
The generated protocol wrapper classes are out of date.
This typically happens when the macro files or generator scripts
have changed but the generated files were not regenerated.
Run the following from the repository root, then commit and push
the regenerated files. This needs neither the dependencies nor a
compiler. See include/xrpl/protocol_autogen/README.md for more info.
cmake -S cmake/codegen -B build/codegen
cmake --build build/codegen --target setup_code_gen
cmake --build build/codegen --target code_gen
In an already configured build directory, the 'setup_code_gen' and
'code_gen' targets do the same thing.
run: |
# Record untracked files in the index without staging their contents,
# so that classes generated for a newly added transaction or ledger
# entry type show up in the diff below rather than silently as an
# empty one.
git add --intent-to-add .
DIFF=$(git status --porcelain)
if [ -n "${DIFF}" ]; then
# Print the differences to give the contributor a hint about what to
# expect when running code generation on their own machine.
git diff
echo "${MESSAGE}"
exit 1
fi

View File

@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Check levelization
run: python .github/scripts/levelization/generate.py
- name: Check for differences

View File

@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Check definitions
run: .github/scripts/rename/definitions.sh .
- name: Check copyright notices

View File

@@ -27,23 +27,23 @@ jobs:
determine-files:
permissions:
contents: read
uses: XRPLF/actions/.github/workflows/determine-tidy-files.yml@70145243b905dc3e040a61d39c00e178cfb96f71
uses: XRPLF/actions/.github/workflows/determine-tidy-files.yml@d041ac9f1fa9f07a4ba335eb4c1c82233fb3fef6
run-clang-tidy:
name: Run clang tidy
needs: [determine-files]
if: ${{ needs.determine-files.outputs.cpp_changed_files != '' || needs.determine-files.outputs.need_full_run == 'true' }}
runs-on: ["self-hosted", "Linux", "X64", "heavy"]
container: "ghcr.io/xrplf/xrpld/nix-debian:sha-a0074f8"
container: "ghcr.io/xrplf/xrpld/nix-debian:sha-2e25435"
permissions:
contents: read
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Prepare runner
uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13
uses: XRPLF/actions/prepare-runner@ad188deb3dae79dc39816e16ddfdad1e06c6fab2
with:
enable_ccache: false
@@ -59,13 +59,6 @@ jobs:
with:
compiler: ${{ env.COMPILER }}
- name: Use cargo artifacts cache
uses: ./.github/actions/cargo-cache
with:
cache-directories: ${{ env.BUILD_DIR }}/corrosion
save-if: ${{ github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/heads/release') }}
workspaces: crates -> ../${{ env.BUILD_DIR }}/cargo
- name: Setup Conan
uses: ./.github/actions/setup-conan
@@ -89,10 +82,11 @@ jobs:
-Dverify_headers=ON \
..
- name: Build clang-tidy prerequisites
# clang-tidy needs headers generated from proto files
- name: Build libxrpl.libpb
working-directory: ${{ env.BUILD_DIR }}
run: |
ninja -j ${{ steps.nproc.outputs.nproc }} tidy_prerequisites
ninja -j ${{ steps.nproc.outputs.nproc }} xrpl.libpb
- name: Run clang tidy
id: run_clang_tidy
@@ -101,7 +95,7 @@ jobs:
TARGETS: ${{ needs.determine-files.outputs.need_full_run != 'true' && needs.determine-files.outputs.cpp_changed_files || 'include src tests' }}
run: |
set -o pipefail
run-clang-tidy -j ${{ steps.nproc.outputs.nproc }} -p "${BUILD_DIR}" -quiet -fix -format -allow-no-checks ${TARGETS} 2>&1 | tee "${OUTPUT_FILE}"
run-clang-tidy -j ${{ steps.nproc.outputs.nproc }} -p "${BUILD_DIR}" -quiet -fix -allow-no-checks ${TARGETS} 2>&1 | tee "${OUTPUT_FILE}"
- name: Print filtered clang-tidy errors
if: ${{ steps.run_clang_tidy.outcome != 'success' }}

View File

@@ -1,37 +1,17 @@
# Build Linux packages from the pre-built xrpld and validator-keys artifacts:
#
# - one job per distro, taken from "package_configs" in linux.json
# - each job runs in that distro's container, which is what decides DEB or RPM
# - with 'publish: true' a job also uploads what it built
# (see package/publish_pkg.sh)
#
# Only linux/amd64 is supported; the runner is hardcoded in the job below.
# Build Linux packages (DEB and RPM) from pre-built binary artifacts.
# Discovers which configurations to package from linux.json (configs in
# "package_configs") and fans out one job per distro. Only linux/amd64 is
# supported; the runner is hardcoded in the job below.
name: Package
on:
workflow_call:
inputs:
publish:
description: "Whether to publish the packages after building them."
required: false
type: boolean
default: false
nexus_url:
description: "The base URL of the Nexus instance hosting the deb and rpm repositories."
pkg_release:
description: "Package release number. Increment when repackaging the same executable."
required: false
type: string
default: https://packages.xrplf.org
secrets:
remote_username:
description: "The username of a Nexus account with write access to the repositories."
required: false
remote_password:
description: "The password or token for that Nexus account."
required: false
signing_key:
description: "Armoured PGP private key used to sign the RPMs. Required when publishing."
required: false
default: "1"
defaults:
run:
@@ -47,7 +27,7 @@ jobs:
matrix: ${{ steps.generate.outputs.matrix }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
@@ -61,11 +41,11 @@ jobs:
package:
needs: [generate-matrix]
if: ${{ github.event.repository.visibility == 'public' || startsWith(github.ref, 'refs/tags/') }}
if: ${{ github.event.repository.visibility == 'public' }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.generate-matrix.outputs.matrix) }}
name: "${{ matrix.xrpld_artifact_name }}"
name: "${{ matrix.artifact_name }}"
permissions:
contents: read
runs-on: ["self-hosted", "Linux", "X64", "heavy"]
@@ -74,56 +54,28 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Download pre-built xrpld binary
- name: Download pre-built binary
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ matrix.xrpld_artifact_name }}
name: ${{ matrix.artifact_name }}
path: ${{ env.BUILD_DIR }}
- name: Download pre-built validator-keys binary
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ matrix.validator_keys_artifact_name }}
path: ${{ env.BUILD_DIR }}
- name: Make binaries executable
run: chmod +x "${BUILD_DIR}/xrpld" "${BUILD_DIR}/validator-keys"
- name: Determine release info
id: release_info
uses: ./.github/actions/release-info
- name: Make binary executable
run: chmod +x "${BUILD_DIR}/xrpld"
- name: Build package
env:
PKG_RELEASE: ${{ steps.release_info.outputs.pkg_release }}
PKG_CHANNEL: ${{ steps.release_info.outputs.channel }}
PKG_RELEASE: ${{ inputs.pkg_release }}
run: ./package/build_pkg.sh
# Before the upload, so the artifact and the published package are the
# same bytes. DEBs are not signed, so the key is never set on that job.
- name: Sign RPM
if: ${{ inputs.publish && matrix.distro == 'rhel' }}
env:
PKG_SIGNING_KEY: ${{ secrets.signing_key }}
run: ./package/sign_rpm.sh "${BUILD_DIR}"
- name: Upload package artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ matrix.xrpld_artifact_name }}-pkg
name: ${{ matrix.artifact_name }}-pkg
path: |
${{ env.BUILD_DIR }}/debbuild/*.deb
${{ env.BUILD_DIR }}/debbuild/*.ddeb
${{ env.BUILD_DIR }}/rpmbuild/RPMS/**/*.rpm
if-no-files-found: error
- name: Publish package
if: ${{ inputs.publish }}
env:
CHANNEL: ${{ steps.release_info.outputs.channel }}
NEXUS_URL: ${{ inputs.nexus_url }}
NEXUS_USERNAME: ${{ secrets.remote_username }}
NEXUS_PASSWORD: ${{ secrets.remote_password }}
run: ./package/publish_pkg.sh "${CHANNEL}" "${BUILD_DIR}"

View File

@@ -1,80 +0,0 @@
# Clippy, coverage and documentation for the Rust crates in crates/. Each runs
# as an independent job on a GitHub-hosted runner, but inside the same container
# image used to build the crates in the C++/Corrosion path, so the toolchain
# (and therefore the lints, coverage instrumentation and the cargo cache) matches
# what production builds use.
#
# Rust unit tests are deliberately NOT run here. They run as part of the C++
# build (reusable-build-test-config.yml), which already compiles the crates on a
# self-hosted runner, so there is no need to provision a toolchain again.
name: Rust
on:
workflow_call:
secrets:
CODECOV_TOKEN:
description: "The Codecov token to use for uploading coverage reports."
required: true
defaults:
run:
shell: bash
working-directory: crates
permissions:
contents: read
jobs:
clippy:
runs-on: ubuntu-latest
container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Use cargo artifacts cache
uses: ./.github/actions/cargo-cache
- name: Run clippy
run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
coverage:
runs-on: ubuntu-latest
container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Use cargo artifacts cache
uses: ./.github/actions/cargo-cache
- name: Generate coverage report
run: cargo llvm-cov nextest --workspace --all-features --locked --no-tests=warn --lcov --output-path lcov.info
- name: Upload coverage report
if: ${{ github.repository == 'XRPLF/rippled' }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
disable_search: true
disable_telem: true
fail_ci_if_error: true
files: crates/lcov.info
flags: rust
plugins: noop
token: ${{ secrets.CODECOV_TOKEN }}
verbose: true
doc:
runs-on: ubuntu-latest
container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Use cargo artifacts cache
uses: ./.github/actions/cargo-cache
- name: Build documentation
env:
RUSTDOCFLAGS: "-D warnings"
run: cargo doc --workspace --no-deps --all-features --locked

View File

@@ -23,7 +23,7 @@ jobs:
matrix: ${{ steps.generate.outputs.matrix }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0

View File

@@ -40,18 +40,18 @@ defaults:
jobs:
upload:
runs-on: ubuntu-latest
container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8
container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-2e25435
env:
REMOTE_NAME: ${{ inputs.remote_name }}
CONAN_LOGIN_USERNAME_XRPLF: ${{ secrets.remote_username }}
CONAN_PASSWORD_XRPLF: ${{ secrets.remote_password }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Determine release info
id: release_info
uses: ./.github/actions/release-info
- name: Generate build version number
id: version
uses: ./.github/actions/generate-version
- name: Set up Conan
uses: ./.github/actions/setup-conan
@@ -64,8 +64,8 @@ jobs:
- name: Upload Conan recipe (version)
run: |
conan export . --version=${{ steps.release_info.outputs.version }}
conan upload --confirm --check --remote="${REMOTE_NAME}" xrpl/${{ steps.release_info.outputs.version }}
conan export . --version=${{ steps.version.outputs.version }}
conan upload --confirm --check --remote="${REMOTE_NAME}" xrpl/${{ steps.version.outputs.version }}
# When this workflow is triggered by a push event, it will always be when merging into the
# 'develop' branch, see on-trigger.yml.
@@ -92,4 +92,4 @@ jobs:
conan upload --confirm --check --remote="${REMOTE_NAME}" xrpl/release
outputs:
ref: xrpl/${{ steps.release_info.outputs.version }}
ref: xrpl/${{ steps.version.outputs.version }}

View File

@@ -65,18 +65,13 @@ jobs:
uses: XRPLF/actions/cleanup-workspace@c7d9ce5ebb03c752a354889ecd870cadfc2b1cd4
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Prepare runner
uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13
uses: XRPLF/actions/prepare-runner@ad188deb3dae79dc39816e16ddfdad1e06c6fab2
with:
enable_ccache: false
# Before any step that uses a build tool, composite actions included.
- name: Setup Nix environment
if: ${{ matrix.toolchain == 'nix' }}
uses: ./.github/actions/setup-nix-env
- name: Print build environment
uses: XRPLF/actions/print-build-env@59dec886e4afb05a1724443af08baccbc045b574
@@ -92,9 +87,7 @@ jobs:
with:
compiler: ${{ matrix.compiler }}
# `setup-nix-env` already did this for the Nix toolchain.
- name: Setup Conan
if: ${{ matrix.toolchain != 'nix' }}
env:
SANITIZERS: ${{ matrix.sanitizers }}
uses: ./.github/actions/setup-conan
@@ -113,10 +106,6 @@ jobs:
log_verbosity: ${{ runner.os == 'Windows' && 'quiet' || 'verbose' }}
sanitizers: ${{ matrix.sanitizers }}
- name: Check the Conan cache for Nix store references (Nix toolchain)
if: ${{ matrix.toolchain == 'nix' }}
run: ./bin/check-nix-store-refs.sh "${CONAN_HOME}"
- name: Log into Conan remote
if: ${{ github.repository == 'XRPLF/rippled' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }}
run: conan remote login "${CONAN_REMOTE_NAME}" "${{ secrets.NEXUS_REMOTE_USERNAME }}" --password "${{ secrets.NEXUS_REMOTE_PASSWORD }}"

3
.gitignore vendored
View File

@@ -89,6 +89,3 @@ target/
# clangd cache
/.cache
# Rust build directory
crates/target

View File

@@ -62,15 +62,6 @@ repos:
types_or: [c++, c, proto]
exclude: ^include/xrpl/protocol_autogen/(transactions|ledger_entries)/
- repo: local
hooks:
- id: cargo-fmt
name: cargo fmt
entry: cargo fmt --manifest-path crates/Cargo.toml --all
language: system
types: [rust]
pass_filenames: false # rustfmt formats the whole workspace
- repo: https://github.com/BlankSpruce/gersemi-pre-commit
rev: e98930bdc210d3387007f9252d8c1694ea7e410f # frozen: 0.27.7
hooks:

View File

@@ -42,7 +42,6 @@ This section contains changes targeting a future version.
### Bugfixes
- `get_aggregate_price`: Duplicate entries in the `oracles` request array are now ignored. [#6586](https://github.com/XRPLF/rippled/pull/6586)
- Peer Crawler: The `port` field in `overlay.active[]` now consistently returns an integer instead of a string for outbound peers. [#6318](https://github.com/XRPLF/rippled/pull/6318)
- `ping`: The `ip` field is no longer returned as an empty string for proxied connections without a forwarded-for header. It is now omitted, consistent with the behavior for identified connections. [#6730](https://github.com/XRPLF/rippled/pull/6730)
- gRPC `GetLedgerDiff`: Fixed error message that incorrectly said "base ledger not validated" when the desired ledger was not validated. [#6730](https://github.com/XRPLF/rippled/pull/6730)
@@ -54,8 +53,6 @@ This section contains changes targeting a future version.
- `submit`: The `fail_hard` field now returns an error if the value is not a boolean. [#6529](https://github.com/XRPLF/rippled/pull/6529)
- `subscribe`: The `taker` field in the `books` array now returns `actMalformed` instead of `badIssuer` if the value is not a valid account. [#6529](https://github.com/XRPLF/rippled/pull/6529)
- Fixed a bug in `Forwarded` HTTP header parsing where the extracted IP address could be incorrect when no comma or semicolon delimiter follows the address. This could cause the server to misidentify a client's IP address when operating behind a reverse proxy. [#6529](https://github.com/XRPLF/rippled/pull/6529)
- `gateway_balances`: The `account` and `ident` fields now return an `invalidParams` error if the value is not a string, instead of an `internal` error. [#7655](https://github.com/XRPLF/rippled/pull/7655)
- `account_lines`: The `peer` field now returns an error if the value is not a string. [#7728](https://github.com/XRPLF/rippled/pull/7728)
## XRP Ledger server version 3.1.0

122
BUILD.md
View File

@@ -1,17 +1,37 @@
| :warning: **WARNING** :warning: |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| These instructions assume you have a C++ development environment ready with Git, Python, Conan, CMake, Rust, and a C++ compiler. For help setting one up on Linux, macOS, or Windows, [see this guide](./docs/build/environment.md).<br><br>These instructions also assume a basic familiarity with Conan and CMake. If you are unfamiliar with Conan, you can read our [crash course](./docs/build/conan.md) or the official [Getting Started][conan-getting-started] walkthrough. |
| :warning: **WARNING** :warning: |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| These instructions assume you have a C++ development environment ready with Git, Python, Conan, CMake, and a C++ compiler. For help setting one up on Linux, macOS, or Windows, [see this guide](./docs/build/environment.md).<br><br>These instructions also assume a basic familiarity with Conan and CMake. If you are unfamiliar with Conan, you can read our [crash course](./docs/build/conan.md) or the official [Getting Started][conan-getting-started] walkthrough. |
## Minimum Requirements
For the hardware needed to run a node, see
[System Requirements](https://xrpl.org/system-requirements.html).
See [System Requirements](https://xrpl.org/system-requirements.html).
For the software needed to build xrpld, see the
[environment setup guide](./docs/build/environment.md).
Building xrpld generally requires Git, Python, Conan, CMake, and a C++
compiler.
- [Python](https://www.python.org/downloads/)
- [Conan](https://conan.io/downloads.html)
- [CMake](https://cmake.org/download/)
You can verify that the required tools are installed and runnable with:
```bash
./bin/check-tools.sh
```
`xrpld` is written in the C++23 dialect. The [tested compiler versions][cpp23-support] are:
| Compiler | Version |
| ----------- | --------------- |
| GCC | 15.2 |
| Clang | 22 |
| Apple Clang | 21 |
| MSVC | 19.44[^windows] |
## Operating Systems
Please see the [environment setup guide](./docs/build/environment.md) for detailed instructions for all platforms.
### Linux
The Ubuntu Linux distribution has received the highest level of quality
@@ -22,13 +42,12 @@ Our Linux CI tooling is distro-independent and uses a Nix-based environment, so
### macOS
Many `xrpld` engineers use macOS for development.
The minimum supported version is macOS 15 (Sequoia).
CI testing is done in macOS 26 (Tahoe), but the build defaults `CMAKE_OSX_DEPLOYMENT_TARGET` to 15.
### Windows
Windows is used by some engineers for development only, and is not recommended
for production use.
Windows is used by some engineers for development only.
[^windows]: Windows is not recommended for production use.
## Steps
@@ -53,25 +72,37 @@ releases](https://github.com/XRPLF/rippled/releases).
### Set Up Conan
Once your [development environment](./docs/build/environment.md) is ready, set
Conan up for this repository:
After you have a [C++ development environment](./docs/build/environment.md) ready with Git, Python,
Conan, CMake, and a C++ compiler, you may need to set up your Conan profile.
These instructions assume a basic familiarity with Conan and CMake. If you are
unfamiliar with Conan, then please read [this crash course](./docs/build/conan.md) or the official
[Getting Started][conan-getting-started] walkthrough.
#### Profiles
We recommend that you install our Conan profiles:
```bash
./conan/init.sh
conan config install conan/profiles/ -tf $(conan config home)/profiles/
```
That installs our [`global.conf`](./conan/global.conf), our Conan
[profiles](./conan/profiles), and the `xrplf` remote that hosts some of our
dependencies. It honours `CONAN_HOME` and never deletes an existing Conan home,
so it is safe to re-run — it only overwrites the files it manages.
You can check your Conan profile by running:
> [!TIP]
> In the [Nix development shell](./docs/build/nix.md#conan-configuration) this is
> already done for you: the script runs on entry.
```bash
conan profile show
```
You can inspect the resulting profile with `conan profile show`. If it is not
suitable for your environment, create a custom profile and pass it to Conan — see
[Advanced Conan configuration](./docs/build/advanced_conan.md).
If the default profile is not suitable for your environment, you can create a custom profile and pass it to Conan.
More information on customizing Conan can be found in the [Advanced Conan configuration](./docs/build/advanced_conan.md).
#### Add xrplf remote
Run the following command to add the `xrplf` remote, which hosts some of our dependencies:
```bash
conan remote add --index 0 --force xrplf https://conan.xrplf.org/repository/conan/
```
### Set Up Ccache
@@ -214,17 +245,7 @@ cmake --build . --target setup_code_gen # create venv and install dependencies
cmake --build . --target code_gen # regenerate code
```
The same targets are also available as a standalone project, which does not
need the dependencies to be configured first:
```
cmake -S cmake/codegen -B build/codegen
cmake --build build/codegen --target setup_code_gen
cmake --build build/codegen --target code_gen
```
The regenerated files should be committed alongside your changes. CI verifies
that they are up-to-date.
The regenerated files should be committed alongside your changes.
## Coverage report
@@ -236,14 +257,10 @@ which is only enabled when the `coverage` option is set, e.g. with
Prerequisites for the coverage report:
- [gcovr tool][gcovr] (can be installed e.g. with [pip][python-pip])
- `gcov` for GCC or `llvm-cov` for Clang, usually installed with the compiler
- `gcov` for GCC (installed with the compiler by default) or
- `llvm-cov` for Clang (installed with the compiler by default)
- `Debug` build type
> [!NOTE]
> Clang coverage is not available in the [Nix development shell](./docs/build/nix.md#building-xrpld-in-the-nix-shell):
> its `clang` shells do not ship `llvm-cov`. Use a `gcc` shell instead (`.#gcc`,
> or `.#gcc-plain` on Linux), which provides a `gcov` matching its compiler.
A coverage report is created when the following steps are completed, in order:
1. `xrpld` binary built with instrumentation data, enabled by the `coverage`
@@ -316,22 +333,6 @@ memory) since they concatenate sources into fewer translation units. Non-unity
builds may be faster for incremental builds, and can be helpful for detecting
`#include` omissions.
### Rust crates
The build compiles the Rust workspace in `crates/` and generates the cxxbridge
bindings the C++ side includes, so it needs a Rust toolchain (`cargo`, `rustc`)
at the channel pinned in [`rust-toolchain.toml`](./rust-toolchain.toml). The
[Nix development shell](./docs/build/nix.md) provides one; otherwise install it
as described in [Rust](./docs/build/environment.md#rust).
The crates also have their own Rust unit tests. Those are run with `cargo` and
need only the Rust toolchain, independently of CMake (CI runs them with
`cargo nextest`):
```bash
cargo test --manifest-path crates/Cargo.toml --workspace
```
### Verifying headers
The regular build only compiles `.cpp` files, so a header is only ever checked
@@ -376,14 +377,10 @@ After any updates or changes to dependencies, you may need to do the following:
4. [Regenerate lockfile](./docs/build/advanced_conan.md#conan-lockfile).
5. Re-run [conan install](#build-and-test).
If you are using the Nix development shell, whether prebuilt Conan binaries apply
depends on your platform — see
[Prebuilt packages](./docs/build/nix.md#prebuilt-packages).
#### ERROR: Package not resolved
If you're seeing an error like `ERROR: Package 'snappy/1.1.10' not resolved: Unable to find 'snappy/1.1.10#968fef506ff261592ec30c574d4a7809%1756234314.246' in remotes.`,
please [set Conan up](#set-up-conan) so the `xrplf` remote is configured, or re-run `conan export` for [patched recipes](./docs/build/advanced_conan.md#patched-recipes).
please [add `xrplf` remote](#add-xrplf-remote) or re-run `conan export` for [patched recipes](./docs/build/advanced_conan.md#patched-recipes).
### `protobuf/port_def.inc` file not found
@@ -403,6 +400,7 @@ For example, if you want to build Debug:
1. For conan install, pass `--settings build_type=Debug`
2. For cmake, pass `-DCMAKE_BUILD_TYPE=Debug`
[cpp23-support]: https://en.cppreference.com/w/cpp/compiler_support/23
[conan-getting-started]: https://docs.conan.io/en/latest/getting_started.html
[unity-build]: https://en.wikipedia.org/wiki/Unity_build
[gcovr]: https://gcovr.com/en/stable/getting-started.html

View File

@@ -13,23 +13,6 @@ if(DEFINED CMAKE_MODULE_PATH)
endif()
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
# Must be set before project() because project() consumes it when configuring the compiler and SDK.
# A user-provided -DCMAKE_OSX_DEPLOYMENT_TARGET still takes precedence.
#
# CMAKE_SYSTEM_NAME can't be used before project(), so CMAKE_HOST_SYSTEM_NAME is used instead.
#
# When CMAKE_OSX_DEPLOYMENT_TARGET is bumped to >=26.0, FastFloat dependency won't be needed anymore
if(
CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin"
AND NOT DEFINED CMAKE_OSX_DEPLOYMENT_TARGET
)
set(CMAKE_OSX_DEPLOYMENT_TARGET
"15.0"
CACHE STRING
"Minimum macOS deployment version"
)
endif()
project(xrpl)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD 23)
@@ -104,7 +87,6 @@ include(deps/Boost)
add_subdirectory(external/antithesis-sdk)
find_package(date REQUIRED)
find_package(ed25519 REQUIRED)
find_package(FastFloat REQUIRED)
find_package(gRPC REQUIRED)
find_package(LibArchive REQUIRED)
find_package(lz4 REQUIRED)
@@ -120,7 +102,6 @@ target_link_libraries(
xrpl_libs
INTERFACE
ed25519::ed25519
FastFloat::fast_float
lz4::lz4
mpt-crypto::mpt-crypto
OpenSSL::Crypto
@@ -158,16 +139,11 @@ if(coverage)
include(XrplCov)
endif()
add_custom_target(tidy_prerequisites)
add_subdirectory(crates)
include(XrplCore)
include(XrplProtocolAutogen)
include(XrplInstall)
include(XrplValidatorKeys)
# Must come after XrplValidatorKeys: the 'package' target depends on the
# validator-keys target existing.
include(XrplPackaging)
include(XrplValidatorKeys)
if(tests)
include(CTest)

View File

@@ -225,9 +225,8 @@ environment, so you don't need to install most of the individual tools
yourself. The version of each hook sourced from an external repository
(`clang-format`, `gersemi`, etc.) is pinned in that file, so running the hooks
locally uses exactly the same versions as CI. A few `local` hooks — most notably
`clang-tidy` and `cargo fmt` — run tools from your own environment; see
[Installing clang-tidy](#installing-clang-tidy) and
[Rust](./docs/build/environment.md#rust) for how to get those.
`clang-tidy` — run tools from your own environment; see
[Installing clang-tidy](#installing-clang-tidy) for how to get those.
To get started, install `pre-commit` and enable the git hook scripts:
@@ -256,7 +255,6 @@ The hooks configured in this repository include, among others:
- `clang-tidy` — C++ static analysis (see [Clang-tidy](#clang-tidy)); opt in with `TIDY=1`
- `fix-include-style`, `fix-pragma-once`, `check-doxygen-style` — C++ hygiene
- `gersemi` — CMake formatting
- `cargo fmt` — Rust formatting for the crates in `crates/`
- `prettier`, `black`, `shfmt` — formatting for JavaScript/JSON/Markdown, Python, and shell
- `cspell` — spell checking
@@ -321,11 +319,7 @@ See the [environment setup guide](./docs/build/environment.md#clang-tidy) for ho
### Running clang-tidy locally
Before running clang-tidy, you must generate the files it depends on (protobuf headers and the cxxbridge headers from the Rust crates). Configure the project as described in [`BUILD.md`](./BUILD.md), then build the `tidy_prerequisites` target, which generates all of them:
```bash
cmake --build build --target tidy_prerequisites
```
Before running clang-tidy, you must build the project to generate required files (particularly protobuf headers). Refer to [`BUILD.md`](./BUILD.md) for build instructions.
#### Via pre-commit (recommended)
@@ -354,14 +348,12 @@ run-clang-tidy -p build -allow-no-checks src tests
```
This will check all source files in the `src`, `include` and `tests` directories using the compile commands from your `build` directory.
If you wish to automatically fix whatever clang-tidy finds _and_ is capable of fixing, add `-fix -format` to the above command:
If you wish to automatically fix whatever clang-tidy finds _and_ is capable of fixing, add `-fix` to the above command:
```
run-clang-tidy -p build -quiet -fix -format -allow-no-checks src tests
run-clang-tidy -p build -quiet -fix -allow-no-checks src tests
```
`-format` reformats the fixed code with [`.clang-format`](./.clang-format); without it the fixes are inserted in LLVM style and the `clang-format` hook rewrites them afterwards.
## Contracts and instrumentation
We are using [Antithesis](https://antithesis.com/) for continuous fuzzing,

View File

@@ -54,7 +54,6 @@ Here are some good places to start learning the source code:
| `./docs` | Source documentation files and doxygen config. |
| `./cfg` | Example configuration files. |
| `./src` | Source code. |
| `./crates` | Rust source code. |
Some of the directories under `src` are external repositories included using
git-subtree. See those directories' README files for more details.

View File

@@ -1,111 +0,0 @@
#!/usr/bin/env bash
# Fail if a binary under <path> records a /nix/store path it resolves at run
# time. See docs/build/nix.md#prebuilt-packages for why that matters.
#
# <path> is a file or a directory. macOS: nothing may reference the store, so
# point it at whole trees. Linux: the toolchain always writes the store into
# PT_INTERP and RUNPATH, so only at what cmake/PatchNixBinary.cmake retargets.
#
# Only Mach-O / ELF is inspected. Static archives hold store paths in debug info
# alone; the scripts in a Conan cache are all git hook samples and autotools
# scratch, 36 false positives to 0 real.
#
# Usage: bin/check-nix-store-refs.sh <path>
set -euo pipefail
if [ "$#" -ne 1 ]; then
echo "usage: $0 <path>" >&2
exit 2
fi
if [ ! -e "$1" ]; then
echo "$0: no such path: $1" >&2
exit 2
fi
case "$(uname -s)" in
Darwin)
format=Mach-O
recorded_paths=macho_recorded_paths
tool=otool
;;
Linux)
format=ELF
recorded_paths=elf_recorded_paths
tool=readelf
;;
*)
echo "Unsupported OS - skipping the Nix store reference check."
exit 0
;;
esac
# `pipefail` would catch this too, but only as a bare nonzero exit.
if ! command -v "${tool}" >/dev/null; then
echo "$0: ${tool} not found; cannot inspect binaries" >&2
exit 2
fi
# Both list what the file records. `ldd` would answer what this machine resolves
# now, which is wrong both ways: store paths for a correctly patched binary,
# silence for a store RUNPATH that resolves nowhere.
# `name` covers LC_ID_DYLIB and LC_LOAD*_DYLIB, `path` covers LC_RPATH.
macho_recorded_paths() {
otool -l "$1" | sed -nE 's#^ *(name|path) ([^ ]*).*#\2#p'
}
# RPATH and RUNPATH are colon-separated.
elf_recorded_paths() {
readelf -ldW "$1" |
sed -nE \
-e 's#.*program interpreter: ([^]]*)\].*#\1#p' \
-e 's#.*\((RPATH|RUNPATH|NEEDED)\).*\[([^]]*)\].*#\2#p' |
tr ':' '\n'
}
checked=0
skipped=0
leaked=0
while IFS= read -r file; do
case "$(file -b "${file}" 2>/dev/null)" in
*"${format}"*) ;;
*)
skipped=$((skipped + 1))
continue
;;
esac
checked=$((checked + 1))
# Filter after extracting, or a search path starting elsewhere ($ORIGIN)
# hides the rest. `sed` not `grep`: grep calls "no matches" a failure, and
# the `|| true` that would need masks a broken pipeline too.
refs="$("${recorded_paths}" "${file}" | sed -n '\#^/nix/store/#p' | sort -u)"
if [ -n "${refs}" ]; then
leaked=$((leaked + 1))
echo "::error file=${file}::references the Nix store at run time"
echo "${file}"
echo "${refs}" | sed 's/^/ /'
fi
done < <(find "$1" -type f \( -perm -u+x -o -name '*.dylib' -o -name '*.so*' \))
echo "$1: checked ${checked}, skipped ${skipped}, ${leaked} with Nix store references."
if [ "${leaked}" -ne 0 ]; then
cat >&2 <<'EOF'
Fixes, in order of preference:
- A Conan package built before this check existed: drop it
(`conan remove '<name>/*'`) and rebuild.
- A binary that should have been retargeted to the system loader: check that
cmake/PatchNixBinary.cmake ran for it.
- Link the macOS system library instead of the Nix one - see
libresolvSystemStub in nix/darwin.nix.
- No system library exists (libstdc++): link it statically.
- None of the above: pin the toolchain into the package ID, following
`user.package:libc_version` in conan/profiles/ci.
EOF
exit 1
fi

View File

@@ -15,14 +15,10 @@
# - Windows: the core build tools only (CMake, Conan, Git, Python).
# MSVC is expected to be provided separately and is not checked here.
#
# Some tools (clang-format, clang-tidy, doxygen, gcovr, gh, git-cliff, gpg,
# pre-commit, run-clang-tidy) are present in our Linux CI images and in local
# development setups, but not in the macOS CI environment. They are checked
# everywhere except when running in CI on macOS.
#
# Tools that Nix also exposes under a version-suffixed name (`clang-tidy-22`,
# `g++-15`, ...) are probed under both names: a suffixed name can break while
# the plain one still works (see mkVersionedToolLinks in nix/packages.nix).
# Some tools (clang-format, doxygen, gcovr, gh, git-cliff, gpg, pre-commit,
# run-clang-tidy) are present in our Linux CI images and in local development
# setups, but not in the macOS CI environment. They are checked everywhere
# except when running in CI on macOS.
#
# Environment variables:
# CI if set, skip the tools above when on macOS.
@@ -30,27 +26,12 @@
set -uo pipefail
# Version suffixes of the Nix tool links, tracking nix/packages.nix.
gcc_version=15
llvm_version=22
missing=()
checked=0
# tool_path <name>
# Fully resolved path of a tool, so the snapshots record which derivation
# provides it. Prints nothing when it isn't on PATH.
tool_path() {
local path
path="$(command -v "$1" 2>/dev/null)" || return 0
readlink -f "${path}" 2>/dev/null || printf '%s' "${path}"
}
# check <name> [probe-command...]
# Runs the probe (default: "<name> --version"), capturing both stdout and
# stderr, and prints three lines: the status and name, the first non-blank line
# of the probe output (its version, or the error when it failed), and the tool's
# resolved path. Records <name> as missing if it is not found or exits non-zero.
# Runs the probe (default: "<name> --version") quietly. Records <name> as
# missing if the command is not found or exits non-zero.
check() {
local name="$1"
shift
@@ -59,18 +40,14 @@ check() {
probe=("${name}" --version)
fi
echo "Checking ${name}..."
checked=$((checked + 1))
local output version path
path="$(tool_path "${name}")"
if output="$("${probe[@]}" 2>&1)"; then
printf ' ✅ %s\n' "${name}"
if "${probe[@]}" | head -n 1; then
printf ' [ ok ] %s\n' "${name}"
else
printf ' %s\n' "${name}"
printf ' [MISS] %s\n' "${name}"
missing+=("${name}")
fi
version="$(printf '%s\n' "${output}" | grep -m1 '[^[:space:]]' || true)"
printf ' %s\n' "${version:-(no output)}"
printf ' %s\n' "${path:-(not found)}"
}
case "$(uname -s)" in
@@ -102,35 +79,24 @@ if [ "${os}" = "linux" ] || [ "${os}" = "macos" ]; then
echo "Development tooling:"
check ccache
check clang
check "clang-${llvm_version}"
check clang++
check "clang++-${llvm_version}"
check ClangBuildAnalyzer
check curl
check file
check less
check make
# net-tools netstat reports "net-tools X.Y"; macOS ships BSD netstat with no
# version flag, so fall back to a presence marker there.
check netstat sh -c 'command -v netstat >/dev/null && { netstat --version 2>&1 | grep -m1 -oE "net-tools [0-9.]+" || echo present; }'
check netstat which netstat
check ninja
check perl perl -e 'print "$^V\n"'
check perl
check pkg-config
check vim
check zip bash -c 'zip --version 2>&1 | grep -m1 -oE "Zip [0-9.]+"'
check zip
# These tools are present in our Linux CI images and in local development
# setups, but not in the macOS CI environment. So check them everywhere
# except when running in CI on macOS.
if [ "${os}" = "linux" ] || [ -z "${CI:-}" ]; then
check clang-apply-replacements
check "clang-apply-replacements-${llvm_version}"
check clang-format
check "clang-format-${llvm_version}"
# clang-tidy leads --version with the LLVM banner, not the version.
tidy_probe="--version | grep -m1 -oE 'LLVM version [0-9.]+'"
check clang-tidy sh -c "clang-tidy ${tidy_probe}"
check "clang-tidy-${llvm_version}" sh -c "clang-tidy-${llvm_version} ${tidy_probe}"
check dot
check doxygen
check gcovr
@@ -141,7 +107,6 @@ if [ "${os}" = "linux" ] || [ "${os}" = "macos" ]; then
# pre-commit, or its alternative implementation prek
check pre-commit sh -c 'pre-commit --version || prek --version'
check run-clang-tidy run-clang-tidy --help
check "run-clang-tidy-${llvm_version}" "run-clang-tidy-${llvm_version}" --help
fi
fi
@@ -156,7 +121,7 @@ if [ "${os}" = "linux" ] || [ "${os}" = "macos" ]; then
check cargo-audit cargo audit --version
check cargo-llvm-cov cargo llvm-cov --version
check cargo-nextest cargo nextest --version
check clippy-driver
check clippy clippy-driver --version
check rust-analyzer
check rustc
check rustfmt
@@ -168,11 +133,7 @@ if [ "${os}" = "linux" ]; then
echo
echo "GCC toolchain:"
check gcc
check "gcc-${gcc_version}"
check g++
check "g++-${gcc_version}"
check cpp
check "cpp-${gcc_version}"
check gcov
echo
@@ -197,9 +158,9 @@ else
checked=$((checked + 1))
tmp_clone="$(mktemp -d)"
if git clone --depth 1 https://github.com/XRPLF/actions.git "${tmp_clone}/actions" >/dev/null 2>&1; then
printf ' git clone over HTTPS\n'
printf ' [ ok ] git clone over HTTPS\n'
else
printf ' git clone over HTTPS\n'
printf ' [MISS] git clone over HTTPS\n'
missing+=("git-https-clone")
fi
rm -rf "${tmp_clone}"
@@ -207,9 +168,9 @@ fi
echo
if [ "${#missing[@]}" -eq 0 ]; then
echo "All ${checked} checked tools are present and runnable."
echo "All ${checked} checked tools are present and runnable."
else
echo "Missing or non-functional tools (${#missing[@]} of ${checked}):" >&2
echo "Missing or non-functional tools (${#missing[@]} of ${checked}):" >&2
for tool in "${missing[@]}"; do
echo " - ${tool}" >&2
done

View File

@@ -144,11 +144,7 @@ def main():
+ files
)
canonicalize_fix_paths(Path(fixes_dir))
# `FormatStyle` in .clang-tidy does not reach this path,
# so ask for the repository style here.
applied = subprocess.run(
[clang_apply_replacements, "--format", "--style=file", fixes_dir]
)
applied = subprocess.run([clang_apply_replacements, fixes_dir])
return result.returncode or applied.returncode

View File

@@ -488,17 +488,6 @@
# Must be a number between 100 and 1000, defaults to 250
#
#
# [max_subscriptions_per_connection]
#
# Maximum number of account, real-time account, and account-history
# subscriptions a single client connection may hold at once. Bounds the
# per-connection state torn down when the connection disconnects. Book
# subscriptions are tracked separately and are not counted here.
#
# Defaults to 100000 if not set; large enough for legitimate power users
# such as block explorers.
#
#
# [overlay]
#
# Controls settings related to the peer to peer overlay.
@@ -549,45 +538,6 @@
# only be used for local testing and debugging. Do not disable
# on mainnet.
#
# max_untrusted_count = <number>
#
# The number of manifests the server keeps for validators it does not
# list, and the number it sends and processes in a single peer protocol
# message. Once the server holds this many, a manifest for a new
# unlisted validator is rejected, so peer gossip cannot grow the cache
# without end.
#
# This option can take any value between 50 and 1000, inclusive. If
# the option is not present the server uses its built-in value.
#
# The current default (which is subject to change) is 300.
#
# max_trusted_count = <number>
#
# The number of manifests for listed validators to allow for when
# sizing peer protocol messages. Manifests for listed validators are
# never dropped, whether sending or receiving, because doing so would
# delay a validator key change reaching this server. Set this above the
# number of validators the server lists.
#
# Together the two counts above set the largest manifest message the
# server accepts: bigger messages are discarded without reading them,
# and without penalising the sender. Raising either means the server
# accepts and sends bigger messages than a peer using the defaults, and
# those peers will discard what this server sends. Lowering either below
# what peers send makes this server discard their manifest messages,
# which it does without recording anything.
#
# This option can take any value between 50 and 1000, inclusive. If
# the option is not present the server uses its built-in value.
#
# The current default (which is subject to change) is 300.
#
# NOTE: These two options (max_untrusted_count and max_trusted_count)
# are transitional. They exist to bound manifest-message size and cache
# growth during the network upgrade. They may be removed in a future
# release once the fleet has upgraded, and should not be relied upon as
# stable configuration.
#
# [transaction_queue] EXPERIMENTAL
#

View File

@@ -29,27 +29,6 @@ if(CMAKE_GENERATOR STREQUAL "Xcode")
set(is_xcode TRUE)
endif()
# --------------------------------------------------------------------
# Nix toolchain detection
# --------------------------------------------------------------------
# True when the C++ compiler resolves into the Nix store. CMAKE_CXX_COMPILER may
# be referenced through a symlink outside the store (a Nix profile, a /usr/bin
# alternative, ...), so resolve the real path before matching.
set(is_nix_compiler FALSE)
get_filename_component(_cxx_real "${CMAKE_CXX_COMPILER}" REALPATH)
if(_cxx_real MATCHES "^/nix/store/")
set(is_nix_compiler TRUE)
endif()
unset(_cxx_real)
# True inside the Nix CI Docker image, identified by the /nix/ci-env tree it
# ships (see nix/docker/Dockerfile). The dev shell and bare systems don't have
# it, so it distinguishes the CI image from other Nix-compiler environments.
set(is_ci_image FALSE)
if(EXISTS "/nix/ci-env/bin")
set(is_ci_image TRUE)
endif()
# --------------------------------------------------------------------
# Operating system detection
# --------------------------------------------------------------------

View File

@@ -1,38 +1,26 @@
#[===================================================================[
Patch executables to run in non-Nix environments.
The Nix toolchain links binaries against an ELF interpreter (loader)
The Nix-based CI image links binaries against an ELF interpreter (loader)
that lives in the Nix store, so the resulting binaries don't run elsewhere
(including once installed from the .deb package). `patch_nix_binary` resets
the interpreter to the system default loader and drops the rpath, once the
binary has been linked.
(including once installed from the .deb package). `patch_nix_binary` adds a
POST_BUILD step that resets the interpreter to the system default loader and
drops the rpath.
This runs by default for Nix-toolchain builds (determined by whether the compiler resolves under /nix/store/).
Those builds are where binaries get a Nix-store loader.
It is opted out of by setting the XRPLD_NO_PATCH_NIX_BINARY environment variable
the plain Nix dev shells set it, since their binaries link a newer glibc
and must not be retargeted to the system loader.
Non-Nix builds (a system compiler, already using the system loader) and sanitizer builds
(runtime libraries resolved through the rpath) are skipped too.
Everywhere else `patch_nix_binary` is a no-op.
The default loader is resolved by bin/default-loader-path.sh.
This is only active inside the Nix-based image, detected by the presence of
/tmp/loader-path.sh (shipped by that image, resolves the default loader). It
is skipped for sanitizer builds, whose runtime libraries are resolved through
the rpath. Everywhere else `patch_nix_binary` is a no-op.
#]===================================================================]
include_guard(GLOBAL)
include(CompilationEnv)
# Resolves the system default ELF loader path for the current architecture.
set(_loader_path_script "${CMAKE_SOURCE_DIR}/bin/default-loader-path.sh")
# Provided by the Nix-based CI image; prints the system default ELF loader path.
set(_loader_path_script "/tmp/loader-path.sh")
if(
is_linux
AND NOT SANITIZERS_ENABLED
AND is_nix_compiler
AND NOT DEFINED ENV{XRPLD_NO_PATCH_NIX_BINARY}
)
if(is_linux AND NOT SANITIZERS_ENABLED AND EXISTS "${_loader_path_script}")
execute_process(
COMMAND "${_loader_path_script}"
OUTPUT_VARIABLE DEFAULT_LOADER_PATH
@@ -53,38 +41,13 @@ function(patch_nix_binary target)
if(NOT PATCH_NIX_BINARIES)
return()
endif()
set(patch_command
"${PATCHELF_COMMAND}"
--set-interpreter
"${DEFAULT_LOADER_PATH}"
--remove-rpath
"$<TARGET_FILE:${target}>"
add_custom_command(
TARGET ${target}
POST_BUILD
COMMAND
"${PATCHELF_COMMAND}" --set-interpreter "${DEFAULT_LOADER_PATH}"
--remove-rpath "$<TARGET_FILE:${target}>"
COMMENT "Patching ${target}: set default loader, remove rpath"
VERBATIM
)
set(comment "Patching ${target}: set default loader, remove rpath")
# POST_BUILD is the cheap way to do this: it runs only when the binary is
# relinked. It is also only available in the directory that defined the
# target, so for a target from elsewhere (e.g. a FetchContent subproject)
# fall back to a custom target that runs after the binary is linked. That
# one runs on every build, which is harmless because patchelf is idempotent.
get_target_property(target_source_dir ${target} SOURCE_DIR)
if("${target_source_dir}" STREQUAL "${CMAKE_CURRENT_SOURCE_DIR}")
add_custom_command(
TARGET ${target}
POST_BUILD
COMMAND ${patch_command}
COMMENT "${comment}"
VERBATIM
)
else()
add_custom_target(
${target}-patch-nix
ALL
COMMAND ${patch_command}
COMMENT "${comment}"
VERBATIM
)
add_dependencies(${target}-patch-nix ${target})
endif()
endfunction()

View File

@@ -1,5 +1,3 @@
include_guard()
include(isolate_headers)
# Define a benchmark executable for the module `name`.

View File

@@ -120,10 +120,7 @@ if(MSVC)
_SILENCE_ALL_CXX17_DEPRECATION_WARNINGS
$<$<AND:$<COMPILE_LANGUAGE:CXX>,$<CONFIG:Debug>>:_CRTDBG_MAP_ALLOC>
)
target_link_libraries(
common
INTERFACE -errorreport:none -machine:X64 -ignore:4099
)
target_link_libraries(common INTERFACE -errorreport:none -machine:X64)
else()
target_compile_options(
common
@@ -174,8 +171,9 @@ else()
# Clang wrapper supplies those paths itself (via -nostdinc++), so at compile time the
# flag is unused -> Clang errors under our -Werror. At link time the flag IS consumed
# (it selects the C++ runtime), so we move it there instead of dropping it entirely.
get_filename_component(_cxx_real "${CMAKE_CXX_COMPILER}" REALPATH)
if(
is_nix_compiler
_cxx_real MATCHES "^/nix/store/"
AND is_linux
AND is_clang
AND CMAKE_CXX_FLAGS MATCHES "stdlib=libstdc"
@@ -269,50 +267,10 @@ elseif(use_lld)
)
if("${LD_VERSION}" MATCHES "LLD")
target_link_libraries(common INTERFACE -fuse-ld=lld)
# remembered for the linker flag probe below
set(fuse_ld_flag "-fuse-ld=lld")
endif()
unset(LD_VERSION)
endif()
# Linker warnings are errors where we control the toolchain and the dependencies: CI and the Nix dev shell.
# On non-Nix macOS we suppress the deployment target warning: an old Conan profile may not pin os.version.
# Only the new Apple linker understands the flag, so probe the actual linker (lld may be selected above).
if(is_macos OR is_linux)
if(is_ci OR is_nix_compiler)
if(is_macos)
set(fatal_warnings_flag "-Wl,-fatal_warnings")
else()
set(fatal_warnings_flag "-Wl,--fatal-warnings")
endif()
message(
STATUS
"Treating all linker warnings as errors (${fatal_warnings_flag})"
)
target_link_options(common INTERFACE "${fatal_warnings_flag}")
unset(fatal_warnings_flag)
elseif(is_macos)
set(silence_flag "-Wl,-deployment_target_mismatches,suppress")
set(probe_flags ${fuse_ld_flag} "${silence_flag}")
include(CheckLinkerFlag)
check_linker_flag(
CXX
"${probe_flags}"
have_deployment_target_mismatches
)
if(have_deployment_target_mismatches)
message(
STATUS
"Silencing macOS deployment target mismatch warnings (${silence_flag})"
)
target_link_options(common INTERFACE "${silence_flag}")
endif()
unset(probe_flags)
unset(silence_flag)
endif()
endif()
unset(fuse_ld_flag)
if(assert)
foreach(var_ CMAKE_C_FLAGS_RELEASE CMAKE_CXX_FLAGS_RELEASE)
string(REGEX REPLACE "[-/]DNDEBUG" "" ${var_} "${${var_}}")

View File

@@ -51,8 +51,6 @@ target_compile_options(
target_link_libraries(xrpl.libpb PUBLIC protobuf::libprotobuf gRPC::grpc++)
add_dependencies(tidy_prerequisites xrpl.libpb)
# TODO: Clean up the number of library targets later.
add_library(xrpl.imports.main INTERFACE)
@@ -135,12 +133,6 @@ target_link_libraries(
add_module(xrpl resource)
target_link_libraries(xrpl.libxrpl.resource PUBLIC xrpl.libxrpl.protocol)
add_module(xrpl peerfinder)
target_link_libraries(
xrpl.libxrpl.peerfinder
PUBLIC xrpl.libxrpl.basics xrpl.libxrpl.protocol
)
# Level 08
add_module(xrpl net)
target_link_libraries(
@@ -207,21 +199,7 @@ target_link_libraries(
)
add_module(xrpl tx)
target_link_libraries(
xrpl.libxrpl.tx
PUBLIC xrpl.libxrpl.ledger xrpl_wasm_vm_ffi_cxxbridge
)
add_dependencies(xrpl.libxrpl.tx xrpl_crates)
add_module(xrpl consensus)
target_link_libraries(
xrpl.libxrpl.consensus
PUBLIC
xrpl.libxrpl.basics
xrpl.libxrpl.json
xrpl.libxrpl.protocol
xrpl.libxrpl.ledger
)
target_link_libraries(xrpl.libxrpl.tx PUBLIC xrpl.libxrpl.ledger)
add_library(xrpl.libxrpl)
set_target_properties(xrpl.libxrpl PROPERTIES OUTPUT_NAME xrpl)
@@ -242,7 +220,6 @@ target_link_modules(
beast
conditions
config
consensus
core
crypto
git
@@ -250,7 +227,6 @@ target_link_modules(
ledger
net
nodestore
peerfinder
protocol
protocol_autogen
rdb

View File

@@ -44,7 +44,6 @@ setup_target_for_coverage_gcovr(
EXCLUDE
"src/test"
"src/tests"
"src/benchmarks"
"include/xrpl/beast/test"
"include/xrpl/beast/unit_test"
"${CMAKE_BINARY_DIR}/pb-xrpl.libpb"

View File

@@ -25,19 +25,6 @@ if(NOT (RPMBUILD_EXECUTABLE OR DPKG_BUILDPACKAGE_EXECUTABLE))
return()
endif()
if(NOT TARGET xrpld)
message(STATUS "xrpld=ON is required; 'package' target not available")
return()
endif()
if(NOT TARGET validator-keys)
message(
STATUS
"validator_keys=ON is required; 'package' target not available"
)
return()
endif()
set(package_env
SRC_DIR=${CMAKE_SOURCE_DIR}
BUILD_DIR=${CMAKE_BINARY_DIR}
@@ -50,7 +37,7 @@ add_custom_target(
${CMAKE_COMMAND} -E env ${package_env}
${CMAKE_SOURCE_DIR}/package/build_pkg.sh
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
DEPENDS xrpld validator-keys
DEPENDS xrpld
COMMENT "Building Linux package (deb/rpm inferred from host tooling)"
VERBATIM
)

View File

@@ -2,22 +2,21 @@
Protocol Autogen - Code generation for protocol wrapper classes
#]===================================================================]
# The repository root, derived from the location of this file rather than from
# the including project, so that the targets below can also be offered on their
# own by cmake/codegen/CMakeLists.txt.
get_filename_component(XRPL_ROOT "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE)
set(CODEGEN_VENV_DIR
"${XRPL_ROOT}/.venv"
"${CMAKE_CURRENT_SOURCE_DIR}/.venv"
CACHE PATH
"Path to a Python virtual environment for code generation. A venv will be created here by setup_code_gen and used to run generation scripts."
)
# Directory paths
set(MACRO_DIR "${XRPL_ROOT}/include/xrpl/protocol/detail")
set(AUTOGEN_HEADER_DIR "${XRPL_ROOT}/include/xrpl/protocol_autogen")
set(AUTOGEN_TEST_DIR "${XRPL_ROOT}/src/tests/libxrpl/protocol_autogen")
set(SCRIPTS_DIR "${XRPL_ROOT}/cmake/scripts/codegen")
set(MACRO_DIR "${CMAKE_CURRENT_SOURCE_DIR}/include/xrpl/protocol/detail")
set(AUTOGEN_HEADER_DIR
"${CMAKE_CURRENT_SOURCE_DIR}/include/xrpl/protocol_autogen"
)
set(AUTOGEN_TEST_DIR
"${CMAKE_CURRENT_SOURCE_DIR}/src/tests/libxrpl/protocol_autogen"
)
set(SCRIPTS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/cmake/scripts/codegen")
# Input macro files
set(TRANSACTIONS_MACRO "${MACRO_DIR}/transactions.macro")
@@ -115,14 +114,14 @@ if(CODEGEN_VENV_DIR)
setup_code_gen
COMMAND ${Python3_EXECUTABLE} -m venv "${CODEGEN_VENV_DIR}"
COMMAND ${CODEGEN_PYTHON} -m pip install -r "${REQUIREMENTS_FILE}"
WORKING_DIRECTORY "${XRPL_ROOT}"
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
COMMENT "Creating venv and installing code generation dependencies..."
)
else()
add_custom_target(
setup_code_gen
COMMAND ${Python3_EXECUTABLE} -m pip install -r "${REQUIREMENTS_FILE}"
WORKING_DIRECTORY "${XRPL_ROOT}"
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
COMMENT "Installing code generation dependencies..."
)
endif()
@@ -140,8 +139,8 @@ add_custom_target(
-DSFIELDS_MACRO=${SFIELDS_MACRO}
-DAUTOGEN_HEADER_DIR=${AUTOGEN_HEADER_DIR}
-DAUTOGEN_TEST_DIR=${AUTOGEN_TEST_DIR} -P
"${CMAKE_CURRENT_LIST_DIR}/XrplProtocolAutogenRun.cmake"
WORKING_DIRECTORY "${XRPL_ROOT}"
"${CMAKE_CURRENT_SOURCE_DIR}/cmake/XrplProtocolAutogenRun.cmake"
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
COMMENT "Running protocol code generation..."
SOURCES ${ALL_INPUT_FILES}
)

View File

@@ -36,19 +36,6 @@ elseif(is_gcc)
endif()
endif()
# A Nix compiler is only meant to be used from a managed environment: the xrpld
# dev shell (which exports XRPL_DEVSHELL) or the CI image. Using one from a bare
# shell usually means a leaked toolchain (picked up via PATH or a Conan profile)
# and leads to confusing breakage, so fail early with guidance.
if(is_nix_compiler AND NOT is_ci_image AND NOT DEFINED ENV{XRPL_DEVSHELL})
message(
FATAL_ERROR
"A Nix compiler (${CMAKE_CXX_COMPILER}) is being used outside the xrpld "
"dev shell. Enter it with `nix develop` (see docs/build/nix.md) before "
"configuring the build."
)
endif()
# check for in-source build and fail
if("${CMAKE_CURRENT_SOURCE_DIR}" STREQUAL "${CMAKE_BINARY_DIR}")
message(

View File

@@ -5,39 +5,22 @@ option(
)
if(validator_keys)
# Own the install destination below rather than relying on another module
# having pulled this in first.
include(GNUInstallDirs)
# Pinned to an exact commit, not a branch: the tool ships inside our
# packages, so the same xrpld version must always package the same
# validator-keys. Bump this deliberately.
set(validator_keys_commit "4c0fb75eec9601c711645998c904507e87e910ae")
message(STATUS "Using ValidatorKeys commit: ${validator_keys_commit}")
git_branch(current_branch)
# default to tracking VK master branch unless we are on release
if(NOT (current_branch STREQUAL "release"))
set(current_branch "master")
endif()
message(STATUS "Tracking ValidatorKeys branch: ${current_branch}")
FetchContent_Declare(
validator_keys
GIT_REPOSITORY https://github.com/ripple/validator-keys-tool.git
GIT_TAG "${validator_keys_commit}"
GIT_TAG "${current_branch}"
)
FetchContent_MakeAvailable(validator_keys)
# The tool's own CMakeLists excludes the target from 'all' when it is built
# as a subproject. Undo that, so validator_keys=ON really does build it.
set_target_properties(
validator-keys
PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}"
EXCLUDE_FROM_ALL OFF
EXCLUDE_FROM_DEFAULT_BUILD OFF
)
# We ship this binary, so like xrpld it must not keep the Nix store's ELF
# loader, or it cannot run on the target distro at all.
patch_nix_binary(validator-keys)
configure_file(
"${validator_keys_SOURCE_DIR}/LICENSE"
"${CMAKE_BINARY_DIR}/validator-keys-LICENSE"
COPYONLY
PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}"
)
install(TARGETS validator-keys RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
endif()

View File

@@ -1,21 +0,0 @@
#[===================================================================[
Protocol Autogen - Standalone project
Exposes the 'setup_code_gen' and 'code_gen' targets on their own, without
configuring the rest of xrpl. Code generation is pure Python, so this needs
neither the dependencies nor a compiler, which makes it usable in CI and by
contributors who only want to regenerate the protocol wrapper classes:
cmake -S cmake/codegen -B build/codegen
cmake --build build/codegen --target setup_code_gen
cmake --build build/codegen --target code_gen
The targets are identical to the ones offered by the top-level build, since
both come from cmake/XrplProtocolAutogen.cmake.
#]===================================================================]
cmake_minimum_required(VERSION 3.16)
project(xrpl_codegen LANGUAGES NONE)
include("${CMAKE_CURRENT_LIST_DIR}/../XrplProtocolAutogen.cmake")

View File

@@ -12,7 +12,7 @@
"protobuf/6.33.5#ff253ead763bd8d9904a52979cd21e81%1782392410.233933",
"openssl/3.6.3#f806de8933e3bf6f01016c6a888cee2e%1783945160.863288",
"nudb/2.0.9#11149c73f8f2baff9a0198fe25971fc7%1782392402.297166",
"mpt-crypto/1.0.2#b313cef0c1a493eb970ad185b2e9bab7%1784285108.866483",
"mpt-crypto/0.4.0-rc4#ffdba12f2332357f0d8b0ae944cfff52%1784138702.932355",
"lz4/1.10.0#982d9b673900f665a1da109e09c17cab%1782392402.164188",
"libiconv/1.17#9923bc6dc6f106646d6967e0039a5ada%1782392792.775744",
"libbacktrace/cci.20210118#a7691bfccd8caaf66309df196790a5a1%1782392402.420732",
@@ -20,10 +20,8 @@
"jemalloc/5.3.1#1fc58d55316041f10fbc1e8a2eae632a%1776700028.228",
"gtest/1.17.0#5224b3b3ff3b4ce1133cbdd27d53ee7d%1782392402.791979",
"grpc/1.81.1#f729f6d75992d20f9c72828e9142d62f%1783945160.094135",
"fast_float/8.2.10#f6f28d6bb22112078e7dbda611caf681%1782494504.298",
"ed25519/2015.03#ae761bdc52730a843f0809bdf6c1b1f6%1782307148.15562",
"date/3.0.4#862e11e80030356b53c2c38599ceb32b%1782392402.538492",
"corrosion/0.6.1#bfa292df0a957bc70a450ff316cd9435%1786119416.131296",
"c-ares/1.34.6#545240bb1c40e2cacd4362d6b8967650%1782392402.681654",
"bzip2/1.0.8#c470882369c2d95c5c77e970c0c7e321%1782392402.296732",
"boost/1.91.0#ea540ca2133d831b560036aa24dece3c%1782392419.475605",
@@ -36,7 +34,7 @@
"protobuf/6.33.5#ff253ead763bd8d9904a52979cd21e81%1782392410.233933",
"nasm/2.16.01#31e26f2ee3c4346ecd347911bd126904%1782395690.33162",
"msys2/cci.latest#d22fe7b2808f5fd34d0a7923ace9c54f%1770657326.649",
"m4/1.4.19#1727f439cf74e83826ec96d0b4904eee%1784541921.659",
"m4/1.4.19#34c4bbc3eeebe98ca6edf2f52d602e7d%1777282960.259",
"cmake/4.3.3#840cf00ea09777e05c2050a50a82c722%1782392418.696091",
"b2/5.4.2#ffd6084a119587e70f11cd45d1a386e2%1782392402.624226",
"automake/1.16.5#b91b7c384c3deaa9d535be02da14d04f%1755524470.56",

View File

@@ -1,21 +0,0 @@
#!/usr/bin/env bash
# Install our Conan configuration, profiles and the xrplf remote into CONAN_HOME.
# Safe to re-run; never deletes the Conan home.
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
CONAN_DIR="$(conan config home)"
echo "Installing Conan configuration into ${CONAN_DIR}"
conan config install "${SCRIPT_DIR}/global.conf"
conan config install "${SCRIPT_DIR}/profiles" -tf "${CONAN_DIR}/profiles"
# This script manages these files, so make them read-only - Conan does not
# preserve the source mode. Only the files: the directories must stay writable
# for `conan config install` to replace them.
chmod a-w "${CONAN_DIR}/global.conf"
find "${CONAN_DIR}/profiles" -type f -exec chmod a-w {} +
echo "Adding the xrplf Conan remote"
# --index 0: our patched recipes must win over Conan Center.
conan remote add --index 0 --force xrplf https://conan.xrplf.org/repository/conan/

View File

@@ -1,13 +1,9 @@
{% set os = detect_api.detect_os() %}
{% set arch = detect_api.detect_arch() %}
{% set compiler, version, compiler_exe = detect_api.detect_default_compiler() %}
{% set compiler_version = version %}
{% if os == "Linux" %}
{% set compiler_version = detect_api.default_compiler_version(compiler, version) %}
{% if os == "Macos" %}
{# Minimum macOS the dependencies target. #}
{# Without this, Conan builds each dependency against the (possibly newer) host SDK, so the #}
{# dependency objects target a newer macOS than the binary and the linker warns. #}
{# Keep at or below CMAKE_OSX_DEPLOYMENT_TARGET in CMakeLists.txt. #}
{% set min_macos_version = "15.0" %}
{% endif %}
[settings]
@@ -22,9 +18,6 @@ compiler.runtime=static
{% else %}
compiler.libcxx={{ detect_api.detect_libcxx(compiler, version, compiler_exe) }}
{% endif %}
{% if os == "Macos" %}
os.version={{ min_macos_version }}
{% endif %}
[conf]
{# The Boost recipe builds with b2, which doesn't use Conan's toolchain files. #}
@@ -48,13 +41,3 @@ tools.build:compiler_executables={'c':'{{ cc_exe }}','cpp':'{{ cxx_exe }}'}
{# More info: https://docs.conan.io/2/reference/extensions/binary_compatibility.html #}
user.package:cppstd_version=23
tools.info.package_id:confs+=["user.package:cppstd_version"]
{% if os == "Macos" %}
[buildenv]
{# os.version adds -mmacosx-version-min to compiler command lines, #}
{# but Boost.Context's b2 assembly (.S) rule ignores it, #}
{# so those objects keep the host SDK version and still warn at link time. #}
{# clang's assembler honors this env var regardless, pinning them. #}
{# Scoped to boost/* since it is the only gap. #}
boost/*:MACOSX_DEPLOYMENT_TARGET={{ min_macos_version }}
{% endif %}

View File

@@ -28,9 +28,7 @@ class Xrpl(ConanFile):
}
requires = [
"corrosion/0.6.1",
"ed25519/2015.03",
"fast_float/8.2.10",
"grpc/1.81.1",
"libarchive/3.8.7",
"nudb/2.0.9",
@@ -140,7 +138,7 @@ class Xrpl(ConanFile):
if self.options.jemalloc:
self.requires("jemalloc/5.3.1")
self.requires("lz4/1.10.0", force=True)
self.requires("mpt-crypto/1.0.2", transitive_headers=True)
self.requires("mpt-crypto/0.4.0-rc4", transitive_headers=True)
self.requires("protobuf/6.33.5", force=True)
if self.options.rocksdb:
self.requires("rocksdb/10.5.1")
@@ -152,12 +150,8 @@ class Xrpl(ConanFile):
"CMakeLists.txt",
"cfg/*",
"cmake/*",
"crates/*",
"crates/.cargo/*",
"!crates/target/*",
"external/*",
"include/*",
"rust-toolchain.toml",
"src/*",
)
@@ -217,7 +211,6 @@ class Xrpl(ConanFile):
"boost::thread",
"date::date",
"ed25519::ed25519",
"fast_float::fast_float",
"grpc::grpc++",
"libarchive::libarchive",
"lz4::lz4",

View File

@@ -1,17 +0,0 @@
# The Rust static libraries are linked into C++ targets, so the runtime linkage
# here has to match what the C++ build uses (see cmake/XrplCompiler.cmake).
#
# macOS needs nothing: AppleClang cannot link libgcc/libc++ statically, so the
# C++ build skips those flags on Apple as well.
# Both amd64 and arm64 Linux builds link libgcc statically. This only affects
# links that rustc itself drives (`cargo test` binaries and the like) — the
# `staticlib` crates consumed by CMake are archived, not linked, so rustc
# silently ignores link args for them. Keeping libgcc_s.so.1 off the xrpld link
# line is handled in crates/CMakeLists.txt instead.
[target.'cfg(target_os = "linux")']
rustflags = ["-C", "link-args=-static-libgcc"]
# Windows builds use the static MSVC runtime.
[target.'cfg(windows)']
rustflags = ["-C", "target-feature=+crt-static"]

View File

@@ -1,111 +0,0 @@
find_package(Corrosion REQUIRED)
corrosion_import_crate(MANIFEST_PATH ${CMAKE_CURRENT_SOURCE_DIR}/Cargo.toml)
# The generated C++ lands in the build tree, so put a .clang-tidy next to it to
# keep clang-tidy from analyzing code we don't own.
configure_file(
generated.clang-tidy
"${CMAKE_CURRENT_BINARY_DIR}/.clang-tidy"
COPYONLY
)
add_custom_target(xrpl_crates)
add_dependencies(tidy_prerequisites xrpl_crates)
# On macOS, ld warns `ignoring duplicate libraries` when linking a crate.
# Corrosion is the source of both duplicates it names:
#
# * The crate archive and its cxxbridge archive, because
# `corrosion_add_cxxbridge` makes the two depend on each other, and CMake
# repeats a static library cycle on the link line so single-pass linkers can
# resolve it. (LINK_INTERFACE_MULTIPLICITY can only raise that count.)
# * `-lSystem`, which Corrosion copies from rustc's `native-static-libs` even
# though the compiler driver always links libSystem.
#
# ld needs neither: it resolves the cycle from one copy of each archive and
# links libSystem once. So silence the warning rather than rewrite Corrosion's
# link interface, which the cycle is also part of. The option itself is old —
# Xcode 15 is only where the warning became the default — and the check below
# leaves it out on a linker that does not know it.
if(is_macos)
include(CheckLinkerFlag)
check_linker_flag(
CXX
-Wl,-no_warn_duplicate_libraries
have_no_warn_duplicate_libraries
)
endif()
function(_unlink_libgcc_s crate)
if(NOT (is_linux AND static))
return()
endif()
# Corrosion exposes a crate's staticlib as an imported `<crate>-static`
# target and puts the native libs in its INTERFACE_LINK_LIBRARIES. If either
# of those changes, warn instead of silently letting libgcc_s.so.1 return.
set(imported "${crate}-static")
if(NOT TARGET ${imported})
message(
FATAL_ERROR
"Corrosion did not create the imported target '${imported}', so "
"libgcc_s cannot be removed from the link interface of '${crate}'. "
"xrpld will link libgcc_s.so.1 dynamically. Check where Corrosion "
"${CORROSION_VERSION} now records `native-static-libs`."
)
return()
endif()
get_target_property(libs ${imported} INTERFACE_LINK_LIBRARIES)
if(NOT "gcc_s" IN_LIST libs)
message(
WARNING
"'gcc_s' was not in the link interface of '${imported}' as "
"expected. If the Rust toolchain stopped reporting it this "
"workaround is obsolete and can be deleted; otherwise xrpld may "
"link libgcc_s.so.1 dynamically. Verify with: "
"objdump -p xrpld | grep NEEDED"
)
return()
endif()
list(REMOVE_ITEM libs gcc_s)
set_property(TARGET ${imported} PROPERTY INTERFACE_LINK_LIBRARIES ${libs})
endfunction()
function(add_xrpl_crate name)
cmake_parse_arguments(ARG "" "CRATE" "FILES" ${ARGN})
_unlink_libgcc_s(${ARG_CRATE})
# `cc` picks its runtime flag from `crt-static` alone, so it compiles a
# crate's C++ with `-MT`; Debug needs `-MTd` (to match cmake/XrplCompiler.cmake).
if(is_msvc)
corrosion_set_env_vars(
${ARG_CRATE}
"$<$<CONFIG:Debug>:CXXFLAGS=-MTd>"
)
endif()
corrosion_add_cxxbridge(${name}_cxxbridge CRATE ${ARG_CRATE} FILES
${ARG_FILES}
)
# Generated cxxbridge headers don't exist at configure time; CMake 3.28+
# validates INTERFACE_SOURCES on consuming targets. Clear it to skip the
# existence check — build-time ordering is enforced by the custom commands.
set_target_properties(${name}_cxxbridge PROPERTIES INTERFACE_SOURCES "")
if(have_no_warn_duplicate_libraries)
target_link_options(
${name}_cxxbridge
INTERFACE -Wl,-no_warn_duplicate_libraries
)
endif()
add_dependencies(xrpl_crates ${name}_cxxbridge)
endfunction()
add_xrpl_crate(xrpl_wasm_vm_ffi CRATE xrpl_wasm_vm_ffi FILES lib.rs)
add_xrpl_crate(xrpl_wasm_testkit CRATE xrpl_wasm_testkit FILES lib.rs)
target_include_directories(
xrpl_wasm_vm_ffi_cxxbridge
PRIVATE ${CMAKE_SOURCE_DIR}/include
)

497
crates/Cargo.lock generated
View File

@@ -1,497 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cc"
version = "1.2.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "clap"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "codespan-reporting"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
dependencies = [
"serde",
"termcolor",
"unicode-width",
]
[[package]]
name = "cxx"
version = "1.0.199"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824894a4a85dca76d4c95c2b9098c036f5a29f627b30c12780774f6654e60974"
dependencies = [
"cc",
"cxx-build",
"cxxbridge-cmd",
"cxxbridge-flags",
"cxxbridge-macro",
"foldhash 0.2.0",
"link-cplusplus",
]
[[package]]
name = "cxx-build"
version = "1.0.199"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1ae0b651ea5b0000b19513aef5a03f194d7e3486f2d9258b658da8677fe9036"
dependencies = [
"cc",
"codespan-reporting",
"indexmap",
"proc-macro2",
"quote",
"scratch",
"syn 3.0.3",
]
[[package]]
name = "cxxbridge-cmd"
version = "1.0.199"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb05f91d3fb8435d9bab6ac5ce6ac1868be774325fb7fb2a91be39393b21388e"
dependencies = [
"clap",
"codespan-reporting",
"indexmap",
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "cxxbridge-flags"
version = "1.0.199"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf293202e0e3e98495785745389e8d0755b217e66f19194a5c695c25e03282ef"
[[package]]
name = "cxxbridge-macro"
version = "1.0.199"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca001d746947c7249ed9d332a10f7a59daedbafeb0ec68c5c18a7db7a93f6ccc"
dependencies = [
"indexmap",
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.0",
]
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "link-cplusplus"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82"
dependencies = [
"cc",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "scratch"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
[[package]]
name = "string-interner"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0"
dependencies = [
"hashbrown 0.15.5",
"serde",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "termcolor"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
dependencies = [
"winapi-util",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "wasm-encoder"
version = "0.254.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09480d646178e5fdd12bb06e812d0af9a3a191dbc9cd697fdc86687beade7393"
dependencies = [
"leb128fmt",
"wasmparser 0.254.0",
]
[[package]]
name = "wasmi"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2300d0f78cba12f14e29e8dd157ea64050c0a688179aefdb2050105805594a0c"
dependencies = [
"spin",
"wasmi_collections",
"wasmi_core",
"wasmi_ir",
"wasmparser 0.239.0",
]
[[package]]
name = "wasmi_collections"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8a8c42a2a76148d43097b1d7cc2a5bf33d5c23bd4dd69015fc887e311767884"
dependencies = [
"string-interner",
]
[[package]]
name = "wasmi_core"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9013136083d988725953390bf668b64b7a218fabf26f8b913bbc59546b97ee27"
dependencies = [
"libm",
]
[[package]]
name = "wasmi_ir"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba1fa003f79156f406d62ef0e1464dc03e11ace37170e9fa7524299a75ad8f68"
dependencies = [
"wasmi_core",
]
[[package]]
name = "wasmparser"
version = "0.239.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0"
dependencies = [
"bitflags",
"indexmap",
]
[[package]]
name = "wasmparser"
version = "0.254.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5769a29f799fbab136aaf65b4fe5384cd7d93fe6fc9ba0dcb6c8382a1f16e27"
dependencies = [
"bitflags",
"indexmap",
"semver",
]
[[package]]
name = "wast"
version = "254.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7ed4dfc8f6b9fc38b231065e2cdfbf7359af5ab945990abf09658dcc63c3e32"
dependencies = [
"bumpalo",
"leb128fmt",
"memchr",
"unicode-width",
"wasm-encoder",
]
[[package]]
name = "wat"
version = "1.254.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7127f7f9b8f127c879991cecd35f494e4628bae1b0874c681414d8d8831e952c"
dependencies = [
"wast",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "xrpl-host-functions"
version = "0.1.0"
dependencies = [
"xrpl-host-functions-macros",
]
[[package]]
name = "xrpl-host-functions-macros"
version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"xrpl-host-functions",
]
[[package]]
name = "xrpl-wasm-testkit"
version = "0.1.0"
dependencies = [
"cxx",
"wat",
]
[[package]]
name = "xrpl-wasm-vm"
version = "0.1.0"
dependencies = [
"wasmi",
"wat",
"xrpl-host-functions",
]
[[package]]
name = "xrpl-wasm-vm-ffi"
version = "0.1.0"
dependencies = [
"cxx",
"xrpl-host-functions",
"xrpl-wasm-vm",
]

View File

@@ -1,21 +0,0 @@
[workspace]
members = [
"xrpl-wasm-vm-ffi",
"xrpl-wasm-vm",
"xrpl-wasm-testkit",
"xrpl-host-functions",
"xrpl-host-functions-macros",
]
resolver = "3"
[workspace.dependencies]
cxx = { version = "1.0.199", features = ["c++20"] }
[workspace.package]
edition = "2024"
[profile.release]
opt-level = 3
overflow-checks = true
lto = true
debug = true

View File

@@ -1,10 +0,0 @@
---
# Neutralizes clang-tidy for the corrosion/cxxbridge-generated C++. Copied into
# the crates build directory by crates/CMakeLists.txt, next to the generated
# sources, so clang-tidy picks it up instead of the top-level configuration.
#
# One check is kept enabled to avoid clang-tidy's "no checks enabled" error.
Checks: "-*,google-readability-todo"
WarningsAsErrors: ""
HeaderFilterRegex: ""
InheritParentConfig: false

View File

@@ -1,18 +0,0 @@
[package]
name = "xrpl-host-functions-macros"
version = "0.1.0"
edition.workspace = true
[lib]
proc-macro = true
[dependencies]
syn = { version = "3", features = ["full"] }
quote = "1"
proc-macro2 = "1"
# The doctest declares host functions returning `HostResult`, which the facade
# crate hand-writes. Cargo allows this cycle because dev-dependencies are outside
# the library build graph.
[dev-dependencies]
xrpl-host-functions.path = "../xrpl-host-functions"

View File

@@ -1,12 +0,0 @@
/// Folds accumulated diagnostics into the single error a macro can return.
///
/// `syn::Error` is itself a collection: `combine` appends, and
/// `into_compile_error` emits one `compile_error!` per recorded span. Folding
/// instead of returning the first error means every mistake in a
/// `host_functions!` block surfaces in one build rather than one per rebuild.
pub(crate) fn combine(errors: Vec<syn::Error>) -> Option<syn::Error> {
errors.into_iter().reduce(|mut first, next| {
first.combine(next);
first
})
}

View File

@@ -1,405 +0,0 @@
mod errors;
mod parsed_host_function;
use std::collections::HashSet;
use proc_macro2::TokenStream;
use quote::quote;
use syn::{
TraitItemFn,
parse::{Parse, ParseStream},
parse2,
};
use parsed_host_function::ParsedHostFunction;
/// Declares the wasm host ABI once, and generates everything that follows from it.
///
/// The input is a block of `fn` declarations, each carrying the gas cost the host
/// charges before the call and the name the guest imports it under. Doc comments
/// are kept and appear on the generated items.
///
/// This crate is an implementation detail of `xrpl-host-functions`, which
/// hand-writes the types the declarations refer to and holds the one declaration
/// block.
///
/// # What it generates
///
/// Three items, in the scope the block is written in:
///
/// - `pub trait HostFunctions`: one method per declaration, emitted verbatim —
/// receiver, parameters, return type and doc comment exactly as written. An
/// execution environment implements it; the rest of the expansion does not
/// mention it.
/// - `pub enum HostFunctionSpec`: one variant per declaration, named by
/// PascalCasing the function name (`get_ledger_sqn` becomes `GetLedgerSqn`) and
/// carrying that declaration's doc comment. Its `const fn wasm_name` and
/// `const fn gas` are the ABI metadata, and `ALL` is every variant in
/// declaration order — what a wasm engine iterates to build its import table.
/// - `struct HostFnSpec`: private, one row of that metadata table. It exists only
/// so `wasm_name` and `gas` read from a single `match` over the declarations,
/// and never appears in a signature a caller can name.
///
/// The expansion introduces no other name and reaches for none: the only paths in
/// it are `Self::Variant` and whatever the declarations themselves spell. So the
/// block compiles wherever the types it names — `HostResult` above — resolve.
///
/// ```
/// use xrpl_host_functions::HostResult;
/// use xrpl_host_functions_macros::host_functions;
///
/// host_functions! {
/// /// The sequence number of the ledger being built, as 4 little-endian bytes.
/// #[gas = 60]
/// #[wasm_name = "ldgr_index"]
/// fn get_ledger_sqn(&self, out: &mut [u8]) -> HostResult<usize>;
///
/// /// Writes `msg` to the trace log.
/// #[gas = 500]
/// #[wasm_name = "trace_num"]
/// fn trace_num(&self, msg: &str, number: i64) -> HostResult<()>;
/// }
///
/// // The trait's methods are the declarations, down to the `&self` receiver the
/// // VM calls the host through.
/// fn ledger_sqn(host: &dyn HostFunctions, out: &mut [u8]) -> HostResult<usize> {
/// host.get_ledger_sqn(out)
/// }
///
/// // The metadata is a `const` table, so gas and import names are available at
/// // compile time rather than looked up at run time.
/// const TRACE_GAS: u64 = HostFunctionSpec::TraceNum.gas();
/// assert_eq!(TRACE_GAS, 500);
///
/// assert_eq!(HostFunctionSpec::GetLedgerSqn.wasm_name(), "ldgr_index");
/// assert_eq!(
/// HostFunctionSpec::ALL,
/// &[HostFunctionSpec::GetLedgerSqn, HostFunctionSpec::TraceNum],
/// );
/// ```
///
/// A declaration must be a plain `fn` taking `&self` and returning
/// `HostResult<T>`, with no body and no generics: it maps to exactly one wasm
/// import signature. Two declarations may not share a `wasm_name`, nor collapse to
/// the same PascalCase variant.
#[proc_macro]
pub fn host_functions(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
expand(input.into())
.unwrap_or_else(syn::Error::into_compile_error)
.into()
}
fn expand(input: TokenStream) -> syn::Result<TokenStream> {
let HostFunctionsInput { functions } = parse2(input)?;
let mut parsed = Vec::with_capacity(functions.len());
let mut errors = Vec::new();
for function in functions {
match ParsedHostFunction::parse(function) {
Ok(function) => parsed.push(function),
Err(error) => errors.push(error),
}
}
if let Some(error) = errors::combine(errors) {
return Err(error);
}
if let Some(error) = errors::combine(collisions(&parsed)) {
return Err(error);
}
Ok(generate(&parsed))
}
/// Names two declarations may not share, because the generated code would then
/// fail to compile at a span the caller cannot see.
fn collisions(functions: &[ParsedHostFunction]) -> Vec<syn::Error> {
let mut errors = Vec::new();
let mut variants = HashSet::new();
let mut wasm_names = HashSet::new();
for function in functions {
if !variants.insert(function.variant.to_string()) {
errors.push(syn::Error::new_spanned(
&function.variant,
format!(
"another host function already becomes the `{}` variant",
function.variant
),
));
}
if !wasm_names.insert(function.wasm_name.value()) {
errors.push(syn::Error::new_spanned(
&function.wasm_name,
format!(
"another host function is already imported as `{}`",
function.wasm_name.value()
),
));
}
}
errors
}
fn generate(functions: &[ParsedHostFunction]) -> TokenStream {
let trait_methods = functions.iter().map(ParsedHostFunction::trait_method);
let variants = functions
.iter()
.map(ParsedHostFunction::variant_declaration);
let spec_arms = functions.iter().map(ParsedHostFunction::spec_arm);
let all = functions.iter().map(|function| &function.variant);
quote! {
/// The host side of the wasm ABI: one method per function a guest may
/// import.
///
/// Implement it once per execution environment — the ledger host, a test
/// double, a benchmark fake — and a guest module cannot tell them apart.
/// Each method is one declaration from the `host_functions!` block, as
/// written; its `&self` receiver is not part of the ABI the guest sees,
/// so a host that must mutate does so behind interior mutability.
///
/// # The output contract
///
/// A method handed an `out` buffer **writes into it only when the whole
/// value fits, and returns the value's true length whether it fitted or
/// not.**
///
/// The length is the value's, not the number of bytes written, because it
/// is how a guest that asked with too small a buffer learns the size to
/// ask for next time. The engine turns a length past the buffer into
/// `BufferTooSmall`, and one past the field cap into `DataFieldTooLarge`,
/// so a host needs to know neither.
///
/// Writing nothing unless the value fits is the half only a host can hold
/// up. An engine can bound how many bytes are *writable* — and does, by
/// handing over a region clamped to the field cap — but it cannot take
/// back what a method already put there. A host that wrote a truncated
/// prefix and then reported the larger length would leave those bytes in
/// guest memory behind a refusal the guest is told to ignore.
pub trait HostFunctions {
#(#trait_methods)*
}
/// One row of the ABI table: what [`HostFunctionSpec::wasm_name`] and
/// [`HostFunctionSpec::gas`] read from.
///
/// Private, and the only reason it exists is to keep both of them fed
/// from a single `match` over the declarations.
struct HostFnSpec {
name: &'static str,
gas: u64,
}
/// Identifies one host function, and is the compile-time source of its
/// ABI metadata.
///
/// One variant per `host_functions!` declaration, named by converting the
/// function name to PascalCase. [`Self::ALL`] is the whole ABI, which is
/// what a wasm engine iterates to build its import table.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HostFunctionSpec {
#(#variants,)*
}
impl HostFunctionSpec {
/// Every host function, in the order declared.
///
/// This is the complete import surface a guest may link against: a
/// function absent here cannot be called, and one present here must
/// be registered for a module that imports it to instantiate.
pub const ALL: &'static [Self] = &[#(Self::#all,)*];
/// This function's row of the ABI table.
const fn spec(self) -> HostFnSpec {
match self {
#(#spec_arms,)*
}
}
/// The name a guest imports this function under.
///
/// A guest's import name must match this exactly, or the module
/// fails to instantiate. Usable in `const` context, so import lists
/// can be built at compile time.
pub const fn wasm_name(self) -> &'static str {
self.spec().name
}
/// Gas charged before the call runs, independent of its arguments.
///
/// Consensus-relevant: two nodes that disagree on this value
/// disagree on transaction outcomes. Usable in `const` context, so
/// gas tables can be built at compile time.
pub const fn gas(self) -> u64 {
self.spec().gas
}
}
}
}
struct HostFunctionsInput {
functions: Vec<TraitItemFn>,
}
impl Parse for HostFunctionsInput {
fn parse(input: ParseStream) -> syn::Result<Self> {
let mut functions = Vec::new();
while !input.is_empty() {
functions.push(input.parse()?);
}
Ok(HostFunctionsInput { functions })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_an_empty_block() {
expand(quote! {}).unwrap();
}
#[test]
fn reports_mistakes_from_every_function() {
let error = expand(quote! {
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
#[gas = 2000]
fn sha512_half(&self, data: &[u8]) -> HostResult<[u8; 32]>;
})
.expect_err("expected parsing to fail");
let messages: Vec<_> = error.into_iter().map(|error| error.to_string()).collect();
assert_eq!(messages.len(), 2, "{messages:?}");
assert!(messages[0].contains("missing `#[gas"), "{messages:?}");
assert!(messages[1].contains("missing `#[wasm_name"), "{messages:?}");
}
#[test]
fn propagates_syntax_errors() {
let error = expand(quote! { fn missing_semicolon() }).expect_err("expected a syntax error");
assert!(!error.to_string().is_empty());
}
/// The messages of every diagnostic recorded by one failed `expand`.
fn messages(input: TokenStream) -> Vec<String> {
let Err(error) = expand(input) else {
panic!("expected expansion to fail");
};
error.into_iter().map(|error| error.to_string()).collect()
}
#[test]
fn generates_the_trait_the_enum_and_the_table() {
let generated = expand(quote! {
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
#[gas = 500]
#[wasm_name = "trace_num"]
fn trace_num(&self, msg: &str, number: i64) -> HostResult<()>;
})
.unwrap()
.to_string();
for expected in [
"pub trait HostFunctions",
"fn get_ledger_sqn (& self) -> HostResult < [u8 ; 4] > ;",
"fn trace_num (& self , msg : & str , number : i64) -> HostResult < () > ;",
"pub enum HostFunctionSpec { GetLedgerSqn , TraceNum , }",
"pub const ALL : & 'static [Self] = & [Self :: GetLedgerSqn , Self :: TraceNum ,]",
// The table's row type is generated too, and stays private.
"struct HostFnSpec { name : & 'static str , gas : u64 , }",
"const fn spec (self) -> HostFnSpec",
"Self :: GetLedgerSqn => HostFnSpec { name : \"ldgr_index\" , gas : 60u64 }",
"pub const fn wasm_name (self) -> & 'static str",
"pub const fn gas (self) -> u64",
] {
assert!(generated.contains(expected), "missing {expected:?}");
}
}
/// The expansion stands alone: every name in it is either generated here or
/// written in the declarations, so it cannot depend on the crate it lands in.
#[test]
fn names_no_crate_of_its_own() {
let generated = expand(quote! {
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
})
.unwrap()
.to_string();
assert!(!generated.contains("xrpl_host_functions"), "{generated}");
// `Self::Variant` is the only path the expansion may build: anything else
// would reach out of the generated code. Doc comments spell paths without
// spaces (`Self::ALL`), so they do not match.
for (index, _) in generated.match_indices(" :: ") {
assert!(
generated[..index].ends_with("Self"),
"path out of the expansion at {index}: {generated}"
);
}
}
/// `spec` is an implementation detail of the two accessors, so it must not
/// become part of the ABI crate's public surface.
#[test]
fn keeps_the_table_row_private() {
let generated = expand(quote! {
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
})
.unwrap()
.to_string();
assert!(!generated.contains("pub struct HostFnSpec"), "{generated}");
assert!(!generated.contains("pub const fn spec"), "{generated}");
}
#[test]
fn rejects_two_functions_that_share_a_wasm_name() {
let messages = messages(quote! {
#[gas = 60]
#[wasm_name = "trace"]
fn trace(&self, msg: &str) -> HostResult<()>;
#[gas = 70]
#[wasm_name = "trace"]
fn trace_num(&self, msg: &str, number: i64) -> HostResult<()>;
});
assert_eq!(messages.len(), 1, "{messages:?}");
assert!(
messages[0].contains("already imported as `trace`"),
"{messages:?}"
);
}
/// Names that differ only in underscores collapse to one enum variant.
#[test]
fn rejects_two_functions_that_share_a_variant() {
let messages = messages(quote! {
#[gas = 60]
#[wasm_name = "a"]
fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
#[gas = 70]
#[wasm_name = "b"]
fn get_ledger__sqn(&self) -> HostResult<[u8; 4]>;
});
assert_eq!(messages.len(), 1, "{messages:?}");
assert!(
messages[0].contains("`GetLedgerSqn` variant"),
"{messages:?}"
);
}
}

View File

@@ -1,859 +0,0 @@
use proc_macro2::TokenStream;
use quote::{ToTokens, format_ident, quote};
use syn::{
Attribute, Ident, LitInt, LitStr, PathArguments, ReceiverKind, ReturnType, Safety, Signature,
TraitItemFn, Type, TypePath, parse::Parse,
};
use crate::errors;
/// `#[gas = N]`: the base gas charged before the call runs.
const GAS: &str = "gas";
/// `#[wasm_name = "..."]`: the name the guest imports the function under.
const WASM_NAME: &str = "wasm_name";
/// `///` desugars to `#[doc = "..."]` before macro expansion.
const DOC: &str = "doc";
/// The alias every declaration returns its success type through.
const HOST_RESULT: &str = "HostResult";
/// One entry of a `host_functions!` block: its ABI metadata and its signature.
pub(crate) struct ParsedHostFunction {
pub(crate) gas: u64,
/// Kept as the literal the user wrote, so diagnostics and the generated
/// string both carry that span.
pub(crate) wasm_name: LitStr,
/// Doc comments, in source order, to re-emit on the generated items.
pub(crate) docs: Vec<Attribute>,
/// The enum variant this declaration becomes, spanned at the function name.
pub(crate) variant: Ident,
pub(crate) signature: Signature,
}
impl ParsedHostFunction {
/// `#[doc …] fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;`
pub(crate) fn trait_method(&self) -> TokenStream {
let docs = &self.docs;
// The declaration is already a trait method: emitted verbatim, so what
// the block reads like is what the trait is.
let signature = &self.signature;
quote! {
#(#docs)*
#signature;
}
}
/// `#[doc …] GetLedgerSqn`
pub(crate) fn variant_declaration(&self) -> TokenStream {
let docs = &self.docs;
let variant = &self.variant;
quote! {
#(#docs)*
#variant
}
}
/// `Self::GetLedgerSqn => HostFnSpec { name: "ldgr_index", gas: 60u64 }`
pub(crate) fn spec_arm(&self) -> TokenStream {
let Self {
gas,
wasm_name,
variant,
..
} = self;
quote! {
Self::#variant => HostFnSpec { name: #wasm_name, gas: #gas }
}
}
pub(crate) fn parse(function: TraitItemFn) -> syn::Result<Self> {
let mut gas = None;
let mut wasm_name = None;
let mut docs = Vec::new();
let mut errors = Vec::new();
// Tracked separately from `gas`/`wasm_name` so a malformed attribute is
// not also reported as a missing one.
let mut saw_gas = false;
let mut saw_wasm_name = false;
for attr in function.attrs {
if attr.path().is_ident(GAS) {
saw_gas = true;
if let Err(error) = int_value(&attr).and_then(|v| set_once(&mut gas, v, &attr)) {
errors.push(error);
}
} else if attr.path().is_ident(WASM_NAME) {
saw_wasm_name = true;
if let Err(error) = value::<LitStr>(&attr, "a string literal")
.and_then(|v| set_once(&mut wasm_name, v, &attr))
{
errors.push(error);
}
} else if attr.path().is_ident(DOC) {
docs.push(attr);
} else {
errors.push(syn::Error::new_spanned(
&attr,
format!("unexpected attribute `{}`", path_name(&attr)),
));
}
}
if !saw_gas {
errors.push(syn::Error::new_spanned(
&function.sig.ident,
format!("missing `#[{GAS} = ...]` attribute"),
));
}
if !saw_wasm_name {
errors.push(syn::Error::new_spanned(
&function.sig.ident,
format!("missing `#[{WASM_NAME} = \"...\"]` attribute"),
));
}
if let Some(body) = &function.default {
errors.push(syn::Error::new_spanned(
body,
"a host function is implemented by the host, so it must not have a body",
));
}
if !function.sig.generics.params.is_empty() || function.sig.generics.where_clause.is_some()
{
errors.push(syn::Error::new_spanned(
&function.sig.ident,
"a host function must not be generic: it maps to one wasm import signature",
));
}
errors.extend(check_receiver(&function.sig).err());
errors.extend(check_return_type(&function.sig).err());
if let Some(name) = &wasm_name {
errors.extend(check_wasm_name(name).err());
}
reject_modifiers(&function.sig, &mut errors);
// A name whose PascalCase form is not a legal variant is reported here
// rather than emitted, which would either panic or fail downstream.
let variant = match variant_ident(&function.sig.ident) {
Ok(variant) => Some(variant),
Err(error) => {
errors.push(error);
None
}
};
if let Some(error) = errors::combine(errors) {
return Err(error);
}
let (Some(gas), Some(wasm_name), Some(variant)) = (gas, wasm_name, variant) else {
unreachable!("every absent field is reported above");
};
Ok(Self {
gas,
wasm_name,
docs,
variant,
signature: function.sig,
})
}
}
/// Every declaration carries a receiver, and it is always `&self`.
///
/// `&self` is the only receiver that can work: the VM reaches the host through a
/// shared `&dyn HostFunctions` stored in the wasmi `Store`, and a host that needs
/// to mutate does so behind interior mutability. The receiver is not part of the
/// wasm ABI — the guest passes no `self` — so it is uniform across the block.
fn check_receiver(signature: &Signature) -> syn::Result<()> {
let Some(receiver) = signature.receiver() else {
return Err(syn::Error::new_spanned(
&signature.ident,
format!(
"a host function must declare its receiver: `fn {}(&self, ...)`",
signature.ident
),
));
};
// `&self` and nothing else: not `&mut self`, not `self`/`mut self`, not a
// typed `self: Box<Self>`, and not a spelled-out lifetime.
if !matches!(receiver.kind, ReceiverKind::Reference(_, None, None)) {
return Err(syn::Error::new_spanned(
receiver,
"a host function's receiver must be exactly `&self`: the VM calls the host \
through a shared `&dyn HostFunctions`",
));
}
Ok(())
}
/// Every declaration returns `HostResult<T>`, including the ones that yield
/// nothing (`HostResult<()>`).
///
/// One shape for every function is what lets a single dispatch adapter lower them
/// all: lift the arguments out of guest memory, call the host, then turn `Ok(T)`
/// into the wire's non-negative `i32` and `Err(e)` into a negative code or a trap.
/// A function returning a bare `T` would need its own arm.
fn check_return_type(signature: &Signature) -> syn::Result<()> {
const SHAPE: &str = "a host function must return `HostResult<T>` — \
`HostResult<()>` if it yields nothing";
let ReturnType::Type(_, returned) = &signature.output else {
return Err(syn::Error::new_spanned(&signature.ident, SHAPE));
};
let Type::Path(TypePath {
qself: None, path, ..
}) = &**returned
else {
return Err(syn::Error::new_spanned(returned, SHAPE));
};
// The last segment only, so `HostResult<T>` may be written qualified.
let Some(last) = path.segments.last() else {
return Err(syn::Error::new_spanned(returned, SHAPE));
};
if last.ident != HOST_RESULT {
return Err(syn::Error::new_spanned(returned, SHAPE));
}
// `HostResult` without its success type is `HostResult` the alias, which names
// no type; rustc's own message for that is unhelpfully far from the cause.
let PathArguments::AngleBracketed(arguments) = &last.arguments else {
return Err(syn::Error::new_spanned(
returned,
format!("`{HOST_RESULT}` needs its success type: `{HOST_RESULT}<T>`"),
));
};
if arguments.args.len() != 1 {
return Err(syn::Error::new_spanned(
arguments,
format!("`{HOST_RESULT}` takes exactly one type: `{HOST_RESULT}<T>`"),
));
}
Ok(())
}
/// `const`, `async`, `unsafe`/`safe` and `extern "…"` have no meaning in the
/// wasm ABI, and would otherwise pass silently into the generated trait.
fn reject_modifiers(signature: &Signature, errors: &mut Vec<syn::Error>) {
const PLAIN: &str =
"a host function must be a plain `fn`: this modifier is not part of the wasm ABI";
if let Some(constness) = &signature.constness {
errors.push(syn::Error::new_spanned(constness, PLAIN));
}
if let Some(asyncness) = &signature.asyncness {
errors.push(syn::Error::new_spanned(asyncness, PLAIN));
}
match &signature.safety {
Safety::Default => {}
Safety::Safe(token) => errors.push(syn::Error::new_spanned(token, PLAIN)),
Safety::Unsafe(token) => errors.push(syn::Error::new_spanned(token, PLAIN)),
}
if let Some(abi) = &signature.abi {
errors.push(syn::Error::new_spanned(abi, PLAIN));
}
}
/// The wasm import name reaches the engine's import table verbatim, so it is
/// held to what an import name can sanely be rather than to any string.
fn check_wasm_name(name: &LitStr) -> syn::Result<()> {
let value = name.value();
if value.is_empty() {
return Err(syn::Error::new_spanned(
name,
"the wasm name must not be empty",
));
}
if let Some(character) = value
.chars()
.find(|c| !c.is_ascii_alphanumeric() && *c != '_')
{
return Err(syn::Error::new_spanned(
name,
format!(
"a wasm name may only contain `A-Za-z0-9_`, but this one contains {character:?}"
),
));
}
Ok(())
}
/// The enum variant a declaration becomes: `get_ledger_sqn` -> `GetLedgerSqn`.
///
/// The result carries `ident`'s span, so anything the compiler says about the
/// variant points at the declaration that produced it.
fn variant_ident(ident: &Ident) -> syn::Result<Ident> {
// `to_string` spells raw identifiers `r#type`; the `r#` is not part of the name.
let name = ident.to_string();
let name = name.strip_prefix("r#").unwrap_or(&name);
let mut pascal = String::with_capacity(name.len());
let mut capitalize = true;
for character in name.chars() {
if character == '_' {
capitalize = true;
} else if capitalize {
pascal.extend(character.to_uppercase());
capitalize = false;
} else {
pascal.push(character);
}
}
// A name of nothing but underscores leaves `pascal` empty; the original is
// already a legal identifier, so keep it.
if pascal.is_empty() {
return Ok(ident.clone());
}
// `Ident::new` panics on a leading digit (`_2fa` -> `2fa`) and silently
// accepts keyword spellings (`self_` -> `Self`), which then fails to parse
// where the variant is emitted. Parsing rejects both, without panicking.
if let Err(error) = syn::parse_str::<Ident>(&pascal) {
return Err(syn::Error::new_spanned(
ident,
format!(
"this name becomes the enum variant `{pascal}`, which is not a valid \
variant name ({error}); rename the host function"
),
));
}
Ok(format_ident!("{pascal}", span = ident.span()))
}
/// Records `value`, or reports that the attribute appeared more than once.
fn set_once<T>(slot: &mut Option<T>, value: T, attr: &Attribute) -> syn::Result<()> {
if slot.replace(value).is_some() {
return Err(syn::Error::new_spanned(
attr,
format!("duplicate `{}` attribute", path_name(attr)),
));
}
Ok(())
}
/// The value of `#[name = <value>]`, parsed as `T`.
///
/// `expected` completes "`gas` expects …": syn's own message for the wrong kind
/// of literal names neither the attribute nor what it wanted.
fn value<T: Parse>(attr: &Attribute, expected: &str) -> syn::Result<T> {
let expr = &attr.meta.require_name_value()?.value;
syn::parse2(expr.to_token_stream()).map_err(|_| {
syn::Error::new_spanned(expr, format!("`{}` expects {expected}", path_name(attr)))
})
}
fn int_value(attr: &Attribute) -> syn::Result<u64> {
let int: LitInt = value(attr, "an integer literal")?;
// `LitInt` keeps the sign in its digits, so `base10_parse::<u64>` would
// report a negative value as "invalid digit found in string".
if int.base10_digits().starts_with('-') {
return Err(syn::Error::new_spanned(
int,
format!("`{}` must not be negative", path_name(attr)),
));
}
int.base10_parse()
}
/// The attribute's path as written, for diagnostics: `gas`, or `foo::bar`.
fn path_name(attr: &Attribute) -> String {
attr.path()
.segments
.iter()
.map(|segment| segment.ident.to_string())
.collect::<Vec<_>>()
.join("::")
}
#[cfg(test)]
mod tests {
use super::*;
use syn::{Expr, ExprLit, Lit, parse_quote};
/// The message of every diagnostic recorded by one failed `parse`.
///
/// `expect_err` is unavailable here: it needs `T: Debug`, and syn only
/// implements `Debug` for its AST types under the `extra-traits` feature.
fn messages(function: TraitItemFn) -> Vec<String> {
let Err(error) = ParsedHostFunction::parse(function) else {
panic!("expected parsing to fail");
};
error.into_iter().map(|error| error.to_string()).collect()
}
fn doc_text(attr: &Attribute) -> String {
match &attr.meta.require_name_value().unwrap().value {
Expr::Lit(ExprLit {
lit: Lit::Str(text),
..
}) => text.value(),
_ => panic!("doc attribute is not a string literal"),
}
}
#[test]
fn reads_gas_and_wasm_name() {
let parsed = ParsedHostFunction::parse(parse_quote! {
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
})
.unwrap();
assert_eq!(parsed.gas, 60);
assert_eq!(parsed.wasm_name.value(), "ldgr_index");
assert_eq!(parsed.signature.ident.to_string(), "get_ledger_sqn");
assert_eq!(parsed.variant.to_string(), "GetLedgerSqn");
assert!(parsed.docs.is_empty());
}
#[test]
fn derives_variant_names_from_function_names() {
for (function, variant) in [
("get_ledger_sqn", "GetLedgerSqn"),
("sha512_half", "Sha512Half"),
("trace", "Trace"),
("get_current_ledger_obj_field", "GetCurrentLedgerObjField"),
("r#type", "Type"),
("trace2", "Trace2"),
// Pathological, but must not panic: no letters to capitalize.
("__", "__"),
] {
let ident = format_ident!("{function}");
assert_eq!(
variant_ident(&ident).map(|v| v.to_string()).ok(),
Some(variant.to_owned()),
"{function}"
);
}
}
/// `_2fa` would PascalCase to `2fa`; building that `Ident` panics, and a
/// panic in a proc macro is reported with no useful span at all.
#[test]
fn rejects_a_name_that_becomes_a_leading_digit() {
let messages = messages(parse_quote! {
#[gas = 60]
#[wasm_name = "two_factor"]
fn _2fa(&self) -> HostResult<()>;
});
assert_eq!(messages.len(), 1, "{messages:?}");
assert!(
messages[0].contains("becomes the enum variant `2fa`"),
"{messages:?}"
);
}
/// `self_` PascalCases to `Self`, which `Ident::new` accepts and rustc then
/// rejects where the variant is emitted. `r#Self` is not a legal escape.
#[test]
fn rejects_a_name_that_becomes_a_keyword() {
for function in ["self_", "_self"] {
let ident = format_ident!("{function}");
let Err(error) = variant_ident(&ident) else {
panic!("expected `{function}` to be rejected");
};
assert!(
error.to_string().contains("variant `Self`"),
"{}",
error.to_string()
);
}
}
#[test]
fn rejects_negative_gas() {
let messages = messages(parse_quote! {
#[gas = -5]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
});
assert_eq!(messages.len(), 1, "{messages:?}");
assert_eq!(messages[0], "`gas` must not be negative");
}
#[test]
fn rejects_unusable_wasm_names() {
let empty = messages(parse_quote! {
#[gas = 60]
#[wasm_name = ""]
fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
});
assert_eq!(empty.len(), 1, "{empty:?}");
assert_eq!(empty[0], "the wasm name must not be empty");
let spaced = messages(parse_quote! {
#[gas = 60]
#[wasm_name = "ldgr index"]
fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
});
assert_eq!(spaced.len(), 1, "{spaced:?}");
assert!(spaced[0].contains("may only contain"), "{spaced:?}");
}
#[test]
fn rejects_signature_modifiers() {
for declaration in [
quote! { unsafe fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; },
quote! { async fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; },
quote! { const fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; },
quote! { extern "C" fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>; },
] {
let function: TraitItemFn = syn::parse2(quote! {
#[gas = 60]
#[wasm_name = "ldgr_index"]
#declaration
})
.unwrap();
let messages = messages(function);
assert_eq!(messages.len(), 1, "{messages:?}");
assert!(messages[0].contains("must be a plain `fn`"), "{messages:?}");
}
}
#[test]
fn trait_method_keeps_the_declared_receiver_and_ends_in_a_semicolon() {
let parsed = ParsedHostFunction::parse(parse_quote! {
/// Hashes `data`.
#[gas = 2000]
#[wasm_name = "sha512_half"]
fn sha512_half(&self, data: &[u8]) -> HostResult<[u8; 32]>;
})
.unwrap();
// `///` reaches the macro as `#[doc = r"..."]`: rustc's lexer spells doc
// comments as raw string literals.
let method = parsed.trait_method().to_string();
assert!(
method.starts_with("# [doc = r\" Hashes `data`.\"]"),
"{method}"
);
assert!(
method
.contains("fn sha512_half (& self , data : & [u8]) -> HostResult < [u8 ; 32] > ;"),
"{method}"
);
}
#[test]
fn spec_arm_carries_the_name_and_the_gas() {
let parsed = ParsedHostFunction::parse(parse_quote! {
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
})
.unwrap();
assert_eq!(
parsed.spec_arm().to_string(),
"Self :: GetLedgerSqn => HostFnSpec { name : \"ldgr_index\" , gas : 60u64 }"
);
}
#[test]
fn keeps_doc_comments_in_source_order() {
let parsed = ParsedHostFunction::parse(parse_quote! {
/// First line.
///
/// Third line.
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
})
.unwrap();
let docs: Vec<_> = parsed.docs.iter().map(doc_text).collect();
assert_eq!(docs, vec![" First line.", "", " Third line."]);
}
#[test]
fn preserves_parameters_and_return_type() {
let traced = ParsedHostFunction::parse(parse_quote! {
#[gas = 500]
#[wasm_name = "trace"]
fn trace(&self, msg: &str, data: &[u8], as_hex: bool) -> HostResult<()>;
})
.unwrap();
// The receiver is `inputs[0]`; the three wasm parameters follow it.
assert_eq!(traced.signature.inputs.len(), 4);
assert_eq!(
traced.signature.output.to_token_stream().to_string(),
"-> HostResult < () >"
);
let hashed = ParsedHostFunction::parse(parse_quote! {
#[gas = 2000]
#[wasm_name = "sha512_half"]
fn sha512_half(&self, data: &[u8]) -> HostResult<[u8; HASH_LEN]>;
})
.unwrap();
assert_eq!(
hashed.signature.output.to_token_stream().to_string(),
"-> HostResult < [u8 ; HASH_LEN] >"
);
}
#[test]
fn reports_both_missing_attributes_at_once() {
let messages = messages(parse_quote! {
fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
});
assert_eq!(messages.len(), 2);
assert!(messages[0].contains("missing `#[gas"), "{messages:?}");
assert!(messages[1].contains("missing `#[wasm_name"), "{messages:?}");
}
#[test]
fn names_the_unexpected_attribute() {
let messages = messages(parse_quote! {
#[gas = 60]
#[wsam_name = "typo"]
fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
});
// The typo'd attribute, plus the `wasm_name` it failed to be.
assert_eq!(messages.len(), 2);
assert!(
messages.iter().any(|m| m.contains("`wsam_name`")),
"{messages:?}"
);
}
#[test]
fn rejects_wrong_literal_types() {
let gas = messages(parse_quote! {
#[gas = "60"]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
});
assert_eq!(gas.len(), 1, "{gas:?}");
assert!(
gas[0].contains("`gas` expects an integer literal"),
"{gas:?}"
);
let name = messages(parse_quote! {
#[gas = 60]
#[wasm_name = 7]
fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
});
assert_eq!(name.len(), 1, "{name:?}");
assert!(
name[0].contains("`wasm_name` expects a string literal"),
"{name:?}"
);
}
#[test]
fn rejects_gas_that_does_not_fit_in_u64() {
let messages = messages(parse_quote! {
#[gas = 99999999999999999999999]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
});
assert_eq!(messages.len(), 1, "{messages:?}");
assert!(messages[0].contains("number too large"), "{messages:?}");
}
#[test]
fn rejects_attribute_shapes_other_than_name_value() {
let bare = messages(parse_quote! {
#[gas]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
});
assert_eq!(bare.len(), 1, "{bare:?}");
assert!(bare[0].contains("gas = ..."), "{bare:?}");
let list = messages(parse_quote! {
#[gas(60)]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
});
assert_eq!(list.len(), 1, "{list:?}");
}
#[test]
fn rejects_duplicate_attributes() {
let messages = messages(parse_quote! {
#[gas = 60]
#[gas = 70]
#[wasm_name = "ldgr_index"]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
});
assert_eq!(messages.len(), 2, "{messages:?}");
assert!(messages[0].contains("duplicate `gas`"), "{messages:?}");
assert!(
messages[1].contains("duplicate `wasm_name`"),
"{messages:?}"
);
}
/// A malformed attribute must not also be reported as an absent one.
#[test]
fn does_not_report_a_malformed_attribute_as_missing() {
let messages = messages(parse_quote! {
#[gas = "60"]
#[wasm_name = 7]
fn get_ledger_sqn(&self) -> HostResult<[u8; 4]>;
});
assert_eq!(messages.len(), 2, "{messages:?}");
assert!(
!messages.iter().any(|m| m.contains("missing")),
"{messages:?}"
);
}
#[test]
fn rejects_a_body() {
let messages = messages(parse_quote! {
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn(&self) -> HostResult<[u8; 4]> { Ok([0; 4]) }
});
assert_eq!(messages.len(), 1, "{messages:?}");
assert!(messages[0].contains("must not have a body"), "{messages:?}");
}
#[test]
fn rejects_generics() {
let parameter = messages(parse_quote! {
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn<T>(&self) -> HostResult<T>;
});
assert_eq!(parameter.len(), 1, "{parameter:?}");
assert!(
parameter[0].contains("must not be generic"),
"{parameter:?}"
);
let clause = messages(parse_quote! {
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn(&self) -> HostResult<[u8; 4]> where Self: Sized;
});
assert_eq!(clause.len(), 1, "{clause:?}");
}
#[test]
fn requires_a_receiver() {
let messages = messages(parse_quote! {
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn() -> HostResult<[u8; 4]>;
});
assert_eq!(messages.len(), 1, "{messages:?}");
assert!(
messages[0].contains("must declare its receiver: `fn get_ledger_sqn(&self, ...)`"),
"{messages:?}"
);
}
/// Anything but `&self` would need a host the VM cannot hand out: it holds
/// one shared `&dyn HostFunctions` for the whole run.
#[test]
fn rejects_receivers_other_than_shared_self() {
for receiver in [
quote! { &mut self },
quote! { self },
quote! { mut self },
quote! { self: Box<Self> },
quote! { &'a self },
] {
let function: TraitItemFn = syn::parse2(quote! {
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn(#receiver) -> HostResult<[u8; 4]>;
})
.unwrap_or_else(|_| panic!("`{receiver}` should parse"));
let messages = messages(function);
assert_eq!(messages.len(), 1, "`{receiver}`: {messages:?}");
assert!(
messages[0].contains("must be exactly `&self`"),
"`{receiver}`: {messages:?}"
);
}
}
/// A bare `T` return would need its own lowering arm, so the uniform shape is
/// required rather than inferred.
#[test]
fn rejects_returns_that_are_not_host_result() {
for output in [
quote! {},
quote! { -> () },
quote! { -> [u8; 4] },
quote! { -> i32 },
quote! { -> Result<[u8; 4], HostError> },
quote! { -> impl Iterator<Item = u8> },
] {
let function: TraitItemFn = syn::parse2(quote! {
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn(&self) #output;
})
.unwrap_or_else(|_| panic!("`{output}` should parse"));
let messages = messages(function);
assert_eq!(messages.len(), 1, "`{output}`: {messages:?}");
assert!(
messages[0].contains("must return `HostResult<T>`"),
"`{output}`: {messages:?}"
);
}
}
/// `HostResult` may be written qualified, since the trait method keeps whatever
/// path resolves where the block is written.
#[test]
fn accepts_a_qualified_host_result() {
let parsed = ParsedHostFunction::parse(parse_quote! {
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn(&self) -> xrpl_host_functions::HostResult<[u8; 4]>;
})
.unwrap();
assert!(
parsed
.trait_method()
.to_string()
.contains("xrpl_host_functions :: HostResult < [u8 ; 4] >"),
"{}",
parsed.trait_method()
);
}
/// `HostResult` with no success type names no type at all; rustc's own error
/// for that lands on the generated trait, far from the declaration.
#[test]
fn rejects_host_result_without_a_success_type() {
let messages = messages(parse_quote! {
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn(&self) -> HostResult;
});
assert_eq!(messages.len(), 1, "{messages:?}");
assert!(
messages[0].contains("needs its success type"),
"{messages:?}"
);
}
}

View File

@@ -1,7 +0,0 @@
[package]
name = "xrpl-host-functions"
version = "0.1.0"
edition.workspace = true
[dependencies]
xrpl-host-functions-macros.path = "../xrpl-host-functions-macros"

View File

@@ -1,508 +0,0 @@
//! The wasm host ABI: the one place it is declared.
//!
//! `host_functions!` turns the declaration block at the bottom of this file into the
//! [`HostFunctions`] trait a host implements and the [`HostFunctionSpec`] table a
//! wasm engine registers from.
//!
//! The split: hand-written here is the vocabulary the declarations are written in —
//! [`HostError`], [`TraceDataType`], [`HostResult`], [`HASH_LEN`] — and everything
//! derived from the declarations is generated. The expansion names nothing this file
//! does not, so the two sides meet only in the block below.
//!
//! So this file is lists — error codes, trace data types, functions. The `macro_rules!`
//! that expand the first two into enums live in `macros.rs`.
#![no_std]
#[macro_use]
mod macros;
// Not re-exported: the ABI is declared once, here, and this is the only call site.
use xrpl_host_functions_macros::host_functions;
host_errors! {
Unimplemented = -1,
FieldNotFound = -2,
BufferTooSmall = -3,
NoArray = -4,
NotLeafField = -5,
LocatorMalformed = -6,
SlotOutRange = -7,
SlotsFull = -8,
EmptySlot = -9,
LedgerObjNotFound = -10,
OutOfTransferLimit = -11,
DataFieldTooLarge = -12,
PointerOutOfBounds = -13,
NoMemExported = -14,
InvalidParams = -15,
InvalidAccount = -16,
InvalidField = -17,
IndexOutOfBounds = -18,
FloatInputMalformed = -19,
FloatComputationError = -20,
/// Internal fatal error.
/// User code will never see this error but keep it reserved to not rely on the value.
InternalFatal = -2147483648,
}
/// Convenience alias for the trait's fallible returns.
pub type HostResult<T> = Result<T, HostError>;
/// A `sha512Half` digest: the first 32 bytes of a SHA-512, as XRPL uses it.
pub const HASH_LEN: usize = 32;
trace_data_types! {
/// 8 little-endian bytes, rendered as a signed decimal.
Int64 = 1,
/// 8 little-endian bytes, rendered as an unsigned decimal.
Uint64 = 2,
/// A serialized XRPL float: 12 bytes, mantissa then exponent.
Xfloat = 3,
/// A 20-byte account ID, rendered as base58.
Account = 4,
/// A serialized `STAmount`.
Amount = 5,
/// Raw bytes, hex-encoded.
AsHex = 6,
/// Bytes rendered verbatim as text.
AsText = 7,
}
host_functions! {
/// The sequence number of the ledger being built, as 4 little-endian bytes.
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn(&self, out: &mut [u8]) -> HostResult<usize>;
/// The close time of the parent (last-closed) ledger, as 4 little-endian bytes.
#[gas = 60]
#[wasm_name = "parent_ldgr_time"]
fn get_parent_ledger_time(&self, out: &mut [u8]) -> HostResult<usize>;
/// The hash of the parent (last-closed) ledger, as 32 bytes.
#[gas = 60]
#[wasm_name = "parent_ldgr_hash"]
fn get_parent_ledger_hash(&self, out: &mut [u8]) -> HostResult<usize>;
/// The base fee of the ledger being built, in drops, as 4 little-endian bytes.
#[gas = 60]
#[wasm_name = "base_fee"]
fn get_base_fee(&self, out: &mut [u8]) -> HostResult<usize>;
/// Whether an amendment is enabled. The input is either its 32-byte id or its name;
/// the answer is `1` if enabled and `0` if not.
#[gas = 100]
#[wasm_name = "amendment_enabled"]
fn is_amendment_enabled(&self, amendment: &[u8]) -> HostResult<i32>;
/// Load the ledger object with the given 32-byte id into a cache slot, so later
/// calls can read its fields. `cache_idx` selects the slot (1-based); `0` asks the
/// host to assign a free one. Answers the slot used.
#[gas = 5000]
#[wasm_name = "cache_le"]
fn cache_ledger_obj(&self, obj_id: &[u8], cache_idx: i32) -> HostResult<i32>;
/// The serialized bytes of one field of the transaction being executed, selected
/// by its `SField` code.
#[gas = 70]
#[wasm_name = "tx_field"]
fn get_tx_field(&self, field: i32, out: &mut [u8]) -> HostResult<usize>;
/// The serialized bytes of one field of the current (escrow) ledger object.
#[gas = 70]
#[wasm_name = "home_le_field"]
fn get_current_ledger_obj_field(&self, field: i32, out: &mut [u8]) -> HostResult<usize>;
/// The serialized bytes of one field of a previously cached ledger object,
/// selected by its cache slot and the field's `SField` code.
#[gas = 70]
#[wasm_name = "le_field"]
fn get_ledger_obj_field(&self, cache_idx: i32, field: i32, out: &mut [u8]) -> HostResult<usize>;
/// The serialized bytes of a nested field of the transaction, reached by a
/// `locator`: a path of little-endian `i32` steps (so its byte length is a non-zero
/// multiple of 4).
#[gas = 110]
#[wasm_name = "tx_inner"]
fn get_tx_nested_field(&self, locator: &[u8], out: &mut [u8]) -> HostResult<usize>;
/// The serialized bytes of a nested field of the current (escrow) ledger object,
/// reached by a `locator`, as with [`HostFunctions::get_tx_nested_field`].
#[gas = 110]
#[wasm_name = "home_le_inner"]
fn get_current_ledger_obj_nested_field(
&self,
locator: &[u8],
out: &mut [u8],
) -> HostResult<usize>;
/// The serialized bytes of a nested field of a previously cached ledger object,
/// selected by its cache slot and reached by a `locator`.
#[gas = 110]
#[wasm_name = "le_inner"]
fn get_ledger_obj_nested_field(
&self,
cache_idx: i32,
locator: &[u8],
out: &mut [u8],
) -> HostResult<usize>;
/// The number of elements in an array field of the transaction, selected by its
/// `SField` code. Answers the count directly; `NoArray` if the field is not an array.
#[gas = 40]
#[wasm_name = "tx_arr_len"]
fn get_tx_array_len(&self, field: i32) -> HostResult<i32>;
/// The number of elements in an array field of the current (escrow) ledger
/// object, as with [`HostFunctions::get_tx_array_len`].
#[gas = 40]
#[wasm_name = "home_le_arr_len"]
fn get_current_ledger_obj_array_len(&self, field: i32) -> HostResult<i32>;
/// The number of elements in an array field of a previously cached ledger object,
/// selected by its cache slot and `SField` code.
#[gas = 40]
#[wasm_name = "le_arr_len"]
fn get_ledger_obj_array_len(&self, cache_idx: i32, field: i32) -> HostResult<i32>;
/// The number of elements in a nested array field of the transaction, reached by a
/// `locator`.
#[gas = 70]
#[wasm_name = "tx_inner_arr_len"]
fn get_tx_nested_array_len(&self, locator: &[u8]) -> HostResult<i32>;
/// The number of elements in a nested array field of the current (escrow) ledger
/// object, reached by a `locator`, as with [`HostFunctions::get_tx_nested_array_len`].
#[gas = 70]
#[wasm_name = "home_le_inner_arr_len"]
fn get_current_ledger_obj_nested_array_len(&self, locator: &[u8]) -> HostResult<i32>;
/// The number of elements in a nested array field of a previously cached ledger
/// object, selected by its cache slot and reached by a `locator`.
#[gas = 70]
#[wasm_name = "le_inner_arr_len"]
fn get_ledger_obj_nested_array_len(&self, cache_idx: i32, locator: &[u8]) -> HostResult<i32>;
/// Verify `signature` over `message` under `pubkey`. Answers `1` if the signature
/// is valid, `0` if not, or a negative error.
#[gas = 300]
#[wasm_name = "check_sig"]
fn check_signature(
&self,
message: &[u8],
signature: &[u8],
pubkey: &[u8],
) -> HostResult<i32>;
/// The 32-byte ledger key (keylet) of an account's `AccountRoot`, computed from a
/// 20-byte account id.
#[gas = 350]
#[wasm_name = "accountroot_id"]
fn account_keylet(&self, account: &[u8], out: &mut [u8]) -> HostResult<usize>;
/// The 32-byte keylet of an AMM, computed from its two assets. Each asset is a byte
/// slice whose length selects its kind (24 = MPT, 20 = XRP, 40 = issued currency +
/// issuer).
#[gas = 450]
#[wasm_name = "amm_id"]
fn amm_keylet(&self, asset1: &[u8], asset2: &[u8], out: &mut [u8]) -> HostResult<usize>;
/// The 32-byte keylet of a `Check`, computed from a 20-byte account id and its
/// sequence number. `seq` is the guest's `u32` carried as its `i32` bit pattern.
#[gas = 350]
#[wasm_name = "check_id"]
fn check_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult<usize>;
/// The 32-byte keylet of a `Credential`, computed from the 20-byte subject and
/// issuer account ids and a credential-type byte string.
#[gas = 350]
#[wasm_name = "credential_id"]
fn credential_keylet(
&self,
subject: &[u8],
issuer: &[u8],
credential_type: &[u8],
out: &mut [u8],
) -> HostResult<usize>;
/// The 32-byte keylet of a `Delegate` object, computed from the 20-byte account and
/// the account it authorizes.
#[gas = 350]
#[wasm_name = "delegate_id"]
fn delegate_keylet(
&self,
account: &[u8],
authorize: &[u8],
out: &mut [u8],
) -> HostResult<usize>;
/// The 32-byte keylet of a `DepositPreauth`, computed from the 20-byte account and
/// the account it authorizes to deposit.
#[gas = 350]
#[wasm_name = "deposit_preauth_id"]
fn deposit_preauth_keylet(
&self,
account: &[u8],
authorize: &[u8],
out: &mut [u8],
) -> HostResult<usize>;
/// The 32-byte keylet of an account's `DID`, computed from its 20-byte account id.
#[gas = 350]
#[wasm_name = "did_id"]
fn did_keylet(&self, account: &[u8], out: &mut [u8]) -> HostResult<usize>;
/// The 32-byte keylet of an `Escrow`, computed from the 20-byte owner account and
/// its sequence number. `seq` is the guest's `u32` carried as its `i32` bit
/// pattern.
#[gas = 350]
#[wasm_name = "escrow_id"]
fn escrow_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult<usize>;
/// The 32-byte keylet of a `RippleState` (trust line), computed from two 20-byte
/// account ids and a 20-byte currency.
#[gas = 400]
#[wasm_name = "trustline_id"]
fn trust_line_keylet(
&self,
account1: &[u8],
account2: &[u8],
currency: &[u8],
out: &mut [u8],
) -> HostResult<usize>;
/// The 32-byte keylet of an `MPTokenIssuance`, computed from the 20-byte issuer
/// account and its sequence number. `seq` is the guest's `u32` carried as its `i32`
/// bit pattern.
#[gas = 350]
#[wasm_name = "mpt_issuance_id"]
fn mptoken_issuance_keylet(
&self,
issuer: &[u8],
seq: i32,
out: &mut [u8],
) -> HostResult<usize>;
/// The 32-byte keylet of an `MPToken`, computed from a 24-byte MPT issuance id and
/// the 20-byte holder account.
#[gas = 500]
#[wasm_name = "mptoken_id"]
fn mptoken_keylet(&self, mptid: &[u8], holder: &[u8], out: &mut [u8]) -> HostResult<usize>;
/// The 32-byte keylet of an `NFTokenOffer`, computed from the 20-byte owner account
/// and its sequence number. `seq` is the guest's `u32` carried as its `i32` bit
/// pattern.
#[gas = 350]
#[wasm_name = "nft_offer_id"]
fn nftoken_offer_keylet(
&self,
account: &[u8],
seq: i32,
out: &mut [u8],
) -> HostResult<usize>;
/// The 32-byte keylet of an `Offer`, computed from the 20-byte owner account and
/// its sequence number. `seq` is the guest's `u32` carried as its `i32` bit
/// pattern.
#[gas = 350]
#[wasm_name = "offer_id"]
fn offer_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult<usize>;
/// The 32-byte keylet of an `Oracle`, computed from the 20-byte owner account and
/// its document id. `doc_id` is the guest's `u32` carried as its `i32` bit pattern.
#[gas = 350]
#[wasm_name = "oracle_id"]
fn oracle_keylet(&self, account: &[u8], doc_id: i32, out: &mut [u8]) -> HostResult<usize>;
/// The 32-byte keylet of a `PayChannel`, computed from the 20-byte source account,
/// the 20-byte destination account, and the channel's sequence number. `seq` is the
/// guest's `u32` carried as its `i32` bit pattern.
#[gas = 350]
#[wasm_name = "paychan_id"]
fn paychannel_keylet(
&self,
account: &[u8],
destination: &[u8],
seq: i32,
out: &mut [u8],
) -> HostResult<usize>;
/// The 32-byte keylet of a `PermissionedDomain`, computed from the 20-byte owner
/// account and its sequence number. `seq` is the guest's `u32` carried as its `i32`
/// bit pattern.
#[gas = 350]
#[wasm_name = "permissioned_domain_id"]
fn permissioned_domain_keylet(
&self,
account: &[u8],
seq: i32,
out: &mut [u8],
) -> HostResult<usize>;
/// The 32-byte keylet of a `SignerList`, computed from its 20-byte owner account.
#[gas = 350]
#[wasm_name = "signers_id"]
fn signer_list_keylet(&self, account: &[u8], out: &mut [u8]) -> HostResult<usize>;
/// The 32-byte keylet of a `Ticket`, computed from the 20-byte owner account and
/// its ticket sequence number. `seq` is the guest's `u32` carried as its `i32` bit
/// pattern.
#[gas = 350]
#[wasm_name = "ticket_id"]
fn ticket_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult<usize>;
/// The 32-byte keylet of a `Vault`, computed from the 20-byte owner account and its
/// sequence number. `seq` is the guest's `u32` carried as its `i32` bit pattern.
#[gas = 350]
#[wasm_name = "vault_id"]
fn vault_keylet(&self, account: &[u8], seq: i32, out: &mut [u8]) -> HostResult<usize>;
/// The XRPL `sha512Half` of `data`: the first [`HASH_LEN`] bytes of its SHA-512.
#[gas = 2000]
#[wasm_name = "sha512_half"]
fn sha512_half(&self, data: &[u8], out: &mut [u8]) -> HostResult<usize>;
/// Writes `msg` to the trace log, followed by `data` rendered as `data_type` says.
///
/// The one declaration whose wasm function has **no result**: this node's own log
/// is its only effect, so a guest is told nothing. An `Err` from a host therefore
/// reaches it in no form, and only the host-fatal ones do anything at all.
///
/// It is also the one declaration that is **not** the wasm parameter order.
/// `data_type` is the third wasm parameter, between the two regions, because that
/// is where the guest stdlib declares it; `register.rs` takes the arguments in wasm
/// order and calls this in declaration order.
#[gas = 30]
#[wasm_name = "trace"]
fn trace(&self, msg: &str, data: &[u8], data_type: TraceDataType) -> HostResult<()>;
/// Stores `data` as the current object's data field, replacing whatever was there,
/// and returns the number of bytes stored; `DataFieldTooLarge` if it exceeds the
/// host's limit.
#[gas = 1000]
#[wasm_name = "set_data"]
fn update_data(&self, data: &[u8]) -> HostResult<i32>;
/// The URI of the `NFToken` with id `nft_id` (32 bytes) held by the 20-byte
/// `account`.
#[gas = 5000]
#[wasm_name = "nft_uri"]
fn get_nft(&self, account: &[u8], nft_id: &[u8], out: &mut [u8]) -> HostResult<usize>;
/// The 20-byte issuer account encoded in the `NFToken` id `nft_id` (32 bytes).
#[gas = 70]
#[wasm_name = "nft_issuer"]
fn get_nft_issuer(&self, nft_id: &[u8], out: &mut [u8]) -> HostResult<usize>;
/// The taxon encoded in the `NFToken` id `nft_id` (32 bytes), as four little-endian
/// bytes.
#[gas = 60]
#[wasm_name = "nft_taxon"]
fn get_nft_taxon(&self, nft_id: &[u8], out: &mut [u8]) -> HostResult<usize>;
/// The flags encoded in the `NFToken` id `nft_id` (32 bytes).
#[gas = 60]
#[wasm_name = "nft_flags"]
fn get_nft_flags(&self, nft_id: &[u8]) -> HostResult<i32>;
/// The transfer fee encoded in the `NFToken` id `nft_id` (32 bytes).
#[gas = 60]
#[wasm_name = "nft_xfer_fee"]
fn get_nft_transfer_fee(&self, nft_id: &[u8]) -> HostResult<i32>;
/// The sequence number encoded in the `NFToken` id `nft_id` (32 bytes), as four
/// little-endian bytes.
#[gas = 60]
#[wasm_name = "nft_serial"]
fn get_nft_sequence(&self, nft_id: &[u8], out: &mut [u8]) -> HostResult<usize>;
// A "float" here is an XRPL `Number` in its serialized form: a byte blob the guest
// holds opaquely and hands back to these functions. Inputs and outputs that are
// floats are byte regions; `mode` is the rounding mode, a scalar the guest chooses.
/// A float built from the signed integer `x` under rounding `mode`.
#[gas = 100]
#[wasm_name = "float_from_int"]
fn float_from_int(&self, x: i64, mode: i32, out: &mut [u8]) -> HostResult<usize>;
/// A float built from the unsigned integer in the 8-byte region `x` under rounding
/// `mode`.
#[gas = 130]
#[wasm_name = "float_from_uint"]
fn float_from_uint(&self, x: &[u8], mode: i32, out: &mut [u8]) -> HostResult<usize>;
/// A float built from the serialized `STAmount` in `amount` under rounding `mode`.
#[gas = 150]
#[wasm_name = "float_from_stamount"]
fn float_from_stamount(&self, amount: &[u8], mode: i32, out: &mut [u8]) -> HostResult<usize>;
/// A float built from the serialized `STNumber` in `number` under rounding `mode`.
#[gas = 150]
#[wasm_name = "float_from_stnumber"]
fn float_from_stnumber(&self, number: &[u8], mode: i32, out: &mut [u8]) -> HostResult<usize>;
/// The float `x` rounded to a signed integer under rounding `mode`, as eight
/// little-endian bytes.
#[gas = 130]
#[wasm_name = "float_to_int"]
fn float_to_int(&self, x: &[u8], mode: i32, out: &mut [u8]) -> HostResult<usize>;
/// The float `x` split into its mantissa (eight little-endian bytes) and its exponent
/// (four little-endian bytes), each written to its own output region.
#[gas = 130]
#[wasm_name = "float_to_mant_exp"]
fn float_to_mant_exp(
&self,
x: &[u8],
mantissa_out: &mut [u8],
exponent_out: &mut [u8],
) -> HostResult<usize>;
/// A float built from `mantissa` and `exponent` under rounding `mode`.
#[gas = 100]
#[wasm_name = "float_from_mant_exp"]
fn float_from_mant_exp(
&self,
mantissa: i64,
exponent: i32,
mode: i32,
out: &mut [u8],
) -> HostResult<usize>;
/// Compares floats `x` and `y`, returning a negative, zero, or positive scalar as
/// `x` is less than, equal to, or greater than `y`.
#[gas = 80]
#[wasm_name = "float_cmp"]
fn float_compare(&self, x: &[u8], y: &[u8]) -> HostResult<i32>;
/// The float sum `x + y` under rounding `mode`.
#[gas = 160]
#[wasm_name = "float_add"]
fn float_add(&self, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> HostResult<usize>;
/// The float difference `x - y` under rounding `mode`.
#[gas = 160]
#[wasm_name = "float_sub"]
fn float_subtract(&self, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> HostResult<usize>;
/// The float product `x * y` under rounding `mode`.
#[gas = 300]
#[wasm_name = "float_mult"]
fn float_multiply(&self, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> HostResult<usize>;
/// The float quotient `x / y` under rounding `mode`.
#[gas = 300]
#[wasm_name = "float_div"]
fn float_divide(&self, x: &[u8], y: &[u8], mode: i32, out: &mut [u8]) -> HostResult<usize>;
/// The `n`-th root of the float `x` under rounding `mode`.
#[gas = 5500]
#[wasm_name = "float_root"]
fn float_root(&self, x: &[u8], n: i32, mode: i32, out: &mut [u8]) -> HostResult<usize>;
/// The float `x` raised to the power `n` under rounding `mode`.
#[gas = 5500]
#[wasm_name = "float_pow"]
fn float_power(&self, x: &[u8], n: i32, mode: i32, out: &mut [u8]) -> HostResult<usize>;
}

View File

@@ -1,102 +0,0 @@
//! The `macro_rules!` behind the two hand-listed enums, [`crate::HostError`] and
//! [`crate::TraceDataType`].
//!
//! Each takes one list of `Variant = code,` and expands the enum together with the
//! `ALL`/`code`/`from_code` set that must not fall behind it. The lists themselves stay
//! in `lib.rs`, beside the `host_functions!` block.
/// Declares [`crate::HostError`] from one list: the variants, `HostError::ALL` and
/// `HostError::from_code`'s table all expand from the codes given.
///
/// One list is what makes `ALL` complete. Rust cannot enumerate an enum's
/// variants — an exhaustive `match` forces an arm per variant but gives nothing to
/// iterate — so a hand-written `ALL` beside a hand-written enum could only be kept
/// in step by review, and `ALL`'s whole purpose is to be the set a test can trust.
/// A code added to the list gains its `ALL` entry and its `from_code` arm by
/// construction. `HostFunctionSpec::ALL` is complete the same way, from the
/// `host_functions!` block.
macro_rules! host_errors {
($($(#[$doc:meta])* $variant:ident = $code:literal,)+) => {
/// Error codes a host function may return.
///
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i32)]
pub enum HostError {
$($(#[$doc])* $variant = $code,)+
}
impl HostError {
/// Every error a host function may return, in code order.
///
/// The complete set, and complete by construction: a wasm engine's
/// split between the codes it hands the guest and the conditions it
/// traps on is a decision per variant, so the test that checks the
/// split iterates this and a code added to the ABI cannot slip past it.
pub const ALL: &'static [HostError] = &[$(HostError::$variant,)+];
/// The negative wire value a failed call returns. Every code but
/// `InternalFatal` is one a guest reads off that value.
#[inline]
pub const fn code(self) -> i32 {
self as i32
}
/// Reconstruct a `HostError` from its wire code.
///
/// A code this ABI does not define is `InternalFatal`: an answer the
/// caller cannot act on is the call not having been served, and that is
/// the variant which says so. Positive values are not errors at all and go
/// the same way, since this is reached only once a negative return has
/// been read as a failure.
pub const fn from_code(code: i32) -> HostError {
match code {
$($code => HostError::$variant,)+
_ => HostError::InternalFatal,
}
}
}
};
}
/// Declares [`crate::TraceDataType`] from one list, so `TraceDataType::ALL`,
/// `TraceDataType::code` and `TraceDataType::from_code` cannot fall behind the
/// variants — the reason `host_errors!` above is written this way.
macro_rules! trace_data_types {
($($(#[$doc:meta])* $variant:ident = $code:literal,)+) => {
/// How [`HostFunctions::trace`] is to read its data buffer.
///
/// The discriminants are wire values shared with the guest stdlib: append only,
/// never renumber. They start at 1, so a zeroed argument names no type rather
/// than the first one.
///
/// This is the declaration a guest and a host both compile against. The host
/// side needs a second one — `cxx` cannot be a dependency here, since this
/// crate also links into the guest — so `xrpl-wasm-vm-ffi` declares a shared
/// enum for C++ and converts, exhaustively, from this.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i32)]
pub enum TraceDataType {
$($(#[$doc])* $variant = $code,)+
}
impl TraceDataType {
/// Every data type a guest may name, in code order.
pub const ALL: &'static [TraceDataType] = &[$(TraceDataType::$variant,)+];
/// The wire value a guest passes to name this type.
#[inline]
pub const fn code(self) -> i32 {
self as i32
}
/// The type `code` names, or `None`: the engine drops a call it cannot
/// read rather than guessing at a rendering the guest did not ask for.
pub const fn from_code(code: i32) -> Option<TraceDataType> {
match code {
$($code => Some(TraceDataType::$variant),)+
_ => None,
}
}
}
};
}

View File

@@ -1,34 +0,0 @@
//! `host_functions!` must work outside the crate that declares the ABI: the only
//! names its expansion needs are the ones the declarations themselves spell.
use xrpl_host_functions::HostResult;
use xrpl_host_functions_macros::host_functions;
host_functions! {
/// Answers with the number it was given.
#[gas = 7]
#[wasm_name = "ping"]
fn ping(&self, number: i32) -> HostResult<i32>;
}
struct Host;
impl HostFunctions for Host {
fn ping(&self, number: i32) -> HostResult<i32> {
Ok(number)
}
}
#[test]
fn the_generated_table_stands_on_its_own() {
assert_eq!(HostFunctionSpec::ALL.len(), 1);
assert_eq!(HostFunctionSpec::Ping.wasm_name(), "ping");
assert_eq!(HostFunctionSpec::Ping.gas(), 7);
}
/// The generated trait is implementable from another crate, which is the point of
/// declaring the ABI in a library at all.
#[test]
fn the_generated_trait_is_implementable_here() {
assert_eq!(Host.ping(3), Ok(3));
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,102 +0,0 @@
//! Exercises what `host_errors!` generates: the wire codes, the set
//! [`HostError::ALL`] names, and the round trip between them.
//!
//! The codes are consensus input — they are what a guest reads off a failed host
//! call — so they are pinned here as literals and derived everywhere else.
use xrpl_host_functions::HostError;
/// The whole set, written out in the order `ALL` gives it: the one place the wire
/// codes appear as literals, and a deliberate change-detector, since a code that
/// moves changes what every deployed guest is told.
#[test]
fn the_error_table_matches_the_declarations() {
let table: Vec<(HostError, i32)> = HostError::ALL
.iter()
.map(|&error| (error, error.code()))
.collect();
assert_eq!(
table,
[
(HostError::Unimplemented, -1),
(HostError::FieldNotFound, -2),
(HostError::BufferTooSmall, -3),
(HostError::NoArray, -4),
(HostError::NotLeafField, -5),
(HostError::LocatorMalformed, -6),
(HostError::SlotOutRange, -7),
(HostError::SlotsFull, -8),
(HostError::EmptySlot, -9),
(HostError::LedgerObjNotFound, -10),
(HostError::OutOfTransferLimit, -11),
(HostError::DataFieldTooLarge, -12),
(HostError::PointerOutOfBounds, -13),
(HostError::NoMemExported, -14),
(HostError::InvalidParams, -15),
(HostError::InvalidAccount, -16),
(HostError::InvalidField, -17),
(HostError::IndexOutOfBounds, -18),
(HostError::FloatInputMalformed, -19),
(HostError::FloatComputationError, -20),
(HostError::InternalFatal, i32::MIN),
]
);
}
/// The guest-facing set is `-1 ..= -20` and nothing else: those entries are xrpld's
/// `HostFunctionError`, and each is a code some contract may read.
///
/// `InternalFatal` is the one deliberate exception, exempted by name rather than by
/// widening the range: a condition with no number a contract can act on needs no number
/// in the range a contract reads, and holding it at `i32::MIN` is what keeps it from
/// ever colliding with a code appended to xrpld's list.
#[test]
fn every_code_but_the_sentinel_is_in_the_shared_range() {
let shared: Vec<HostError> = HostError::ALL
.iter()
.copied()
.filter(|&error| error != HostError::InternalFatal)
.collect();
let outside: Vec<HostError> = shared
.iter()
.copied()
.filter(|error| !(-20..=-1).contains(&error.code()))
.collect();
assert!(outside.is_empty(), "outside -1..=-20: {outside:?}");
assert_eq!(shared.len(), 20);
assert_eq!(HostError::InternalFatal.code(), i32::MIN);
assert_eq!(HostError::ALL.len(), 21);
}
/// Every code a guest can be handed comes back as the error that produced it, so a
/// caller reading a negative return value recovers the condition and not a
/// neighbouring one. The table above pins the numbers; this adds only the round
/// trip.
#[test]
fn every_wire_code_round_trips_back_to_its_error() {
for &error in HostError::ALL {
assert_eq!(HostError::from_code(error.code()), error, "{error:?}");
}
}
/// A code from outside the set is `InternalFatal`: a host answering something this ABI
/// does not define has not served the call, whatever it meant by it, and success is not
/// an error at all.
///
/// `-21` is the code xrpld would append next, so it is the one that decides whether a
/// list this crate has not caught up with reaches a guest or stops the run. `i32::MIN +
/// 1` is next to the sentinel and unassigned, which is what makes the sentinel a value
/// rather than a range.
#[test]
fn a_code_outside_the_set_is_internal_fatal() {
for code in [-21, i32::MIN + 1, 0, 1, i32::MAX] {
assert_eq!(
HostError::from_code(code),
HostError::InternalFatal,
"{code}"
);
}
}

View File

@@ -1,11 +0,0 @@
[package]
name = "xrpl-wasm-testkit"
version = "0.1.0"
edition.workspace = true
[lib]
crate-type = ["staticlib", "rlib"]
[dependencies]
cxx.workspace = true
wat = "1"

View File

@@ -1,49 +0,0 @@
//! Assembles WebAssembly text for the C++ test suite. **Test-only.**
//!
//! A crate of its own rather than an entry on `xrpl-wasm-vm-ffi`, and the separation is the
//! point. The engine pins `wasmi = { default-features = false }` precisely so a text
//! assembler cannot reach the consensus path — wasmi's `wat` feature is on by default and
//! makes `Module::new` accept text as readily as binary, which would make a transaction's
//! validity a build flag. Putting `compile_wat` on the production bridge would link `wat`
//! into xrpld even if nothing called it.
//!
//! Linked only into `xrpl_tests`, never into `libxrpl` or `xrpld`, so "no assembler in the
//! shipped node" is a property of the link graph rather than a flag someone can flip.
#![deny(rustdoc::broken_intra_doc_links)]
#[cxx::bridge(namespace = "rs::wasm_testkit")]
mod ffi {
extern "Rust" {
/// Assemble `wat` to a wasm module.
///
/// Throws `rust::Error` on invalid input, which is what a test wants: a typo in a
/// fixture should fail the test that holds it, at the line that holds it.
fn compile_wat(wat: &str) -> Result<Vec<u8>>;
}
}
fn compile_wat(wat: &str) -> Result<Vec<u8>, wat::Error> {
wat::parse_str(wat)
}
#[cfg(test)]
mod tests {
use super::compile_wat;
#[test]
fn a_module_assembles_to_something_beginning_with_the_wasm_magic() {
let wasm = compile_wat("(module)").expect("assembles");
assert_eq!(&wasm[..4], b"\0asm");
}
#[test]
fn a_typo_is_an_error_rather_than_a_module() {
let error = compile_wat("(module (func (export").expect_err("must not assemble");
assert!(
!error.to_string().is_empty(),
"the error has to say something"
);
}
}

View File

@@ -1,12 +0,0 @@
[package]
name = "xrpl-wasm-vm-ffi"
version = "0.1.0"
edition.workspace = true
[lib]
crate-type = ["staticlib", "rlib"]
[dependencies]
cxx.workspace = true
xrpl-host-functions = { path = "../xrpl-host-functions" }
xrpl-wasm-vm = { path = "../xrpl-wasm-vm" }

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +0,0 @@
[package]
name = "xrpl-wasm-vm"
version = "0.1.0"
edition.workspace = true
[dependencies]
wasmi = { version = "1.1.0", default-features = false, features = ["std"] }
xrpl-host-functions = { path = "../xrpl-host-functions" }
[dev-dependencies]
wat = "1"

View File

@@ -1,827 +0,0 @@
use crate::region::Region;
use crate::vm::{MAX_FIELD_BYTES, VmState};
use core::ops::Range;
use wasmi::{Caller, Memory};
use xrpl_host_functions::{HostError, HostFunctionSpec, HostFunctions, HostResult};
/// A condition that stops the run. It is a property of the run rather than an answer
/// to a call, so it reaches no guest and carries no wire code — which is why it is
/// not a [`HostError`]: no host can report one and no contract can read one.
///
/// The three are the outcomes a host call can end a run with, and
/// `From<Fault> for RunError` in `vm.rs` is where each gets its name.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Fault {
/// This call's charge would take the meter below zero. The guest exhausting the
/// meter with its own instructions reaches [`crate::vm::RunError::OutOfGas`] by
/// wasmi's `OutOfFuel` trap instead, never through here.
OutOfGas,
/// The call could not be served: either the host said so, or this engine's own
/// fuel meter did not answer.
Internal,
/// There is no linear memory to work in — the module exports none, or the call
/// came from a start section, which runs before there is an instance.
NoMemory,
}
/// How a host call fails: with a code the guest reads off the return value, or with a
/// [`Fault`] that stops the run.
///
/// **The variant picks the channel.** [`to_wire`] reads it rather than asking a
/// predicate, so the two cannot disagree, and a [`FatalHostError`] cannot be built
/// around something a guest was supposed to see.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CallError {
Code(HostError),
Fatal(Fault),
}
/// A host call's result inside the engine: [`HostResult`] plus the faults only the
/// engine can raise.
pub(crate) type CallResult<T> = Result<T, CallError>;
/// Which channel a host's answer takes, decided once, here.
///
/// Three codes stop the run instead of reaching the contract that asked. Each says the
/// call was not served at all — the host could not do it, it has not been wired, or
/// there is nowhere to put the answer — and a contract has no business interpreting
/// any of them, so it is told nothing and the run ends. Every other code is the
/// contract's to read.
impl From<HostError> for CallError {
fn from(error: HostError) -> CallError {
match error {
HostError::InternalFatal => CallError::Fatal(Fault::Internal),
HostError::Unimplemented => CallError::Fatal(Fault::Internal),
HostError::NoMemExported => CallError::Fatal(Fault::NoMemory),
code => CallError::Code(code),
}
}
}
/// The payload a trap carries so [`crate::vm::run`] can name the outcome without
/// parsing a message. Holds a [`Fault`], so by construction no guest-visible code can
/// leave through this channel.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct FatalHostError(pub(crate) Fault);
impl wasmi::errors::HostError for FatalHostError {}
impl core::fmt::Display for FatalHostError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "host call refused: {:?}", self.0)
}
}
/// Charge the call's gas, run its body, put the result on the wire. The one path
/// every registered closure takes, so gas cannot be forgotten.
pub(crate) fn charged(
caller: &mut Caller<'_, VmState<'_>>,
op: HostFunctionSpec,
body: impl FnOnce(&mut Caller<'_, VmState<'_>>) -> CallResult<i32>,
) -> Result<i32, wasmi::Error> {
to_wire(charge(caller, op.gas()).and_then(|()| body(caller)))
}
/// [`charged`] for a call the guest gets no answer from: its wasm function has no
/// result, so a soft error has nowhere to go and is dropped. The gas is charged first
/// and charged whatever happens after, so the cost is all such a call leaves behind.
///
/// Only `trace` takes this path.
pub(crate) fn charged_unreported(
caller: &mut Caller<'_, VmState<'_>>,
op: HostFunctionSpec,
body: impl FnOnce(&mut Caller<'_, VmState<'_>>) -> CallResult<()>,
) -> Result<(), wasmi::Error> {
dropped(charge(caller, op.gas()).and_then(|()| body(caller)))
}
/// [`to_wire`] for a call with no result: there is no return value to encode a code
/// in, so it is dropped. A [`Fault`] still stops the run — that is a property of the
/// run, not an answer to the call.
fn dropped(result: CallResult<()>) -> Result<(), wasmi::Error> {
match result {
Err(CallError::Fatal(fault)) => Err(wasmi::Error::host(FatalHostError(fault))),
_ => Ok(()),
}
}
fn to_wire(result: CallResult<i32>) -> Result<i32, wasmi::Error> {
match result {
Ok(value) => Ok(value),
Err(CallError::Code(error)) => Ok(error.code()),
Err(CallError::Fatal(fault)) => Err(wasmi::Error::host(FatalHostError(fault))),
}
}
/// Deduct `cost` fuel; [`Fault::OutOfGas`] if it would go negative.
///
/// A meter that will not answer is this crate's own defect, not the contract's, so it
/// is [`Fault::Internal`] rather than a number a guest could act on.
fn charge<T>(caller: &mut Caller<'_, T>, cost: u64) -> CallResult<()> {
let remaining = caller
.get_fuel()
.map_err(|_| CallError::Fatal(Fault::Internal))?;
match remaining.checked_sub(cost) {
Some(left) => caller
.set_fuel(left)
.map_err(|_| CallError::Fatal(Fault::Internal)),
None => {
let _ = caller.set_fuel(0);
Err(CallError::Fatal(Fault::OutOfGas))
}
}
}
fn charge_transfer(state: &VmState<'_>, n: usize) -> Result<(), HostError> {
let n = n as u64;
let remaining = state.transfer_budget.get();
match remaining.checked_sub(n) {
Some(left) => {
state.transfer_budget.set(left);
Ok(())
}
None => Err(HostError::OutOfTransferLimit),
}
}
fn memory(caller: &Caller<'_, VmState<'_>>) -> CallResult<Memory> {
caller
.data()
.memory
.ok_or(CallError::Fatal(Fault::NoMemory))
}
/// [`Region::read`] of the guest's memory, for a call that reads and writes nothing
/// back (`trace`).
pub(crate) fn read_borrowed<'a>(
caller: &'a Caller<'_, VmState<'_>>,
input: Region,
) -> CallResult<&'a [u8]> {
let mem = memory(caller)?;
Ok(input.read(mem.data(caller))?)
}
/// Decode a guest `u32` argument — a keylet's sequence number or document id — from
/// its four little-endian bytes, carried on to the host as its `i32` bit pattern.
///
/// The ABI transports these as a 4-byte region rather than a wasm scalar (the guest
/// SDK passes `seq.to_le_bytes()`), so the region must be exactly four bytes;
/// `InvalidParams` otherwise.
pub(crate) fn read_u32_arg(bytes: &[u8]) -> HostResult<i32> {
let arr: [u8; 4] = bytes.try_into().map_err(|_| HostError::InvalidParams)?;
Ok(i32::from_le_bytes(arr))
}
/// Service a call whose answer is bytes, written straight into the guest's output
/// region.
///
/// **`fill` returns the value's true length, not what it wrote**: a host holding 64
/// bytes and offered room for 4 writes nothing and answers `64`, which is how the
/// guest learns the size to ask for. So `n` is bounded by neither the region, the
/// cap, nor the budget, and all three checks below are reachable.
pub(crate) fn write_into(
caller: &mut Caller<'_, VmState<'_>>,
out: Region,
fill: impl FnOnce(&dyn HostFunctions, &mut [u8]) -> HostResult<usize>,
) -> CallResult<i32> {
let range = out.range()?;
let cap = range.len();
let mem = memory(caller)?;
let host: &dyn HostFunctions = caller.data().host;
let budget = usize::try_from(caller.data().transfer_budget.get()).unwrap_or(usize::MAX);
let buf = mem
.data_mut(&mut *caller)
.get_mut(range)
.ok_or(HostError::PointerOutOfBounds)?;
let buf = &mut buf[..cap.min(MAX_FIELD_BYTES).min(budget)];
let n = fill(host, buf)?;
if n > MAX_FIELD_BYTES {
return Err(HostError::DataFieldTooLarge.into());
}
if n > cap {
return Err(HostError::BufferTooSmall.into());
}
charge_transfer(caller.data(), n)?;
#[expect(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
reason = "`n > MAX_FIELD_BYTES` returned above, and the cap is far inside i32"
)]
let n = n as i32;
Ok(n)
}
/// Service a call that reads guest memory and writes bytes back to it: the host
/// fills the run's output buffer, which is copied to the guest once every rule has
/// passed.
///
/// `call` gets the guest's whole memory, so it can borrow any number of input
/// regions with [`Region::read`] — which a `&mut` view of that memory would forbid.
/// That is why the answer goes through a buffer instead of straight into the guest
/// as [`write_into`]'s does.
///
/// **The host is never told the guest's capacity**: it is offered the whole buffer
/// and reports the value's true length, so the fit is decided here, with nothing yet
/// in guest memory. A refused value therefore reaches it in no part.
///
/// The output is judged after the inputs, so a call with both bad reports the
/// input's verdict. `NoMemExported` precedes both: there is no memory to validate a
/// region against.
pub(crate) fn write_buffered(
caller: &mut Caller<'_, VmState<'_>>,
out: Region,
call: impl FnOnce(&dyn HostFunctions, &[u8], &mut [u8]) -> HostResult<usize>,
) -> CallResult<i32> {
let mem = memory(caller)?;
// One borrow split in two: the guest's bytes for the inputs, the store data for
// the output buffer. Taking them together is what keeps the inputs borrowed
// rather than copied out.
let (data, state) = mem.data_and_store_mut(&mut *caller);
let host: &dyn HostFunctions = state.host;
let n = call(host, data, &mut state.out_buffer[..])?;
// `out` is checked here rather than before the call: the inputs are judged
// first, so a call with both malformed reports the input's verdict.
let range = out.range()?;
let cap = range.len();
if n > MAX_FIELD_BYTES {
return Err(HostError::DataFieldTooLarge.into());
}
let buf = data.get_mut(range).ok_or(HostError::PointerOutOfBounds)?;
if n > cap {
return Err(HostError::BufferTooSmall.into());
}
charge_transfer(state, n)?;
buf[..n].copy_from_slice(&state.out_buffer[..n]);
#[expect(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
reason = "`n > MAX_FIELD_BYTES` returned above, and the cap is far inside i32"
)]
let n = n as i32;
Ok(n)
}
/// The mantissa and exponent widths `float_to_mant_exp` writes: an `i64` and an `i32`.
/// Fixed by the ABI, not the guest, so the split is a constant rather than a reported
/// length.
const MANTISSA_BYTES: usize = 8;
const EXPONENT_BYTES: usize = 4;
fn check_fits(data: &[u8], range: &Range<usize>, width: usize) -> HostResult<()> {
let region = data
.get(range.clone())
.ok_or(HostError::PointerOutOfBounds)?;
if region.len() < width {
return Err(HostError::BufferTooSmall);
}
Ok(())
}
/// Service `float_to_mant_exp`, the one call that writes two output regions: the host
/// fills the run's output buffer with the mantissa followed by the exponent, and each
/// is copied to its own guest region once every rule has passed.
///
/// Like [`write_buffered`], the host reads its input from the guest's memory and writes
/// to a scratch buffer, so the input stays borrowed rather than copied. The two output
/// regions are judged after the input, and the mantissa's region before the exponent's,
/// so the first fault reported is the leftmost.
///
/// The two widths are the ABI's rather than the guest's, so the length the host reports
/// is checked against their sum for equality rather than as a bound, and ahead of the
/// output regions: a wrong total means there is no answer to place, whatever the guest
/// declared. That is a fatal error and not a status, since the guest asked for nothing
/// wrong.
pub(crate) fn write_mant_exp(
caller: &mut Caller<'_, VmState<'_>>,
mantissa_out: Region,
exponent_out: Region,
call: impl FnOnce(&dyn HostFunctions, &[u8], &mut [u8], &mut [u8]) -> HostResult<usize>,
) -> CallResult<i32> {
let mem = memory(caller)?;
let (data, state) = mem.data_and_store_mut(&mut *caller);
let host: &dyn HostFunctions = state.host;
// The scratch buffer is split at the fixed mantissa width: the host fills the first
// eight bytes with the mantissa and the next four with the exponent.
let (mant_buf, exp_buf) = state.out_buffer.split_at_mut(MANTISSA_BYTES);
let mant_buf = &mut mant_buf[..MANTISSA_BYTES];
let exp_buf = &mut exp_buf[..EXPONENT_BYTES];
let total = call(host, data, mant_buf, exp_buf)?;
// Both buffers are fixed-width and were offered whole, so the only length the host
// can correctly report is their sum. Anything else is the host contradicting the
// ABI: with the widths in doubt, part of what would be copied out is whatever the
// previous call left in the buffer, so none of it is copied.
if total != MANTISSA_BYTES + EXPONENT_BYTES {
return Err(HostError::InternalFatal.into());
}
let mant_range = mantissa_out.range()?;
check_fits(data, &mant_range, MANTISSA_BYTES)?;
let exp_range = exponent_out.range()?;
check_fits(data, &exp_range, EXPONENT_BYTES)?;
charge_transfer(state, MANTISSA_BYTES + EXPONENT_BYTES)?;
let mant_dst = data
.get_mut(mant_range)
.ok_or(HostError::PointerOutOfBounds)?;
mant_dst[..MANTISSA_BYTES].copy_from_slice(&state.out_buffer[..MANTISSA_BYTES]);
let exp_dst = data
.get_mut(exp_range)
.ok_or(HostError::PointerOutOfBounds)?;
exp_dst[..EXPONENT_BYTES]
.copy_from_slice(&state.out_buffer[MANTISSA_BYTES..MANTISSA_BYTES + EXPONENT_BYTES]);
#[expect(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
reason = "a total other than 12 returned above, and 12 is far inside i32"
)]
let total = total as i32;
Ok(total)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vm::TRANSFER_LIMIT_BYTES;
use std::cell::Cell;
use wasmi::StoreLimitsBuilder;
use xrpl_host_functions::TraceDataType;
/// `charge_transfer` takes the store data, which has to hold a host.
struct UncalledHost;
impl HostFunctions for UncalledHost {
fn get_ledger_sqn(&self, _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn get_parent_ledger_time(&self, _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn get_parent_ledger_hash(&self, _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn get_base_fee(&self, _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn is_amendment_enabled(&self, _amendment: &[u8]) -> HostResult<i32> {
unreachable!("no unit test in this module calls the host")
}
fn cache_ledger_obj(&self, _obj_id: &[u8], _cache_idx: i32) -> HostResult<i32> {
unreachable!("no unit test in this module calls the host")
}
fn get_tx_field(&self, _field: i32, _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn get_current_ledger_obj_field(&self, _field: i32, _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn get_ledger_obj_field(
&self,
_cache_idx: i32,
_field: i32,
_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn get_tx_nested_field(&self, _locator: &[u8], _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn get_current_ledger_obj_nested_field(
&self,
_locator: &[u8],
_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn get_ledger_obj_nested_field(
&self,
_cache_idx: i32,
_locator: &[u8],
_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn get_tx_array_len(&self, _field: i32) -> HostResult<i32> {
unreachable!("no unit test in this module calls the host")
}
fn get_current_ledger_obj_array_len(&self, _field: i32) -> HostResult<i32> {
unreachable!("no unit test in this module calls the host")
}
fn get_ledger_obj_array_len(&self, _cache_idx: i32, _field: i32) -> HostResult<i32> {
unreachable!("no unit test in this module calls the host")
}
fn get_tx_nested_array_len(&self, _locator: &[u8]) -> HostResult<i32> {
unreachable!("no unit test in this module calls the host")
}
fn get_current_ledger_obj_nested_array_len(&self, _locator: &[u8]) -> HostResult<i32> {
unreachable!("no unit test in this module calls the host")
}
fn get_ledger_obj_nested_array_len(
&self,
_cache_idx: i32,
_locator: &[u8],
) -> HostResult<i32> {
unreachable!("no unit test in this module calls the host")
}
fn check_signature(
&self,
_message: &[u8],
_signature: &[u8],
_pubkey: &[u8],
) -> HostResult<i32> {
unreachable!("no unit test in this module calls the host")
}
fn account_keylet(&self, _account: &[u8], _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn amm_keylet(&self, _asset1: &[u8], _asset2: &[u8], _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn check_keylet(&self, _account: &[u8], _seq: i32, _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn credential_keylet(
&self,
_subject: &[u8],
_issuer: &[u8],
_credential_type: &[u8],
_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn delegate_keylet(
&self,
_account: &[u8],
_authorize: &[u8],
_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn deposit_preauth_keylet(
&self,
_account: &[u8],
_authorize: &[u8],
_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn did_keylet(&self, _account: &[u8], _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn escrow_keylet(&self, _account: &[u8], _seq: i32, _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn trust_line_keylet(
&self,
_account1: &[u8],
_account2: &[u8],
_currency: &[u8],
_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn mptoken_issuance_keylet(
&self,
_issuer: &[u8],
_seq: i32,
_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn mptoken_keylet(
&self,
_mptid: &[u8],
_holder: &[u8],
_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn nftoken_offer_keylet(
&self,
_account: &[u8],
_seq: i32,
_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn offer_keylet(&self, _account: &[u8], _seq: i32, _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn oracle_keylet(
&self,
_account: &[u8],
_doc_id: i32,
_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn paychannel_keylet(
&self,
_account: &[u8],
_destination: &[u8],
_seq: i32,
_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn permissioned_domain_keylet(
&self,
_account: &[u8],
_seq: i32,
_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn signer_list_keylet(&self, _account: &[u8], _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn ticket_keylet(&self, _account: &[u8], _seq: i32, _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn vault_keylet(&self, _account: &[u8], _seq: i32, _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn sha512_half(&self, _data: &[u8], _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn trace(&self, _msg: &str, _data: &[u8], _data_type: TraceDataType) -> HostResult<()> {
unreachable!("no unit test in this module calls the host")
}
fn update_data(&self, _data: &[u8]) -> HostResult<i32> {
unreachable!("no unit test in this module calls the host")
}
fn get_nft(&self, _account: &[u8], _nft_id: &[u8], _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn get_nft_issuer(&self, _nft_id: &[u8], _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn get_nft_taxon(&self, _nft_id: &[u8], _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn get_nft_flags(&self, _nft_id: &[u8]) -> HostResult<i32> {
unreachable!("no unit test in this module calls the host")
}
fn get_nft_transfer_fee(&self, _nft_id: &[u8]) -> HostResult<i32> {
unreachable!("no unit test in this module calls the host")
}
fn get_nft_sequence(&self, _nft_id: &[u8], _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn float_from_int(&self, _x: i64, _mode: i32, _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn float_from_uint(&self, _x: &[u8], _mode: i32, _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn float_from_stamount(
&self,
_amount: &[u8],
_mode: i32,
_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn float_from_stnumber(
&self,
_number: &[u8],
_mode: i32,
_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn float_to_int(&self, _x: &[u8], _mode: i32, _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn float_to_mant_exp(
&self,
_x: &[u8],
_mantissa_out: &mut [u8],
_exponent_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn float_from_mant_exp(
&self,
_mantissa: i64,
_exponent: i32,
_mode: i32,
_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn float_compare(&self, _x: &[u8], _y: &[u8]) -> HostResult<i32> {
unreachable!("no unit test in this module calls the host")
}
fn float_add(
&self,
_x: &[u8],
_y: &[u8],
_mode: i32,
_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn float_subtract(
&self,
_x: &[u8],
_y: &[u8],
_mode: i32,
_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn float_multiply(
&self,
_x: &[u8],
_y: &[u8],
_mode: i32,
_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn float_divide(
&self,
_x: &[u8],
_y: &[u8],
_mode: i32,
_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn float_root(&self, _x: &[u8], _n: i32, _mode: i32, _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn float_power(
&self,
_x: &[u8],
_n: i32,
_mode: i32,
_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
}
fn state(budget: u64) -> VmState<'static> {
VmState {
host: &UncalledHost,
mem_limits: StoreLimitsBuilder::new().build(),
transfer_budget: Cell::new(budget),
memory: None,
out_buffer: [0u8; MAX_FIELD_BYTES],
}
}
/// `wasmi::Error` is not `PartialEq`, so a test expecting the guest-visible
/// channel says so by going through here.
fn wire(result: CallResult<i32>) -> i32 {
to_wire(result)
.unwrap_or_else(|trap| panic!("expected a guest-visible status, got a trap: {trap}"))
}
#[test]
fn a_success_becomes_the_value_and_an_error_becomes_its_code() {
assert_eq!(wire(Ok(0)), 0);
assert_eq!(wire(Ok(32)), 32);
assert_eq!(wire(Err(HostError::BufferTooSmall.into())), -3);
}
/// The codes a host may answer that a contract must not see, and the fault each
/// becomes. Written out rather than derived from `From<HostError>`, which is what
/// they are asserting.
const STOPS_THE_RUN: [(HostError, Fault); 3] = [
(HostError::InternalFatal, Fault::Internal),
(HostError::Unimplemented, Fault::Internal),
(HostError::NoMemExported, Fault::NoMemory),
];
/// Every fault, so the two tests below are the whole set and not a sample.
/// `From<Fault> for RunError` is what forces a fault added later to be
/// considered; this is what forces it to be tested.
const ALL_FAULTS: [Fault; 3] = [Fault::OutOfGas, Fault::Internal, Fault::NoMemory];
#[test]
fn a_code_that_stops_the_run_converts_to_its_fault() {
for (error, fault) in STOPS_THE_RUN {
assert_eq!(CallError::from(error), CallError::Fatal(fault), "{error:?}");
}
}
/// Over `HostError::ALL`, so it is the whole ABI and not a sample: a code added
/// to the ABI arrives already asserted to reach the guest as itself, and stopping
/// the run on it is then a change someone has to come and make.
///
/// `OutOfTransferLimit` is the row worth reading twice: the one budget a
/// contract can be expected to handle, so it is told no rather than killed.
#[test]
fn every_other_code_reaches_the_guest_as_itself() {
for &error in HostError::ALL {
if STOPS_THE_RUN.iter().any(|&(stops, _)| stops == error) {
continue;
}
assert_eq!(CallError::from(error), CallError::Code(error), "{error:?}");
assert_eq!(wire(Err(error.into())), error.code(), "{error:?}");
}
}
/// The trap carries the fault, so `run` can name the outcome without parsing a
/// message.
#[test]
fn a_fault_becomes_a_trap_carrying_it() {
for fault in ALL_FAULTS {
let trap = to_wire(Err(CallError::Fatal(fault)))
.expect_err("a fault must not reach the guest as a code");
let payload = trap.downcast_ref::<FatalHostError>().unwrap_or_else(|| {
panic!("{fault:?}: expected a FatalHostError payload, got: {trap}")
});
assert_eq!(*payload, FatalHostError(fault));
}
}
/// The result-less path splits the same two channels differently: a fault still
/// stops the run, and every code is dropped, since `trace` has no return value to
/// carry it. Over `HostError::ALL` for the reason above — a code added to the ABI
/// arrives asserted against both paths.
#[test]
fn a_call_with_no_result_drops_a_code_and_traps_on_a_fault() {
assert!(dropped(Ok(())).is_ok());
for &error in HostError::ALL {
if let CallError::Code(code) = CallError::from(error) {
assert!(
dropped(Err(CallError::Code(code))).is_ok(),
"{error:?} has no channel to the guest and must be dropped"
);
}
}
for fault in ALL_FAULTS {
let trap =
dropped(Err(CallError::Fatal(fault))).expect_err("a fault must stop the run");
let payload = trap.downcast_ref::<FatalHostError>().unwrap_or_else(|| {
panic!("{fault:?}: expected a FatalHostError payload, got: {trap}")
});
assert_eq!(*payload, FatalHostError(fault));
}
}
#[test]
fn a_transfer_spends_the_budget() {
let state = state(100);
assert_eq!(charge_transfer(&state, 30), Ok(()));
assert_eq!(state.transfer_budget.get(), 70);
assert_eq!(charge_transfer(&state, 70), Ok(()));
assert_eq!(state.transfer_budget.get(), 0);
}
/// The budget bounds the total, so the transfer that would overrun it is
/// refused whole rather than partially charged.
#[test]
fn a_transfer_past_the_budget_is_refused_and_charges_nothing() {
let state = state(100);
assert_eq!(
charge_transfer(&state, 101),
Err(HostError::OutOfTransferLimit)
);
assert_eq!(
state.transfer_budget.get(),
100,
"a refusal must not charge"
);
assert_eq!(charge_transfer(&state, 100), Ok(()));
assert_eq!(
charge_transfer(&state, 1),
Err(HostError::OutOfTransferLimit)
);
}
#[test]
fn transferring_nothing_costs_nothing() {
let state = state(0);
assert_eq!(charge_transfer(&state, 0), Ok(()));
assert_eq!(state.transfer_budget.get(), 0);
}
/// The field cap holds one call to a small share of the run's budget, so the
/// budget bounds a run rather than a call. An inequality, not the two values:
/// those are pinned in `vm.rs`.
#[test]
fn no_single_value_can_exhaust_the_run_budget() {
assert!(
(MAX_FIELD_BYTES as u64) * 64 <= TRANSFER_LIMIT_BYTES,
"one {MAX_FIELD_BYTES}-byte value against a {TRANSFER_LIMIT_BYTES}-byte budget"
);
}
}

View File

@@ -1,28 +0,0 @@
//! The escrow wasm VM: compile a contract, meter it, and serve its host calls.
//!
//! Every guest access goes through `abi.rs` and reaches linear memory only by
//! wasmi's bounds-checked slice operations; `forbid(unsafe_code)` makes that a
//! property rather than a claim. The cast lints are on for the same reason — on a
//! consensus path a truncating or sign-losing cast changes what a contract is
//! charged or told, so each one is argued for at its site.
#![forbid(unsafe_code)]
#![deny(rustdoc::broken_intra_doc_links)]
#![deny(unreachable_pub)]
#![deny(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_sign_loss,
clippy::cast_lossless
)]
mod abi;
mod preflight;
mod region;
mod register;
mod vm;
pub use preflight::{CheckError, check};
pub use vm::{
MAX_FIELD_BYTES, MAX_MEMORY_BYTES, MAX_MEMORY_PAGES, MAX_TABLE_ELEMENTS, RunError, RunFailure,
RunOutcome, TRANSFER_LIMIT_BYTES, run,
};

View File

@@ -1,407 +0,0 @@
//! Screening a contract before it reaches the ledger.
//!
//! [`check`] answers whether [`crate::run`] would refuse a module before the
//! guest's first instruction — the three stages a caller maps to a malformed
//! transaction rather than to a failed one. It needs **no host, no store and no
//! gas**: everything it reads is a property of the compiled module. That is what
//! makes it callable from a transaction's preflight, which has no ledger to serve
//! host calls from.
//!
//! Two things it deliberately does not screen. A module exporting **no** linear
//! memory passes: a contract that makes no host call needs none, and one that
//! does is refused at the call and charged for what it burned. A start section
//! passes: it is guest code, and executing it is the one thing a check must not do
//! — a trap in one is charged to the contract like any other trap.
//!
//! Two things it screens that a run can only discover: an exported memory, or an
//! exported table, larger than the engine grants. Both read the same export list, so
//! [`check_exported_resources`] is one pass — see it for what stays invisible, and
//! why the table case leaves much more of it there.
use std::fmt;
use wasmi::{ExternType, FuncType, Module, ValType};
use xrpl_host_functions::HostFunctionSpec;
use crate::register::HOST_MODULE;
use crate::vm::{MAX_MEMORY_PAGES, MAX_TABLE_ELEMENTS, compile};
/// Why a module cannot be run. One variant per stage, since the caller maps the
/// stages separately.
#[derive(Debug)]
pub enum CheckError {
/// `wasm` is not a valid module under this engine's configuration.
Compile(String),
/// An import no engine of this ABI defines: another module namespace, a name
/// that is not a host function, or one imported as something other than a
/// function.
Import(String),
/// No export named `function_name` with signature `() -> i32`.
EntryPoint(String),
/// The module asks for more linear memory than the engine grants.
Memory(String),
/// The module asks for a larger table than the engine grants.
Table(String),
}
impl fmt::Display for CheckError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CheckError::Compile(detail) => write!(f, "compile: {detail}"),
CheckError::Import(detail) => write!(f, "import: {detail}"),
// The detail says which of the entry point's failures this is, since
// "no entry point" would be wrong for an export of the wrong type.
CheckError::EntryPoint(detail) => write!(f, "{detail}"),
CheckError::Memory(detail) => write!(f, "memory: {detail}"),
CheckError::Table(detail) => write!(f, "table: {detail}"),
}
}
}
/// Screen `wasm`: it must compile, import only what the engine serves, export
/// `function_name` as `() -> i32`, and ask for no more memory or table than it may
/// have.
///
/// The stages are ordered by how much of the module each explains. An import fault
/// is reported before a missing entry point because the imports are what the rest of
/// the module is built on; the resource caps come last, being a request rather than a
/// mistake about the ABI.
pub fn check(wasm: &[u8], function_name: &str) -> Result<(), CheckError> {
let module = compile(wasm).map_err(CheckError::Compile)?;
check_imports(&module)?;
check_entry_point(&module, function_name)?;
check_exported_resources(&module)
}
/// Every import must be one the linker defines. The first that is not ends the
/// check, so a module with several faults reports the earliest.
fn check_imports(module: &Module) -> Result<(), CheckError> {
for import in module.imports() {
check_import(import.module(), import.name(), import.ty()).map_err(CheckError::Import)?;
}
Ok(())
}
/// Whether the engine defines this one import.
///
/// The set of names is [`HostFunctionSpec::ALL`], which is also what
/// [`crate::register::register_host_functions`] iterates — so a check and a run
/// cannot disagree about which names exist, and adding a host function extends
/// both at once. The one thing this does not compare is `ty`'s *signature*, which
/// still parts a module from the engine at instantiation; the kind is compared
/// because the engine defines these names as functions and as nothing else.
///
/// The rules are ordered, not merely alternatives: a guest importing `env::malloc`
/// is told about the namespace rather than that `malloc` is not a host function,
/// because the namespace is the one that explains every other import it has too.
fn check_import(module: &str, name: &str, ty: &ExternType) -> Result<(), String> {
if module != HOST_MODULE {
return Err(format!("'{module}::{name}' is not from '{HOST_MODULE}'"));
}
if !HostFunctionSpec::ALL
.iter()
.any(|op| op.wasm_name() == name)
{
return Err(format!("no host function '{name}'"));
}
if !matches!(ty, ExternType::Func(_)) {
return Err(format!("'{HOST_MODULE}::{name}' is not a function"));
}
Ok(())
}
fn check_entry_point(module: &Module, name: &str) -> Result<(), CheckError> {
match module.get_export(name) {
Some(ExternType::Func(ty)) if is_entry_point(&ty) => Ok(()),
found => Err(CheckError::EntryPoint(entry_point_fault(found, name))),
}
}
/// The entry point's type: nothing in, one `i32` out — what [`crate::run`]'s
/// `get_typed_func::<(), i32>` accepts.
fn is_entry_point(ty: &FuncType) -> bool {
ty.params().is_empty() && matches!(ty.results(), [ValType::I32])
}
/// A module may declare no more linear memory, and no larger a table, than the
/// engine grants. One pass over the exports, since both rules read the same list and
/// the export table is the only place either is visible.
///
/// **A memory or table the module keeps to itself is therefore not screened**: it is
/// absent from the exports, and the store's limiter is what refuses it, at
/// instantiation. That gap is wide for tables — Rust exports
/// `__indirect_function_table` only under `--export-table`, so unexported is the
/// normal shape — and narrow for memories, since a contract needs an exported one to
/// make any host call at all.
///
/// A module faulting on both is reported by whichever it declares first. Neither
/// fault explains the other, so there is no precedence to preserve — only the need
/// for every node to reach the same verdict, which export order already gives.
fn check_exported_resources(module: &Module) -> Result<(), CheckError> {
for export in module.exports() {
match export.ty() {
ExternType::Memory(ty) => {
check_initial_pages(ty.minimum()).map_err(CheckError::Memory)?;
}
ExternType::Table(ty) => {
check_initial_elements(ty.minimum()).map_err(CheckError::Table)?;
}
_ => {}
}
}
Ok(())
}
/// Whether the engine will grant a memory of this declared initial size.
///
/// The *minimum* only: a declared maximum past the cap is legal and simply
/// unreachable, which `vm_limits::a_declared_maximum_past_the_cap_is_allowed_but_
/// unreachable` pins on the run side. Refusing it here would turn a runnable
/// contract away.
fn check_initial_pages(pages: u64) -> Result<(), String> {
if pages > u64::from(MAX_MEMORY_PAGES) {
return Err(format!(
"initial memory of {pages} pages is past the {MAX_MEMORY_PAGES}-page cap"
));
}
Ok(())
}
/// Whether the engine will grant a table of this declared initial size.
///
/// The *minimum* is the whole question: `table.grow` belongs to the reference-types
/// proposal, which [`crate::vm`]'s engine turns off, so a table never becomes larger
/// than it was declared and a declared maximum past the cap is simply unreachable.
fn check_initial_elements(elements: u64) -> Result<(), String> {
let cap = u64::try_from(MAX_TABLE_ELEMENTS).expect("the cap is a small constant");
if elements > cap {
return Err(format!(
"initial table of {elements} elements is past the {MAX_TABLE_ELEMENTS}-element cap"
));
}
Ok(())
}
/// How an entry-point lookup failed, in the words both stages use: a check and a
/// run describe the same module the same way, and "no entry point" would send a
/// contract author looking for a function they already have.
pub(crate) fn entry_point_fault(found: Option<ExternType>, name: &str) -> String {
match found {
Some(ExternType::Func(_)) => {
format!("entry point '{name}' has the wrong signature, expected '() -> i32'")
}
Some(_) => format!("export '{name}' is not a function"),
None => format!("no entry point '{name}'"),
}
}
/// The rules, one by one, on inputs built directly rather than parsed out of a
/// module. `tests/preflight.rs` runs real modules through [`check`]; what is here is
/// what a module cannot state precisely — which rule fires, in which order, and in
/// what words the caller logs it.
///
/// `wat` is a dev-dependency, so the one test here that does need a module writes it
/// as text like every other test in the crate. What the library must not gain is a
/// text *entry point* — `check` and `run` take binaries — and a `cfg(test)` caller
/// cannot give it one.
#[cfg(test)]
mod tests {
use super::*;
use wasmi::{GlobalType, MemoryType, Mutability};
/// A host function as a guest declares it. Any function type will do: the
/// signature is not what [`check_import`] compares.
fn a_function() -> ExternType {
ExternType::Func(FuncType::new([ValType::I32], [ValType::I32]))
}
/// A name every one of these tests can use, taken from the ABI rather than
/// spelled, so it stays a real host function as the ABI changes.
fn a_host_function_name() -> &'static str {
HostFunctionSpec::ALL[0].wasm_name()
}
// -----------------------------------------------------------------------
// Imports
// -----------------------------------------------------------------------
/// Every name the ABI declares is served. Derived from `ALL` rather than
/// listed, so a host function added to the ABI is covered the day it lands.
#[test]
fn every_declared_host_function_is_served() {
for op in HostFunctionSpec::ALL {
assert_eq!(
check_import(HOST_MODULE, op.wasm_name(), &a_function()),
Ok(()),
"{}",
op.wasm_name()
);
}
}
#[test]
fn an_import_from_another_namespace_is_refused() {
for namespace in ["env", "host", "host_lib2", ""] {
let refusal = check_import(namespace, a_host_function_name(), &a_function())
.expect_err(namespace);
assert!(
refusal.contains("is not from 'host_lib'"),
"{namespace}: {refusal}"
);
}
}
#[test]
fn an_unknown_name_is_refused() {
let refusal =
check_import(HOST_MODULE, "no_such_function", &a_function()).expect_err("unknown name");
assert_eq!(refusal, "no host function 'no_such_function'");
}
/// The engine defines these names as functions and as nothing else, so a module
/// importing one as a global or a memory does not link either.
#[test]
fn a_host_function_imported_as_anything_else_is_refused() {
for ty in [
ExternType::Global(GlobalType::new(ValType::I32, Mutability::Const)),
ExternType::Memory(MemoryType::new(1, None)),
] {
let name = a_host_function_name();
let refusal = check_import(HOST_MODULE, name, &ty).expect_err("not a function");
assert_eq!(refusal, format!("'host_lib::{name}' is not a function"));
}
}
/// The rules are ordered. An import that breaks two of them is reported by the
/// first, so the message a contract author reads is the one that explains the
/// rest of their imports too.
#[test]
fn the_namespace_is_reported_before_the_name() {
let refusal = check_import("env", "no_such_function", &a_function())
.expect_err("neither the namespace nor the name is served");
assert!(refusal.contains("is not from 'host_lib'"), "{refusal}");
assert!(
!refusal.contains("no host function"),
"the namespace explains it: {refusal}"
);
}
/// Both halves of the type are load-bearing, and neither is checked anywhere
/// a module cannot reach.
#[test]
fn the_entry_point_type_is_nothing_in_and_one_i32_out() {
assert!(is_entry_point(&FuncType::new([], [ValType::I32])));
for wrong in [
FuncType::new([], []),
FuncType::new([], [ValType::I64]),
FuncType::new([ValType::I32], [ValType::I32]),
FuncType::new([], [ValType::I32, ValType::I32]),
] {
assert!(!is_entry_point(&wrong), "{wrong:?}");
}
}
/// Three faults, three descriptions. A run reports these too, with wasmi's own
/// error appended, so a swapped arm would mislead at both stages at once.
#[test]
fn each_entry_point_fault_is_described_as_itself() {
assert_eq!(
entry_point_fault(Some(a_function()), "finish"),
"entry point 'finish' has the wrong signature, expected '() -> i32'"
);
assert_eq!(
entry_point_fault(
Some(ExternType::Global(GlobalType::new(
ValType::I32,
Mutability::Const
))),
"finish"
),
"export 'finish' is not a function"
);
assert_eq!(
entry_point_fault(None, "finish"),
"no entry point 'finish'",
"an absent export must not be reported as a wrong signature"
);
}
/// The cap itself is granted; one page past it is not. The boundary is the whole
/// rule, and it is the same boundary the store's limiter applies at
/// instantiation.
#[test]
fn the_initial_memory_may_reach_the_cap_but_not_pass_it() {
assert_eq!(check_initial_pages(0), Ok(()));
assert_eq!(check_initial_pages(u64::from(MAX_MEMORY_PAGES)), Ok(()));
let past = u64::from(MAX_MEMORY_PAGES) + 1;
let refusal = check_initial_pages(past).expect_err("one page past the cap");
assert_eq!(
refusal,
format!("initial memory of {past} pages is past the {MAX_MEMORY_PAGES}-page cap")
);
}
/// The cap itself is granted; one element past it is not. The boundary is the
/// whole rule, and it is the same boundary the store's limiter applies at
/// instantiation.
#[test]
fn the_initial_table_may_reach_the_cap_but_not_pass_it() {
let cap = u64::try_from(MAX_TABLE_ELEMENTS).expect("fits");
assert_eq!(check_initial_elements(0), Ok(()));
assert_eq!(check_initial_elements(cap), Ok(()));
let past = cap + 1;
let refusal = check_initial_elements(past).expect_err("one element past the cap");
assert_eq!(
refusal,
format!(
"initial table of {past} elements is past the {MAX_TABLE_ELEMENTS}-element cap"
)
);
}
/// The bridge logs this string and the C++ tests match on it, so the stage's
/// prefix is part of the interface rather than a debugging aid.
#[test]
fn a_refusal_names_its_stage() {
assert_eq!(
CheckError::Compile("bad magic".to_string()).to_string(),
"compile: bad magic"
);
assert_eq!(
CheckError::Memory("initial memory of 129 pages".to_string()).to_string(),
"memory: initial memory of 129 pages"
);
assert_eq!(
CheckError::Table("initial table of 1025 elements".to_string()).to_string(),
"table: initial table of 1025 elements"
);
assert_eq!(
CheckError::Import("no host function 'x'".to_string()).to_string(),
"import: no host function 'x'"
);
// The entry point's detail already says which of its three faults it is,
// so a prefix would only repeat it.
assert_eq!(
CheckError::EntryPoint("no entry point 'finish'".to_string()).to_string(),
"no entry point 'finish'"
);
}
#[test]
fn the_stages_run_in_order() {
assert!(
matches!(check(b"not wasm", "finish"), Err(CheckError::Compile(_))),
"nothing is screened until the module compiles"
);
// A module that compiles and imports nothing, so it reaches the entry point.
let empty = wat::parse_str("(module)").expect("assembles");
assert!(
matches!(check(&empty, "finish"), Err(CheckError::EntryPoint(_))),
"a module that compiles and imports nothing reaches the entry point"
);
}
}

View File

@@ -1,50 +0,0 @@
use crate::vm::MAX_FIELD_BYTES;
use core::ops::Range;
use xrpl_host_functions::{HostError, HostResult};
/// A byte region as the guest declared it: the `(ptr, len)` pair off the wire, not
/// yet checked.
///
/// Every byte parameter in this ABI is such a pair, so pairing them once at the wire
/// boundary is what keeps the helpers in `abi.rs` from each taking two loose integers
/// they could be handed in either order.
///
/// It lives in a module of its own so that the fields are out of reach and
/// [`range`](Region::range) is the *only* way to indices — the check cannot be
/// skipped, only deferred. Construction is infallible for that reason: a call whose
/// output region is malformed is then refused in the order its own helper chooses,
/// rather than at the moment the pair happened to be formed.
#[derive(Copy, Clone)]
pub(crate) struct Region {
ptr: i32,
len: i32,
}
impl Region {
pub(crate) fn new(ptr: i32, len: i32) -> Region {
Region { ptr, len }
}
/// `start..end` as indices. The conversion is the negativity check — it fails on
/// exactly the negative values — and the addition guards a 32-bit `usize`, where
/// two `i32`s can sum past the end.
pub(crate) fn range(self) -> HostResult<Range<usize>> {
let (Ok(start), Ok(len)) = (usize::try_from(self.ptr), usize::try_from(self.len)) else {
return Err(HostError::InvalidParams);
};
let end = start
.checked_add(len)
.ok_or(HostError::PointerOutOfBounds)?;
Ok(start..end)
}
/// The region's bytes, refused past the field cap. No copy: the slice aliases
/// `data`.
pub(crate) fn read(self, data: &[u8]) -> HostResult<&[u8]> {
let range = self.range()?;
if range.len() > MAX_FIELD_BYTES {
return Err(HostError::DataFieldTooLarge);
}
data.get(range).ok_or(HostError::PointerOutOfBounds)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,404 +0,0 @@
use std::cell::Cell;
use std::fmt;
use std::sync::LazyLock;
use wasmi::{
Config, Engine, Export, Linker, Memory, Module, Store, StoreLimits, StoreLimitsBuilder,
TrapCode,
};
use xrpl_host_functions::HostFunctions;
use crate::abi::{FatalHostError, Fault};
use crate::preflight::entry_point_fault;
use crate::register::register_host_functions;
/// wasm linear-memory page size, fixed by the wasm spec (64 KiB).
const WASM_PAGE_BYTES: u32 = 64 * 1024;
/// Linear-memory page cap.
pub const MAX_MEMORY_PAGES: u32 = 128;
/// [`MAX_MEMORY_PAGES`] in bytes: 8 MiB.
pub const MAX_MEMORY_BYTES: usize = (MAX_MEMORY_PAGES * WASM_PAGE_BYTES) as usize;
/// Cap on a table's element count.
///
/// A table entry is 8 bytes and wasmi materializes every one of them inside
/// `instantiate_and_start` — before the guest's first instruction, so no gas charge
/// can reach the cost. Without this cap the ceiling is the validator's, `u32::MAX`
/// entries, which a module asks for in five bytes of LEB128 and pays for in ~34 GiB.
pub const MAX_TABLE_ELEMENTS: usize = 1024;
/// Total bytes the host may write into guest memory in one [`run`], separate from
/// gas.
///
/// One direction only. What the guest passes in is not charged: it reaches the host
/// as a borrowed slice of guest memory, capped per value at [`MAX_FIELD_BYTES`] by
/// `Region::read` and in number by gas, and a host that keeps a copy (`update_data`)
/// bounds it on its own side.
pub const TRANSFER_LIMIT_BYTES: u64 = 1 << 20;
/// Size cap on any single value crossing the boundary, in either direction; over
/// it is `DataFieldTooLarge`.
///
/// A protocol limit: `kMaxWasmDataLength` in `include/xrpl/protocol/Protocol.h`.
pub const MAX_FIELD_BYTES: usize = 1024;
/// State threaded through every host call, stored in the wasmi [`Store`].
pub(crate) struct VmState<'h> {
pub(crate) host: &'h dyn HostFunctions,
/// Enforces [`store_limits`] via `Store::limiter`, which needs a `&mut` into it
/// from `&mut VmState` — hence a field rather than a local.
pub(crate) mem_limits: StoreLimits,
/// Remaining transfer budget for this run ([`TRANSFER_LIMIT_BYTES`]).
///
/// A `Cell` because it is decremented from a shared `&Caller`. One thread per
/// invocation touches the store, so the lack of `Sync` costs nothing.
///
/// TODO: the extra charge for an unaligned field copy has nothing to attach to
/// until this ABI gains a `FieldLocator` host function.
pub(crate) transfer_budget: Cell<u64>,
/// The guest's linear memory, resolved once by [`run`] after instantiation so
/// no host call pays for an export lookup.
///
/// Caching the handle is sound because a [`Memory`] is an arena index, not a
/// pointer to the bytes: it survives `memory.grow`, and `data`/`data_mut`
/// re-derive the slice per call.
///
/// The handle is scoped to one store, so this assumes **one module, one
/// instance, one store per `run`**. Module linking or nested execution would
/// have to resolve per instance: a cached handle would serve a call against the
/// wrong instance's memory, which is a wrong answer rather than an error.
pub(crate) memory: Option<Memory>,
/// Where a host writes a value before [`crate::abi::write_buffered`] copies it
/// to the guest. One buffer per run, so no call zero-fills one of its own.
///
/// Inline rather than boxed: the store's data is built once and then only
/// borrowed, so a kilobyte in it costs a move where a `Box` costs an
/// allocation. A local would cost neither, but `forbid(unsafe_code)` means a
/// stack buffer is zero-filled — per call, which is the cost this removes.
pub(crate) out_buffer: [u8; MAX_FIELD_BYTES],
}
/// Outcome of running an escrow contract to completion.
#[derive(Debug)]
pub struct RunOutcome {
/// The value returned by the exported entry point (`finish`): `> 0` means
/// allow the escrow to finish.
pub result: i32,
/// Fuel (gas) consumed by the whole invocation — guest instructions plus
/// the per-call host charges.
pub fuel_used: u64,
}
/// Why a run produced no result. Each variant is one outcome for the caller to
/// map to a TER.
#[derive(Debug)]
pub enum RunError {
/// `wasm` is not a valid module under this engine's configuration.
Compile(String),
/// The module compiled but the engine would not accept it: an import the
/// linker does not define, or an initial memory past the page cap. Not guest
/// code failing — a start section that traps is [`RunError::Trap`].
Instantiate(String),
/// No export named `function_name` with signature `() -> i32`: absent, not a
/// function, or a function of another type — which the detail tells apart.
EntryPoint(String),
/// Gas exhausted — by the guest's own instructions or by a host call's
/// charge. [`RunFailure::fuel_used`] is the whole limit.
OutOfGas,
/// The host could not serve a call.
Internal,
/// A host call had no linear memory to work in: the module exports none, or
/// the call came from a start section, which runs before there is an instance
/// to resolve the memory from.
NoMemory,
/// The guest trapped: `unreachable`, division by zero, an out-of-bounds
/// access, or `memory.grow` past the page cap. Wherever the guest was
/// executing, including a start section during instantiation.
Trap(String),
}
impl fmt::Display for RunError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RunError::Compile(detail) => write!(f, "compile: {detail}"),
RunError::Instantiate(detail) => write!(f, "instantiate: {detail}"),
// The detail says which of the entry point's failures this is, since
// "no entry point" would be wrong for an export of the wrong type.
RunError::EntryPoint(detail) => write!(f, "{detail}"),
RunError::OutOfGas => write!(f, "out of gas"),
RunError::Internal => write!(f, "internal error"),
RunError::NoMemory => write!(f, "no exported memory"),
RunError::Trap(detail) => write!(f, "trap: {detail}"),
}
}
}
/// A failed run, with the gas it still owes: a contract that traps or exhausts
/// its gas is charged for what it burned.
#[derive(Debug)]
pub struct RunFailure {
pub error: RunError,
/// Fuel consumed before the failure. The whole limit when gas ran out; `0`
/// when the module never ran.
pub fuel_used: u64,
}
impl fmt::Display for RunFailure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} (fuel used: {})", self.error, self.fuel_used)
}
}
impl RunFailure {
/// A failure with no fuel accounted: it stopped the run at or before the guest's
/// first instruction, or under a store with no meter to read.
fn owing_nothing(error: RunError) -> RunFailure {
RunFailure {
error,
fuel_used: 0,
}
}
}
/// Fuel spent out of `gas`: the one place a run's cost is measured, so success,
/// trap and refusal all report it the same way.
///
/// `Store::get_fuel` fails only on a store without fuel metering, which
/// [`build_wasm_engine`] rules out and `run`'s `set_fuel` would already have
/// caught — so a failure here is a defect in this crate. It must not become a
/// number: `0` forgives a run its whole cost, `gas` charges an untouched one for
/// everything. [`RunError::Internal`] instead.
fn fuel_used(store: &Store<VmState<'_>>, gas: u64) -> Result<u64, RunError> {
store
.get_fuel()
.map(|remaining| gas.saturating_sub(remaining))
.map_err(|_| RunError::Internal)
}
/// Report `error` with the run's cost attached. A cost that cannot be read replaces
/// the outcome rather than being invented — see [`fuel_used`].
fn failed(store: &Store<VmState<'_>>, gas: u64, error: RunError) -> RunFailure {
match fuel_used(store, gas) {
Ok(fuel_used) => RunFailure { error, fuel_used },
Err(unmetered) => RunFailure::owing_nothing(unmetered),
}
}
/// The outcome a `wasmi::Error` names for itself, if any, rather than leaving it to
/// the stage that raised it.
///
/// Two ways a run halts mid-flight: a host call that could not be served, which
/// carries a [`FatalHostError`] saying which condition it was, and the guest's own
/// instructions exhausting the meter, which wasmi raises as `OutOfFuel`.
///
/// Both can happen anywhere the guest executes — including a start section, which
/// is guest code running during instantiation — so every stage from there on asks
/// this before naming a failure after itself.
fn guest_halted(error: &wasmi::Error) -> Option<RunError> {
if let Some(fatal) = error.downcast_ref::<FatalHostError>() {
return Some(fatal.0.into());
}
(error.as_trap_code() == Some(TrapCode::OutOfFuel)).then_some(RunError::OutOfGas)
}
/// Why instantiation failed, once [`guest_halted`] has ruled out the two conditions
/// that can arise anywhere.
///
/// A start section is guest code, so it can trap on its own — `unreachable`, a
/// division by zero, an out-of-bounds access — and a trap is the guest's fault
/// wherever it happens. Naming that after the *stage* would file it beside the
/// module faults a caller treats as its own defect, and charge nothing for
/// instructions the contract burned. What is left for [`RunError::Instantiate`] is a
/// module the linker or the store would not accept at all.
fn instantiation_failure(error: &wasmi::Error) -> RunError {
match error.as_trap_code() {
Some(_) => RunError::Trap(error.to_string()),
None => RunError::Instantiate(error.to_string()),
}
}
/// The outcome a [`Fault`] is: the one place a stopped call becomes a stopped run.
///
/// Total and one arm each, because a `Fault` is only ever a condition that stops the
/// run — the guest-visible codes cannot reach here, which is what
/// [`crate::abi::CallError`] buys. A fault added later has no arm and does not
/// compile.
impl From<Fault> for RunError {
fn from(fault: Fault) -> RunError {
match fault {
Fault::OutOfGas => RunError::OutOfGas,
Fault::Internal => RunError::Internal,
Fault::NoMemory => RunError::NoMemory,
}
}
}
/// The process-wide wasmi engine, built once on first use.
///
/// The configuration is consensus-fixed and identical for every invocation, and an
/// [`Engine`] is an internally `Arc`ed `Send + Sync` handle, so one shared engine
/// serves concurrent [`run`] calls.
pub(crate) fn wasm_engine() -> &'static Engine {
static ENGINE: LazyLock<Engine> = LazyLock::new(build_wasm_engine);
&ENGINE
}
/// Build the wasmi engine the escrow VM requires: deterministic, minimal
/// features, fuel metering on.
fn build_wasm_engine() -> Engine {
let mut config = Config::default();
config.consume_fuel(true);
config.ignore_custom_sections(true);
config.wasm_mutable_global(false);
config.wasm_multi_value(false);
config.wasm_sign_extension(false);
config.wasm_saturating_float_to_int(false);
config.wasm_bulk_memory(false);
config.wasm_reference_types(false);
config.wasm_tail_call(false);
config.wasm_extended_const(false);
config.floats(false);
config.wasm_multi_memory(false);
config.wasm_custom_page_sizes(false);
config.wasm_memory64(false);
config.wasm_wide_arithmetic(false);
// TODO: enable option to reject wasm code containing start section after wasmi 2.0 release
Engine::new(&config)
}
/// Every resource ceiling a run is given, in one place.
///
/// The two *size* caps are what a contract can reach today. The three *count* caps
/// are set to 1 although [`build_wasm_engine`] already forces each: turning
/// `wasm_reference_types` on would let a module declare up to
/// `wasmparser::MAX_WASM_TABLES` tables, `wasm_multi_memory` likewise for memories,
/// and both size caps are **per table and per memory, not aggregate** — so a feature
/// flag flipped in isolation would multiply the ceiling by a hundred rather than
/// leave it be. The counts are what keeps those two decisions independent.
///
/// wasmi enforces the counts by asking the limiter before it allocates
/// (`can_create_more_instances`/`_memories`/`_tables`); they default to 10000, so
/// leaving them unset is not the same as their being unreachable.
fn store_limits() -> StoreLimits {
StoreLimitsBuilder::new()
.memory_size(MAX_MEMORY_BYTES)
.table_elements(MAX_TABLE_ELEMENTS)
.instances(1)
.tables(1)
.memories(1)
.trap_on_grow_failure(true)
.build()
}
/// Compile `wasm` for this engine.
///
/// The one path to a [`Module`]: the configuration is what decides whether a
/// contract is valid at all, so [`run`] and [`crate::check`] must not be able to
/// compile against different ones.
pub(crate) fn compile(wasm: &[u8]) -> Result<Module, String> {
Module::new(wasm_engine(), wasm).map_err(|e| e.to_string())
}
/// Run a contract: compile `wasm`, give it `gas` fuel, service its host
/// calls through `host`, and call the exported `function_name`.
pub fn run<'h>(
wasm: &[u8],
gas: u64,
host: &'h dyn HostFunctions,
function_name: &str,
) -> Result<RunOutcome, RunFailure> {
let engine = wasm_engine();
let module =
compile(wasm).map_err(|detail| RunFailure::owing_nothing(RunError::Compile(detail)))?;
let mut store = Store::new(
engine,
VmState {
host,
mem_limits: store_limits(),
transfer_budget: Cell::new(TRANSFER_LIMIT_BYTES),
memory: None,
out_buffer: [0u8; MAX_FIELD_BYTES],
},
);
store
.set_fuel(gas)
.map_err(|_| RunFailure::owing_nothing(RunError::Internal))?;
store.limiter(|state| &mut state.mem_limits);
let mut linker = Linker::<VmState<'h>>::new(engine);
register_host_functions(&mut linker)
.map_err(|_| RunFailure::owing_nothing(RunError::Internal))?;
let instance = match linker.instantiate_and_start(&mut store, &module) {
Ok(instance) => instance,
Err(e) => {
let error = guest_halted(&e).unwrap_or_else(|| instantiation_failure(&e));
return Err(failed(&store, gas, error));
}
};
store.data_mut().memory = instance.exports(&store).find_map(Export::into_memory);
let function = match instance.get_typed_func::<(), i32>(&store, function_name) {
Ok(function) => function,
Err(e) => {
let found = instance
.get_export(&store, function_name)
.map(|export| export.ty(&store));
let error =
RunError::EntryPoint(format!("{}: {e}", entry_point_fault(found, function_name)));
return Err(failed(&store, gas, error));
}
};
let result = match function.call(&mut store, ()) {
Ok(result) => result,
Err(e) => {
let error = guest_halted(&e).unwrap_or_else(|| RunError::Trap(e.to_string()));
return Err(failed(&store, gas, error));
}
};
let fuel_used = fuel_used(&store, gas).map_err(RunFailure::owing_nothing)?;
Ok(RunOutcome { result, fuel_used })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_engine_is_one_engine() {
assert!(Engine::same(wasm_engine(), wasm_engine()));
}
/// One instance, one table, one memory — asserted here rather than through a
/// module, because no module can reach these. `wasm_reference_types(false)` and
/// `wasm_multi_memory(false)` make a module declaring a second table or memory
/// fail *validation*, so a run never gets far enough to consult the limiter.
/// That is exactly why the counts are worth pinning: they are the ceiling that
/// survives one of those flags being turned on, and nothing else would fail if
/// they were silently dropped.
#[test]
fn the_store_grants_one_of_each_thing_a_module_can_own() {
use wasmi::ResourceLimiter;
let limits = store_limits();
assert_eq!(limits.instances(), 1);
assert_eq!(limits.tables(), 1);
assert_eq!(limits.memories(), 1);
}
/// The only place these numbers appear as literals; every other test derives
/// them from the constants.
#[test]
fn the_limits_are_the_protocol_limits() {
assert_eq!(MAX_MEMORY_PAGES, 128, "linear-memory page cap");
assert_eq!(MAX_MEMORY_BYTES, 8 * 1024 * 1024, "page cap in bytes");
assert_eq!(MAX_TABLE_ELEMENTS, 1024, "table-element cap");
assert_eq!(MAX_FIELD_BYTES, 1024, "kMaxWasmDataLength");
assert_eq!(TRANSFER_LIMIT_BYTES, 1 << 20, "kWasmTransferLimit");
}
}

View File

@@ -1,927 +0,0 @@
//! The two budgets a run spends: gas (fuel), and the transfer limit on bytes
//! crossing the boundary. Both are consensus input, so several of these tests
//! assert exact numbers.
mod support;
use support::{
Answer, EMPTY_REGION, FakeHost, ONE_PAGE, PLENTY_OF_GAS, code, import, module, run,
run_with_gas, trace_call,
};
use xrpl_host_functions::{HASH_LEN, HostError, HostFunctionSpec, TraceDataType};
use xrpl_wasm_vm::{MAX_FIELD_BYTES, RunError, TRANSFER_LIMIT_BYTES};
// ---------------------------------------------------------------------------
// Gas
// ---------------------------------------------------------------------------
/// The fuel a module of `body` burns, given gas to spare.
fn fuel_for(body: &str, parts: &[&str], host: &FakeHost) -> u64 {
let wat = module(parts, body);
run(&wat, host).expect("the module should run").fuel_used
}
/// The fuel a module burns doing nothing but returning a constant; every figure
/// below builds on it. wasmi's number, pinned deliberately because wasmi's fuel
/// table is consensus input.
const EMPTY_MODULE_FUEL: u64 = 30;
/// wasmi's own fuel for a host call whose operands are all constants under 64: 14
/// per `*.const`, plus 1 for the call. Our gas sits on top.
///
/// The formula holds only under 64, because wasmi widens a constant's encoding
/// above that, each tier costing 7 more. Every call in [`call_for`] keeps its
/// operands small for that reason; one with a larger constant fails here by a
/// multiple of 7.
fn wasmi_call_fuel(small_const_operands: u64) -> u64 {
14 * small_const_operands + 1
}
/// What wasmi charges on top of that for a call to a function with no result —
/// `trace`'s shape, and nothing else in the ABI. Per call, not per module. Measured
/// and pinned like the figures above.
const WASMI_NO_RESULT_FUEL: u64 = 14;
/// wasmi's fuel for one `(drop …)`, which is how a module makes more than one call
/// and keeps only the last result. Pinned like the two above.
const WASMI_DROP_FUEL: u64 = 21;
/// The wasm a test needs in order to call one host function: the `(import …)`
/// declaration, a call with small-constant operands, and how many it pushes.
struct Call {
import: &'static str,
call: &'static str,
operands: u64,
/// Whether the call leaves an `i32` behind. `trace` does not, which is why
/// [`Call::body`] ends every module with a constant instead of the call.
yields: bool,
}
impl Call {
/// `n` calls in a row, leaving one `i32` for the module to return: the last
/// answer where there is one, and a constant where the call has none.
fn body(&self, n: usize) -> String {
if self.yields {
format!(
"{}{}",
format!("(drop {}) ", self.call).repeat(n - 1),
self.call
)
} else {
format!("{}(i32.const 0)", format!("{} ", self.call).repeat(n))
}
}
/// What [`Call::body`] burns beside the calls' own gas and the module's floor:
/// one `drop` between consecutive answers, or wasmi's own surcharge on a call
/// that has none.
fn overhead(&self, n: u64) -> u64 {
if self.yields {
(n - 1) * WASMI_DROP_FUEL
} else {
n * WASMI_NO_RESULT_FUEL
}
}
}
/// The test wasm for each host function. The `match` is exhaustive, so a function
/// added to the ABI fails to compile until it has wasm here, and iterating
/// [`HostFunctionSpec::ALL`] then covers the whole ABI.
fn call_for(op: HostFunctionSpec) -> Call {
let (import, call, operands) = match op {
HostFunctionSpec::GetLedgerSqn => (
import::LDGR_INDEX,
"(call $ldgr_index (i32.const 0) (i32.const 4))",
2,
),
HostFunctionSpec::GetParentLedgerTime => (
import::PARENT_LDGR_TIME,
"(call $parent_ldgr_time (i32.const 0) (i32.const 4))",
2,
),
HostFunctionSpec::GetParentLedgerHash => (
import::PARENT_LDGR_HASH,
"(call $parent_ldgr_hash (i32.const 0) (i32.const 32))",
2,
),
HostFunctionSpec::GetBaseFee => (
import::BASE_FEE,
"(call $base_fee (i32.const 0) (i32.const 4))",
2,
),
HostFunctionSpec::IsAmendmentEnabled => (
import::AMENDMENT_ENABLED,
"(call $amendment_enabled (i32.const 0) (i32.const 32))",
2,
),
HostFunctionSpec::CacheLedgerObj => (
import::CACHE_LE,
"(call $cache_le (i32.const 0) (i32.const 32) (i32.const 0))",
3,
),
HostFunctionSpec::GetTxField => (
import::TX_FIELD,
"(call $tx_field (i32.const 1) (i32.const 0) (i32.const 4))",
3,
),
HostFunctionSpec::GetCurrentLedgerObjField => (
import::HOME_LE_FIELD,
"(call $home_le_field (i32.const 1) (i32.const 0) (i32.const 4))",
3,
),
HostFunctionSpec::GetLedgerObjField => (
import::LE_FIELD,
"(call $le_field (i32.const 1) (i32.const 1) (i32.const 0) (i32.const 4))",
4,
),
HostFunctionSpec::GetTxNestedField => (
import::TX_INNER,
"(call $tx_inner (i32.const 0) (i32.const 4) (i32.const 8) (i32.const 4))",
4,
),
HostFunctionSpec::GetCurrentLedgerObjNestedField => (
import::HOME_LE_INNER,
"(call $home_le_inner (i32.const 0) (i32.const 4) (i32.const 8) (i32.const 4))",
4,
),
HostFunctionSpec::GetLedgerObjNestedField => (
import::LE_INNER,
"(call $le_inner (i32.const 1) (i32.const 0) (i32.const 4) (i32.const 8) (i32.const 4))",
5,
),
HostFunctionSpec::GetTxArrayLen => {
(import::TX_ARR_LEN, "(call $tx_arr_len (i32.const 1))", 1)
}
HostFunctionSpec::GetCurrentLedgerObjArrayLen => (
import::HOME_LE_ARR_LEN,
"(call $home_le_arr_len (i32.const 1))",
1,
),
HostFunctionSpec::GetLedgerObjArrayLen => (
import::LE_ARR_LEN,
"(call $le_arr_len (i32.const 1) (i32.const 1))",
2,
),
HostFunctionSpec::GetTxNestedArrayLen => (
import::TX_INNER_ARR_LEN,
"(call $tx_inner_arr_len (i32.const 0) (i32.const 4))",
2,
),
HostFunctionSpec::GetCurrentLedgerObjNestedArrayLen => (
import::HOME_LE_INNER_ARR_LEN,
"(call $home_le_inner_arr_len (i32.const 0) (i32.const 4))",
2,
),
HostFunctionSpec::GetLedgerObjNestedArrayLen => (
import::LE_INNER_ARR_LEN,
"(call $le_inner_arr_len (i32.const 1) (i32.const 0) (i32.const 4))",
3,
),
HostFunctionSpec::CheckSignature => (
import::CHECK_SIG,
"(call $check_sig (i32.const 0) (i32.const 0) (i32.const 0) (i32.const 0) (i32.const 0) (i32.const 0))",
6,
),
HostFunctionSpec::AccountKeylet => (
import::ACCOUNTROOT_ID,
"(call $accountroot_id (i32.const 0) (i32.const 20) (i32.const 32) (i32.const 32))",
4,
),
HostFunctionSpec::AmmKeylet => (
import::AMM_ID,
"(call $amm_id (i32.const 0) (i32.const 20) (i32.const 24) (i32.const 40) (i32.const 0) (i32.const 32))",
6,
),
HostFunctionSpec::CheckKeylet => (
import::CHECK_ID,
"(call $check_id (i32.const 0) (i32.const 20) (i32.const 0) (i32.const 4) (i32.const 32) (i32.const 32))",
6,
),
HostFunctionSpec::CredentialKeylet => (
import::CREDENTIAL_ID,
"(call $credential_id (i32.const 0) (i32.const 20) (i32.const 20) (i32.const 20) (i32.const 40) (i32.const 4) (i32.const 44) (i32.const 20))",
8,
),
HostFunctionSpec::DelegateKeylet => (
import::DELEGATE_ID,
"(call $delegate_id (i32.const 0) (i32.const 20) (i32.const 20) (i32.const 20) (i32.const 40) (i32.const 32))",
6,
),
HostFunctionSpec::DepositPreauthKeylet => (
import::DEPOSIT_PREAUTH_ID,
"(call $deposit_preauth_id (i32.const 0) (i32.const 20) (i32.const 20) (i32.const 20) (i32.const 40) (i32.const 32))",
6,
),
HostFunctionSpec::DidKeylet => (
import::DID_ID,
"(call $did_id (i32.const 0) (i32.const 20) (i32.const 32) (i32.const 32))",
4,
),
HostFunctionSpec::EscrowKeylet => (
import::ESCROW_ID,
"(call $escrow_id (i32.const 0) (i32.const 20) (i32.const 0) (i32.const 4) (i32.const 32) (i32.const 32))",
6,
),
HostFunctionSpec::TrustLineKeylet => (
import::TRUSTLINE_ID,
"(call $trustline_id (i32.const 0) (i32.const 20) (i32.const 20) (i32.const 20) (i32.const 40) (i32.const 20) (i32.const 60) (i32.const 32))",
8,
),
HostFunctionSpec::MptokenIssuanceKeylet => (
import::MPT_ISSUANCE_ID,
"(call $mpt_issuance_id (i32.const 0) (i32.const 20) (i32.const 0) (i32.const 4) (i32.const 32) (i32.const 32))",
6,
),
HostFunctionSpec::MptokenKeylet => (
import::MPTOKEN_ID,
"(call $mptoken_id (i32.const 0) (i32.const 24) (i32.const 24) (i32.const 20) (i32.const 44) (i32.const 20))",
6,
),
HostFunctionSpec::NftokenOfferKeylet => (
import::NFT_OFFER_ID,
"(call $nft_offer_id (i32.const 0) (i32.const 20) (i32.const 0) (i32.const 4) (i32.const 32) (i32.const 32))",
6,
),
HostFunctionSpec::OfferKeylet => (
import::OFFER_ID,
"(call $offer_id (i32.const 0) (i32.const 20) (i32.const 0) (i32.const 4) (i32.const 32) (i32.const 32))",
6,
),
HostFunctionSpec::OracleKeylet => (
import::ORACLE_ID,
"(call $oracle_id (i32.const 0) (i32.const 20) (i32.const 0) (i32.const 4) (i32.const 32) (i32.const 32))",
6,
),
HostFunctionSpec::PaychannelKeylet => (
import::PAYCHAN_ID,
"(call $paychan_id (i32.const 0) (i32.const 20) (i32.const 20) (i32.const 20) (i32.const 0) (i32.const 4) (i32.const 40) (i32.const 20))",
8,
),
HostFunctionSpec::PermissionedDomainKeylet => (
import::PERMISSIONED_DOMAIN_ID,
"(call $permissioned_domain_id (i32.const 0) (i32.const 20) (i32.const 0) (i32.const 4) (i32.const 32) (i32.const 32))",
6,
),
HostFunctionSpec::SignerListKeylet => (
import::SIGNERS_ID,
"(call $signers_id (i32.const 0) (i32.const 20) (i32.const 32) (i32.const 32))",
4,
),
HostFunctionSpec::TicketKeylet => (
import::TICKET_ID,
"(call $ticket_id (i32.const 0) (i32.const 20) (i32.const 0) (i32.const 4) (i32.const 32) (i32.const 32))",
6,
),
HostFunctionSpec::VaultKeylet => (
import::VAULT_ID,
"(call $vault_id (i32.const 0) (i32.const 20) (i32.const 0) (i32.const 4) (i32.const 32) (i32.const 32))",
6,
),
HostFunctionSpec::Sha512Half => (
import::SHA512_HALF,
"(call $sha512_half (i32.const 0) (i32.const 4) (i32.const 0) (i32.const 32))",
4,
),
HostFunctionSpec::Trace => (
import::TRACE,
"(call $trace (i32.const 0) (i32.const 0) (i32.const 1) (i32.const 0) (i32.const 0))",
5,
),
HostFunctionSpec::UpdateData => (
import::SET_DATA,
"(call $set_data (i32.const 0) (i32.const 8))",
2,
),
HostFunctionSpec::GetNft => (
import::NFT_URI,
"(call $nft_uri (i32.const 0) (i32.const 20) (i32.const 20) (i32.const 32) (i32.const 52) (i32.const 12))",
6,
),
HostFunctionSpec::GetNftIssuer => (
import::NFT_ISSUER,
"(call $nft_issuer (i32.const 0) (i32.const 32) (i32.const 32) (i32.const 20))",
4,
),
HostFunctionSpec::GetNftTaxon => (
import::NFT_TAXON,
"(call $nft_taxon (i32.const 0) (i32.const 32) (i32.const 32) (i32.const 4))",
4,
),
HostFunctionSpec::GetNftFlags => (
import::NFT_FLAGS,
"(call $nft_flags (i32.const 0) (i32.const 32))",
2,
),
HostFunctionSpec::GetNftTransferFee => (
import::NFT_XFER_FEE,
"(call $nft_xfer_fee (i32.const 0) (i32.const 32))",
2,
),
HostFunctionSpec::GetNftSequence => (
import::NFT_SERIAL,
"(call $nft_serial (i32.const 0) (i32.const 32) (i32.const 32) (i32.const 4))",
4,
),
HostFunctionSpec::FloatFromInt => (
import::FLOAT_FROM_INT,
"(call $float_from_int (i64.const 0) (i32.const 0) (i32.const 8) (i32.const 0))",
4,
),
HostFunctionSpec::FloatFromUint => (
import::FLOAT_FROM_UINT,
"(call $float_from_uint (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 8) (i32.const 0))",
5,
),
HostFunctionSpec::FloatFromStamount => (
import::FLOAT_FROM_STAMOUNT,
"(call $float_from_stamount (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 8) (i32.const 0))",
5,
),
HostFunctionSpec::FloatFromStnumber => (
import::FLOAT_FROM_STNUMBER,
"(call $float_from_stnumber (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 8) (i32.const 0))",
5,
),
HostFunctionSpec::FloatToInt => (
import::FLOAT_TO_INT,
"(call $float_to_int (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 8) (i32.const 0))",
5,
),
HostFunctionSpec::FloatToMantExp => (
import::FLOAT_TO_MANT_EXP,
"(call $float_to_mant_exp (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 8) (i32.const 16) (i32.const 4))",
6,
),
HostFunctionSpec::FloatFromMantExp => (
import::FLOAT_FROM_MANT_EXP,
"(call $float_from_mant_exp (i64.const 0) (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 0))",
5,
),
HostFunctionSpec::FloatCompare => (
import::FLOAT_CMP,
"(call $float_cmp (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 8))",
4,
),
HostFunctionSpec::FloatAdd => (
import::FLOAT_ADD,
"(call $float_add (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 8) (i32.const 16) (i32.const 8) (i32.const 0))",
7,
),
HostFunctionSpec::FloatSubtract => (
import::FLOAT_SUB,
"(call $float_sub (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 8) (i32.const 16) (i32.const 8) (i32.const 0))",
7,
),
HostFunctionSpec::FloatMultiply => (
import::FLOAT_MULT,
"(call $float_mult (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 8) (i32.const 16) (i32.const 8) (i32.const 0))",
7,
),
HostFunctionSpec::FloatDivide => (
import::FLOAT_DIV,
"(call $float_div (i32.const 0) (i32.const 8) (i32.const 8) (i32.const 8) (i32.const 16) (i32.const 8) (i32.const 0))",
7,
),
HostFunctionSpec::FloatRoot => (
import::FLOAT_ROOT,
"(call $float_root (i32.const 0) (i32.const 8) (i32.const 2) (i32.const 8) (i32.const 8) (i32.const 0))",
6,
),
HostFunctionSpec::FloatPower => (
import::FLOAT_POW,
"(call $float_pow (i32.const 0) (i32.const 8) (i32.const 2) (i32.const 8) (i32.const 8) (i32.const 0))",
6,
),
};
Call {
import,
call,
operands,
yields: !matches!(op, HostFunctionSpec::Trace),
}
}
#[test]
fn an_empty_module_burns_a_fixed_amount_of_fuel() {
let fuel = fuel_for("(i32.const 0)", &[ONE_PAGE], &FakeHost::new());
assert_eq!(fuel, EMPTY_MODULE_FUEL);
}
/// Calling a host function `n` times costs `n` times its gas, to the unit. Every
/// other term is known — the module's floor, wasmi's fuel per call, one `drop` per
/// answered call — so the total is a closed form, with the gas read from the spec
/// table rather than restated. `n = 1` pins the charge, `n > 1` pins that it lands
/// on every call rather than once per run.
#[test]
fn a_host_call_costs_its_gas_every_time_it_is_called() {
let host = FakeHost::new().answering_field(1, Answer::bytes([0xaa]));
for &op in HostFunctionSpec::ALL {
let call = call_for(op);
let per_call = wasmi_call_fuel(call.operands) + op.gas();
for n in 1..=3 {
let body = call.body(n);
let n = n as u64;
assert_eq!(
fuel_for(&body, &[call.import, ONE_PAGE], &host),
EMPTY_MODULE_FUEL + n * per_call + call.overhead(n),
"{n} x {}",
call.call
);
}
}
}
/// The gas charge precedes the call's body, so a failing call costs exactly what a
/// successful one costs. Field 1 is answered and field 7 is not; the two modules
/// are otherwise identical, so their totals are comparable.
#[test]
fn a_failing_host_call_costs_exactly_what_a_successful_one_costs() {
let host = FakeHost::new().answering_field(1, Answer::bytes([0xaa]));
let call = |field: i32| {
module(
&[import::HOME_LE_FIELD, ONE_PAGE],
&format!("(call $home_le_field (i32.const {field}) (i32.const 0) (i32.const 4))"),
)
};
let answered = run(&call(1), &host).expect("the module should run");
let refused = run(&call(7), &host).expect("the module should run");
assert_eq!(answered.result, 1);
assert_eq!(refused.result, code(HostError::FieldNotFound));
assert_eq!(refused.fuel_used, answered.fuel_used);
}
/// `fuel_used` is `gas - remaining`: what the run spent, not what was left or what
/// it was handed. The gas figures are derived from the run's cost, so the boundary
/// — exactly enough, and one short — is among the cases.
#[test]
fn fuel_used_is_what_was_spent_not_what_was_supplied() {
let host = FakeHost::new();
let op = HostFunctionSpec::GetLedgerSqn;
let call = call_for(op);
let wat = module(&[call.import, ONE_PAGE], call.call);
let cost = EMPTY_MODULE_FUEL + wasmi_call_fuel(call.operands) + op.gas();
// Exactly its cost is enough, and no amount above it changes the figure. The
// result is checked too, so the figure belongs to a run that did the work
// rather than to one that was cut short.
for gas in [cost, cost + 1, cost * 100, PLENTY_OF_GAS] {
let outcome = run_with_gas(&wat, gas, &host).expect("should run");
assert_eq!(
outcome.result, 4,
"gas {gas}: the call should have succeeded"
);
assert_eq!(outcome.fuel_used, cost, "gas {gas}");
}
// One fuel short: the run ends at the call it cannot pay for and still owes the
// whole limit, because `charge` spends what is left.
let short = run_with_gas(&wat, cost - 1, &host).expect_err("one fuel short must not complete");
assert!(
matches!(short.error, RunError::OutOfGas),
"expected the run to end out of gas, got: {short}"
);
assert_eq!(short.fuel_used, cost - 1);
}
/// Fuel is metered, so the same module burns the same fuel every time — a
/// property consensus depends on.
#[test]
fn the_same_run_burns_the_same_fuel() {
let call = call_for(HostFunctionSpec::Trace);
let wat = module(&[call.import, ONE_PAGE], &call.body(1));
let first = run(&wat, &FakeHost::new()).expect("should run").fuel_used;
for _ in 0..4 {
assert_eq!(
run(&wat, &FakeHost::new()).expect("should run").fuel_used,
first
);
}
assert!(first > HostFunctionSpec::Trace.gas());
}
/// Too little gas to finish stops the run: the meter refuses the guest's own
/// instructions before it ever reaches the host call.
#[test]
fn a_run_that_cannot_afford_itself_fails() {
let host = FakeHost::new();
let wat = module(
&[import::LDGR_INDEX, ONE_PAGE],
"(call $ldgr_index (i32.const 0) (i32.const 4))",
);
for gas in [0, 1, 10] {
let Err(failure) = run_with_gas(&wat, gas, &host) else {
panic!("gas {gas} should not have completed");
};
assert!(
matches!(failure.error, RunError::OutOfGas),
"gas {gas}: expected the run to end out of gas, got: {failure}"
);
}
}
/// A guest looping forever is stopped by gas rather than running away, and owes
/// the gas it burned doing it.
#[test]
fn an_endless_loop_is_stopped_by_gas() {
const GAS: u64 = 100_000;
let host = FakeHost::new();
let wat = module(&[ONE_PAGE], "(loop $l (br $l)) (i32.const 0)");
let failure = run_with_gas(&wat, GAS, &host).expect_err("an endless loop must not complete");
assert!(
matches!(failure.error, RunError::OutOfGas),
"expected the meter to stop it, got: {failure}"
);
assert_eq!(
failure.fuel_used, GAS,
"a runaway guest burns the whole limit"
);
}
/// A host call refused its gas stops the run: the guest never gets a chance to
/// ignore the refusal and carry on, and it is charged the whole limit.
///
/// The gas range is every amount that reaches the call and cannot pay for it, so
/// the case is the whole boundary rather than one number. `trace` is the call under
/// it because it is the one that could not report a refusal even if it wanted to:
/// stopping the run is the whole of what the guest sees.
#[test]
fn a_host_call_refused_its_gas_stops_the_run() {
let host = FakeHost::new();
let op = HostFunctionSpec::Trace;
let call = call_for(op);
let wat = module(&[call.import, ONE_PAGE], &call.body(1));
// Measured rather than derived: the whole run's cost, less the call's own gas,
// is the least a guest can be given and still reach the call. Below that the
// meter stops the guest's own instructions instead, which is
// `a_run_that_cannot_afford_itself_fails`'s case, not this one.
let cost = run(&wat, &FakeHost::new())
.expect("the module should run")
.fuel_used;
for gas in cost - op.gas()..cost {
let Err(failure) = run_with_gas(&wat, gas, &host) else {
panic!("gas {gas}: the run completed, so the guest was handed the refusal");
};
assert!(
matches!(failure.error, RunError::OutOfGas),
"gas {gas}: expected the run to end out of gas, got: {failure}"
);
assert_eq!(
failure.fuel_used, gas,
"gas {gas}: a call it cannot afford burns the whole limit"
);
}
assert!(host.traces().is_empty(), "the host body must not have run");
}
// ---------------------------------------------------------------------------
// The transfer limit
// ---------------------------------------------------------------------------
/// A module that repeats `call` while `keep_going` holds, then returns the last
/// status, so a budget can be run to exhaustion inside one invocation.
fn until_refused(imports: &str, call: &str, keep_going: &str) -> String {
module(
&[imports, ONE_PAGE],
&format!(
"(local $r i32)
(loop $l
(local.set $r {call})
(br_if $l {keep_going}))
(local.get $r)"
),
)
}
/// For a call whose success is a positive byte count.
const WHILE_POSITIVE: &str = "(i32.gt_s (local.get $r) (i32.const 0))";
/// Bytes written into guest memory are charged against the run's budget, and the
/// budget is a per-run total: 1 MiB of 1 KiB values exhausts it.
#[test]
fn writes_spend_the_transfer_budget() {
let host = FakeHost::new().answering_field(1, Answer::filler(MAX_FIELD_BYTES));
let wat = until_refused(
import::HOME_LE_FIELD,
&format!("(call $home_le_field (i32.const 1) (i32.const 0) (i32.const {MAX_FIELD_BYTES}))"),
WHILE_POSITIVE,
);
let outcome = run(&wat, &host).expect("the module should run");
assert_eq!(outcome.result, code(HostError::OutOfTransferLimit));
assert_eq!(
host.fields_asked.borrow().len() as u64,
TRANSFER_LIMIT_BYTES / MAX_FIELD_BYTES as u64 + 1,
"one call per 1 KiB of budget, plus the one that was refused"
);
}
/// The budget is per run, not per call: a fresh run starts with a full budget.
#[test]
fn each_run_gets_its_own_budget() {
let wat = until_refused(
import::HOME_LE_FIELD,
&format!("(call $home_le_field (i32.const 1) (i32.const 0) (i32.const {MAX_FIELD_BYTES}))"),
WHILE_POSITIVE,
);
for _ in 0..2 {
let host = FakeHost::new().answering_field(1, Answer::filler(MAX_FIELD_BYTES));
let outcome = run(&wat, &host).expect("the module should run");
assert_eq!(outcome.result, code(HostError::OutOfTransferLimit));
assert_eq!(
host.fields_asked.borrow().len() as u64,
TRANSFER_LIMIT_BYTES / MAX_FIELD_BYTES as u64 + 1
);
}
}
/// A run well inside the budget never sees it.
#[test]
fn a_modest_run_never_meets_the_budget() {
let host = FakeHost::new().answering_field(1, Answer::filler(MAX_FIELD_BYTES));
let wat = module(
&[import::HOME_LE_FIELD, ONE_PAGE],
&format!("(call $home_le_field (i32.const 1) (i32.const 0) (i32.const {MAX_FIELD_BYTES}))"),
);
let outcome = run(&wat, &host).expect("the module should run");
assert_eq!(outcome.result, MAX_FIELD_BYTES as i32);
}
/// A write the budget refuses is a write that did not happen. `float_to_mant_exp` is
/// the case worth pinning: its two regions are charged as one, so a call that cannot
/// pay for both must leave both alone rather than place the mantissa and refuse.
#[test]
fn a_write_the_budget_refuses_reaches_guest_memory_in_no_part() {
let host = FakeHost::new()
.answering_field(1, Answer::filler(MAX_FIELD_BYTES))
.answering_float_mant_exp(vec![1, 2, 3, 4, 5, 6, 7, 8], vec![9, 10, 11, 12]);
// Spend the budget on 1 KiB fields at offset 0, then ask for a mantissa and an
// exponent at offsets well clear of them.
let call = "(call $float_to_mant_exp (i32.const 0) (i32.const 8) (i32.const 2048) (i32.const 8) (i32.const 2064) (i32.const 4))";
let spent = |tail: &str| {
module(
&[import::HOME_LE_FIELD, import::FLOAT_TO_MANT_EXP, ONE_PAGE],
&format!(
"(local $r i32)
(loop $l
(local.set $r (call $home_le_field (i32.const 1) (i32.const 0) (i32.const {MAX_FIELD_BYTES})))
(br_if $l {WHILE_POSITIVE}))
{tail}"
),
)
};
let refused = run(&spent(call), &host).expect("the module should run");
assert_eq!(refused.result, code(HostError::OutOfTransferLimit));
let wat = spent(&format!(
"(drop {call})
(i32.or (i32.load8_u (i32.const 2048)) (i32.load8_u (i32.const 2064)))"
));
let outcome = run(&wat, &host).expect("the module should run");
assert_eq!(outcome.result, 0, "neither region should be written");
}
/// The same rule on the path that writes straight into guest memory: `write_into`
/// hands the host a slice *of the guest's own buffer*, so a value the budget cannot
/// pay for has to be kept out of that slice before the host fills it.
///
/// The probe region is one the spending loop never writes to, so anything found there
/// came from the refused call.
#[test]
fn a_straight_write_the_budget_refuses_reaches_guest_memory_in_no_part() {
/// Clear of the offset the spending loop writes to.
const PROBE: usize = 2048;
/// Every byte of the value, so the fold sees a prefix as readily as the whole.
const MARK: u8 = 0xff;
let host = FakeHost::new().answering_field(1, Answer::bytes(vec![MARK; MAX_FIELD_BYTES]));
let call = format!(
"(call $home_le_field (i32.const 1) (i32.const {PROBE}) (i32.const {MAX_FIELD_BYTES}))"
);
// Every local the tails below use is declared here: wasm wants them all ahead of
// the first instruction.
let spent = |tail: &str| {
module(
&[import::HOME_LE_FIELD, ONE_PAGE],
&format!(
"(local $r i32) (local $i i32) (local $seen i32)
(loop $l
(local.set $r (call $home_le_field (i32.const 1) (i32.const 0) (i32.const {MAX_FIELD_BYTES})))
(br_if $l {WHILE_POSITIVE}))
{tail}"
),
)
};
let refused = run(&spent(&call), &host).expect("the module should run");
assert_eq!(refused.result, code(HostError::OutOfTransferLimit));
// Guest memory starts zero-filled, so or-ing the region together reports whether
// any byte of it was written.
let wat = spent(&format!(
"(drop {call})
(loop $l
(local.set $seen (i32.or (local.get $seen)
(i32.load8_u (i32.add (i32.const {PROBE}) (local.get $i)))))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br_if $l (i32.lt_u (local.get $i) (i32.const {MAX_FIELD_BYTES}))))
(local.get $seen)"
));
let outcome = run(&wat, &host).expect("the module should run");
assert_eq!(outcome.result, 0, "not one byte should have been written");
}
/// What a write may deliver is what is *left* of the budget, to the byte.
///
/// The prologue spends all but `LEFT`, and field 3's host answers with as much as it
/// is offered — so the window `write_into` opened is what it reports and what it
/// leaves in guest memory, and both are read off as `LEFT`. A mark is a 1, so the
/// fold over the probe's whole buffer counts the bytes that reached it.
///
/// `LEFT` is under [`MAX_FIELD_BYTES`] and the buffer is wider than both probes'
/// values, so it is the budget answering and neither the field cap nor the guest's
/// capacity. Field 4 is the byte past it: a host whose value is one larger than what
/// is left, which no window can hold.
#[test]
fn a_write_may_deliver_what_is_left_of_the_budget_and_not_a_byte_more() {
/// Full-cap writes, all the prologue can make without overshooting.
const BULK: u64 = TRANSFER_LIMIT_BYTES / MAX_FIELD_BYTES as u64 - 1;
/// What the prologue leaves unspent.
const LEFT: usize = MAX_FIELD_BYTES / 2;
/// The write that trims what [`BULK`] leaves down to [`LEFT`].
const TRIM: usize = MAX_FIELD_BYTES - LEFT;
/// Clear of the offset the prologue writes to.
const PROBE: usize = 2048;
const BUFFER: usize = MAX_FIELD_BYTES;
/// One per byte written, so the fold below sums to how many there were.
const MARK: u8 = 1;
assert_eq!(
BULK * MAX_FIELD_BYTES as u64 + TRIM as u64 + LEFT as u64,
TRANSFER_LIMIT_BYTES,
"the prologue must spend all but LEFT of the budget"
);
let host = FakeHost::new()
.answering_field(1, Answer::filler(MAX_FIELD_BYTES))
.answering_field(2, Answer::filler(TRIM))
.answering_field(3, Answer::as_much_as_offered(MARK))
.answering_field(4, Answer::claiming(LEFT + 1));
let probe = |field: i32| {
format!(
"(call $home_le_field (i32.const {field}) (i32.const {PROBE}) (i32.const {BUFFER}))"
)
};
// Every local the tails use, declared where wasm wants them.
let after_prologue = |tail: String| {
let wat = module(
&[import::HOME_LE_FIELD, ONE_PAGE],
&format!(
"(local $i i32) (local $marks i32)
(loop $l
(drop (call $home_le_field (i32.const 1) (i32.const 0) (i32.const {MAX_FIELD_BYTES})))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br_if $l (i32.lt_u (local.get $i) (i32.const {BULK}))))
(drop (call $home_le_field (i32.const 2) (i32.const 0) (i32.const {TRIM})))
(local.set $i (i32.const 0))
{tail}"
),
);
run(&wat, &host).expect("the module should run").result
};
assert_eq!(
after_prologue(probe(3)),
LEFT as i32,
"the host should be offered exactly what is left"
);
// Guest memory starts zero-filled, so summing the probe's whole buffer counts the
// marks in it.
assert_eq!(
after_prologue(format!(
"(drop {})
(loop $l
(local.set $marks (i32.add (local.get $marks)
(i32.load8_u (i32.add (i32.const {PROBE}) (local.get $i)))))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br_if $l (i32.lt_u (local.get $i) (i32.const {BUFFER}))))
(local.get $marks)",
probe(3)
)),
LEFT as i32,
"and that many marks, no more, should reach guest memory"
);
assert_eq!(
after_prologue(probe(4)),
code(HostError::OutOfTransferLimit),
"a value one byte past what is left fits no window"
);
}
/// Reads leave the budget alone: `read_borrowed` hands the host a slice *aliasing*
/// guest memory, so there are no copied bytes to charge. What bounds how many reads
/// a run can make is gas, which every host call pays before its body runs.
///
/// The observation is the write at the end, not the reads: the module reads four
/// times the whole budget first, so a rule that charged reads would have nothing
/// left, and the write would answer `OutOfTransferLimit` instead of a byte count.
#[test]
fn reads_do_not_spend_the_transfer_budget() {
/// 1 KiB reads, four times over the budget.
const READS: u64 = 4 * TRANSFER_LIMIT_BYTES / MAX_FIELD_BYTES as u64;
let host = FakeHost::new().answering_field(1, Answer::filler(MAX_FIELD_BYTES));
let read = trace_call(
TraceDataType::AsHex,
EMPTY_REGION,
&format!("(i32.const 0) (i32.const {MAX_FIELD_BYTES})"),
);
let wat = module(
&[import::TRACE, import::HOME_LE_FIELD, ONE_PAGE],
&format!(
"(local $i i32)
(loop $l
{read}
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br_if $l (i32.lt_u (local.get $i) (i32.const {READS}))))
(call $home_le_field (i32.const 1) (i32.const 0) (i32.const {MAX_FIELD_BYTES}))"
),
);
let outcome = run(&wat, &host).expect("the module should run");
assert_eq!(
host.traces().len() as u64,
READS,
"every read should have been served"
);
assert_eq!(
outcome.result, MAX_FIELD_BYTES as i32,
"the write after {READS} reads of {MAX_FIELD_BYTES} bytes should still have its budget"
);
}
/// Only the output half of a read-write call spends the budget. `sha512_half`'s
/// input is a borrowed read like any other, aliasing guest memory rather than
/// crossing the boundary, so a run may hash far more bytes than the budget holds as
/// long as the digests it writes fit inside it.
///
/// The two totals are asserted, so the arithmetic that makes the case is in the
/// test rather than in a comment: the inputs alone would overrun the budget, the
/// digests alone are a small fraction of it.
#[test]
fn only_the_output_half_of_a_read_write_spends_the_budget() {
/// Enough 1 KiB inputs to overrun the budget twice over.
const CALLS: u64 = 2 * TRANSFER_LIMIT_BYTES / MAX_FIELD_BYTES as u64;
assert!(
CALLS * MAX_FIELD_BYTES as u64 > TRANSFER_LIMIT_BYTES,
"the inputs alone must overrun the budget"
);
assert!(
CALLS * HASH_LEN as u64 <= TRANSFER_LIMIT_BYTES / 2,
"the digests alone must stay well inside it"
);
let host = FakeHost::new().answering_digest(Answer::filler(HASH_LEN));
let wat = module(
&[import::SHA512_HALF, ONE_PAGE],
&format!(
"(local $i i32)
(local $r i32)
(loop $l
(local.set $r (call $sha512_half (i32.const 0) (i32.const {MAX_FIELD_BYTES})
(i32.const 0) (i32.const {HASH_LEN})))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br_if $l (i32.lt_u (local.get $i) (i32.const {CALLS}))))
(local.get $r)"
),
);
let outcome = run(&wat, &host).expect("the module should run");
assert_eq!(
host.digested.borrow().len() as u64,
CALLS,
"every call should have been served"
);
assert_eq!(
outcome.result, HASH_LEN as i32,
"only the digests are charged, and they fit"
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,621 +0,0 @@
//! The bounds, field-cap and buffer-fit rules `abi.rs` enforces on every region
//! crossing the boundary. This is the policy the guest observes, so each rule is
//! pinned to the code it answers with.
mod support;
use support::{
Answer, COMPLETED, EMPTY_REGION, FakeHost, ONE_PAGE, code, failure, import, module, status,
traced,
};
use xrpl_host_functions::{HASH_LEN, HostError, TraceDataType};
use xrpl_wasm_vm::{MAX_FIELD_BYTES, RunError};
/// One page, so anything at or past 65536 is out of bounds.
const PAGE: i64 = 64 * 1024;
/// The per-field size cap, as a wasm operand.
const CAP: i64 = MAX_FIELD_BYTES as i64;
/// One byte over the cap: the smallest value the engine must refuse.
const OVER_CAP: i64 = CAP + 1;
// ---------------------------------------------------------------------------
// Output regions (`write_into`)
// ---------------------------------------------------------------------------
/// The whole output region must be in bounds, not merely its start — the engine
/// checks `[dst, dst + cap)` before the host is allowed to write.
#[test]
fn an_output_region_running_past_memory_is_refused() {
let host = FakeHost::new();
for (dst, cap) in [(PAGE, 4), (PAGE - 3, 4), (PAGE + 1024, 4), (0, PAGE + 1)] {
let wat = module(
&[import::LDGR_INDEX, ONE_PAGE],
&format!("(call $ldgr_index (i32.const {dst}) (i32.const {cap}))"),
);
assert_eq!(
status(&wat, &host),
code(HostError::PointerOutOfBounds),
"dst {dst} cap {cap}"
);
}
}
/// A region ending exactly at the last byte of memory is in bounds.
#[test]
fn an_output_region_ending_at_the_last_byte_is_allowed() {
let host = FakeHost::new();
let wat = module(
&[import::LDGR_INDEX, ONE_PAGE],
&format!("(call $ldgr_index (i32.const {}) (i32.const 4))", PAGE - 4),
);
assert_eq!(status(&wat, &host), 4);
}
/// The wire carries `i32`, so a guest can present a negative pointer or length.
#[test]
fn a_negative_output_pointer_or_length_is_refused() {
let host = FakeHost::new();
for (dst, cap) in [(-1, 4), (0, -1), (-1, -1), (i32::MIN, 4)] {
let wat = module(
&[import::LDGR_INDEX, ONE_PAGE],
&format!("(call $ldgr_index (i32.const {dst}) (i32.const {cap}))"),
);
assert_eq!(
status(&wat, &host),
code(HostError::InvalidParams),
"dst {dst} cap {cap}"
);
}
}
/// The host reports a value's true length whether or not it fitted; a value that
/// did not fit is the guest's error, not the host's.
#[test]
fn a_value_larger_than_the_buffer_is_refused() {
let host = FakeHost::new().answering_field(1, Answer::filler(64));
let wat = module(
&[import::HOME_LE_FIELD, ONE_PAGE],
"(call $home_le_field (i32.const 1) (i32.const 0) (i32.const 63))",
);
assert_eq!(status(&wat, &host), code(HostError::BufferTooSmall));
let wat = module(
&[import::HOME_LE_FIELD, ONE_PAGE],
"(call $home_le_field (i32.const 1) (i32.const 0) (i32.const 64))",
);
assert_eq!(status(&wat, &host), 64, "exactly enough room is enough");
}
/// A zero-length output region is in bounds and simply cannot hold anything.
#[test]
fn a_zero_length_output_region_is_in_bounds_but_too_small() {
let host = FakeHost::new();
let wat = module(
&[import::LDGR_INDEX, ONE_PAGE],
"(call $ldgr_index (i32.const 0) (i32.const 0))",
);
assert_eq!(status(&wat, &host), code(HostError::BufferTooSmall));
}
/// A host that reports more than the per-field cap is refused even when the
/// guest offered room for it: the cap is the engine's rule, not the buffer's.
#[test]
fn a_value_past_the_field_cap_is_refused() {
let host = FakeHost::new()
.answering_field(1, Answer::claiming(OVER_CAP as usize))
.answering_field(2, Answer::claiming(MAX_FIELD_BYTES));
let wat = module(
&[import::HOME_LE_FIELD, ONE_PAGE],
"(call $home_le_field (i32.const 1) (i32.const 0) (i32.const 4096))",
);
assert_eq!(status(&wat, &host), code(HostError::DataFieldTooLarge));
let wat = module(
&[import::HOME_LE_FIELD, ONE_PAGE],
"(call $home_le_field (i32.const 2) (i32.const 0) (i32.const 4096))",
);
assert_eq!(status(&wat, &host), CAP as i32, "the cap itself is allowed");
}
/// A refused over-cap value leaves nothing behind. `write_into` hands the host at
/// most [`MAX_FIELD_BYTES`] of the guest's buffer however much room the guest
/// declared, so a value past the cap does not fit the region it is offered and no
/// prefix of it can reach guest memory either.
///
/// The host answers with a real over-cap value: [`Answer::claiming`] writes
/// nothing whatever the engine does, so it could not tell the two apart. The
/// second module folds the *whole* declared buffer rather than one byte, so the
/// claim is about the region and not about its first byte.
#[test]
fn an_over_cap_value_is_refused_without_reaching_guest_memory() {
/// The buffer the guest declares: well over the cap, so the clamp bites.
const BUFFER: usize = 4096;
let over_cap = vec![0xff; MAX_FIELD_BYTES + 1];
let host = FakeHost::new().answering_field(1, Answer::bytes(over_cap));
let call = format!("(call $home_le_field (i32.const 1) (i32.const 0) (i32.const {BUFFER}))");
// The status the guest sees, from a module that returns it directly.
let refusing = module(&[import::HOME_LE_FIELD, ONE_PAGE], &call);
assert_eq!(
status(&refusing, &host),
code(HostError::DataFieldTooLarge),
"the value is refused"
);
// Every byte of the buffer, or-ed together: guest memory starts zero-filled,
// so any byte the host wrote shows up here.
let reading = module(
&[import::HOME_LE_FIELD, ONE_PAGE],
&format!(
"(local $i i32)
(local $seen i32)
(drop {call})
(loop $l
(local.set $seen (i32.or (local.get $seen) (i32.load8_u (local.get $i))))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br_if $l (i32.lt_u (local.get $i) (i32.const {BUFFER}))))
(local.get $seen)"
),
);
assert_eq!(
status(&reading, &host),
0,
"and not one of its bytes is in the guest's buffer"
);
}
/// The field cap is checked before the buffer-fit rule, so a value that breaks both
/// is reported as over-cap. The guest branches on the code, and the two rules
/// answer different questions, so the order is worth pinning.
#[test]
fn the_field_cap_precedes_the_buffer_fit_check() {
let host = FakeHost::new().answering_field(1, Answer::claiming(MAX_FIELD_BYTES + 1));
// A 63-byte buffer: the value is both over the cap and far too big to fit.
let wat = module(
&[import::HOME_LE_FIELD, ONE_PAGE],
"(call $home_le_field (i32.const 1) (i32.const 0) (i32.const 63))",
);
assert_eq!(status(&wat, &host), code(HostError::DataFieldTooLarge));
}
// ---------------------------------------------------------------------------
// Input regions (`Region::read`, via `sha512_half`)
//
// `sha512_half`'s first pair is an input region like any other, and it is the
// input the guest gets a status back from: `trace`, the other reader, answers
// nothing at all. So the codes are pinned here and the silence below.
// ---------------------------------------------------------------------------
/// An input region is bounds-checked the same way an output region is. Every case
/// here stays within the field cap, which on an input is checked first.
#[test]
fn an_input_region_running_past_memory_is_refused() {
let host = FakeHost::new();
for (ptr, len) in [(PAGE, 1), (PAGE - 3, 4), (PAGE - 1, CAP)] {
let wat = module(
&[import::SHA512_HALF, ONE_PAGE],
&format!(
"(call $sha512_half (i32.const {ptr}) (i32.const {len})
(i32.const 0) (i32.const {HASH_LEN}))"
),
);
assert_eq!(
status(&wat, &host),
code(HostError::PointerOutOfBounds),
"ptr {ptr} len {len}"
);
assert!(host.digested.borrow().is_empty(), "the host is not called");
}
}
#[test]
fn a_negative_input_pointer_or_length_is_refused() {
let host = FakeHost::new();
for (ptr, len) in [(-1, 1), (0, -1), (i32::MIN, 1)] {
let wat = module(
&[import::SHA512_HALF, ONE_PAGE],
&format!(
"(call $sha512_half (i32.const {ptr}) (i32.const {len})
(i32.const 0) (i32.const {HASH_LEN}))"
),
);
assert_eq!(
status(&wat, &host),
code(HostError::InvalidParams),
"ptr {ptr} len {len}"
);
}
}
/// The field cap bounds what the guest may hand *in*, too.
#[test]
fn an_input_past_the_field_cap_is_refused() {
let host = FakeHost::new();
let digest = |len: i64| {
module(
&[import::SHA512_HALF, ONE_PAGE],
&format!(
"(call $sha512_half (i32.const 0) (i32.const {len})
(i32.const 2048) (i32.const {HASH_LEN}))"
),
)
};
assert_eq!(
status(&digest(OVER_CAP), &host),
code(HostError::DataFieldTooLarge)
);
assert!(host.digested.borrow().is_empty());
assert_eq!(
status(&digest(CAP), &host),
HASH_LEN as i32,
"the cap itself is allowed"
);
}
/// The two directions check in opposite orders: an input's length is known before
/// the read, so the cap comes first, while an output's region has to be resolved
/// before the host can produce a value, so bounds come first there.
#[test]
fn the_field_cap_precedes_the_bounds_check_on_an_input() {
let host = FakeHost::new();
let reading = module(
&[import::SHA512_HALF, ONE_PAGE],
&format!(
"(call $sha512_half (i32.const 0) (i32.const {})
(i32.const 0) (i32.const {HASH_LEN}))",
PAGE + 1
),
);
assert_eq!(status(&reading, &host), code(HostError::DataFieldTooLarge));
let writing = module(
&[import::LDGR_INDEX, ONE_PAGE],
&format!("(call $ldgr_index (i32.const 0) (i32.const {}))", PAGE + 1),
);
assert_eq!(status(&writing, &host), code(HostError::PointerOutOfBounds));
}
// ---------------------------------------------------------------------------
// The reader with no result (`read_borrowed`, via `trace`)
// ---------------------------------------------------------------------------
/// `trace` reads two regions and either one being bad refuses the call. The same
/// rule as above, and the guest is told nothing: the refusal is the host not being
/// called, and the run carries on to the constant that follows.
#[test]
fn both_of_traces_regions_are_checked_silently() {
let host = FakeHost::new();
let regions = [
(
format!("(i32.const {PAGE}) (i32.const 1)"),
EMPTY_REGION.to_owned(),
),
(
EMPTY_REGION.to_owned(),
format!("(i32.const {PAGE}) (i32.const 1)"),
),
(
EMPTY_REGION.to_owned(),
format!("(i32.const 0) (i32.const {OVER_CAP})"),
),
(
"(i32.const -1) (i32.const 1)".to_owned(),
EMPTY_REGION.to_owned(),
),
];
for (msg, data) in regions {
let wat = module(
&[import::TRACE, ONE_PAGE],
&traced(TraceDataType::AsHex, &msg, &data),
);
assert_eq!(status(&wat, &host), COMPLETED, "msg {msg} data {data}");
assert!(
host.traces().is_empty(),
"msg {msg} data {data}: the host must not be called"
);
}
}
// ---------------------------------------------------------------------------
// Both at once (`write_buffered`, via `sha512_half`)
// ---------------------------------------------------------------------------
/// A call with an input and an output region decides everything about the input
/// before anything about the output, so a bad input is reported however the output
/// region is wrong — out of bounds, or a pointer that is not one at all.
///
/// The whole output region, params included, is judged after the host has answered.
/// Hoisting any part of that above the call would put the output's verdict first for
/// these cases, and there is no half of it that can be hoisted on a principle the
/// other half shares.
#[test]
fn a_read_write_checks_its_input_before_its_output() {
let host = FakeHost::new();
let digest = |src: i64, src_len: i64, dst: i64| {
module(
&[import::SHA512_HALF, ONE_PAGE],
&format!(
"(call $sha512_half (i32.const {src}) (i32.const {src_len})
(i32.const {dst}) (i32.const {HASH_LEN}))"
),
)
};
let over_cap = digest(0, OVER_CAP, 0);
assert_eq!(status(&over_cap, &host), code(HostError::DataFieldTooLarge));
let out_of_bounds = digest(PAGE, 4, 0);
assert_eq!(
status(&out_of_bounds, &host),
code(HostError::PointerOutOfBounds)
);
// A bad input against each way the output can be wrong: the input's verdict is
// the one reported, and the host is never asked for a value nobody can take.
for dst in [PAGE, -1] {
let both_bad = digest(0, OVER_CAP, dst);
assert_eq!(
status(&both_bad, &host),
code(HostError::DataFieldTooLarge),
"dst {dst}"
);
}
assert!(host.digested.borrow().is_empty(), "the host is not reached");
}
/// The output half of a read-write call obeys the same rules as a plain write.
#[test]
fn a_read_write_output_obeys_the_write_rules() {
let host = FakeHost::new().answering_digest(Answer::filler(32));
let wat = module(
&[import::SHA512_HALF, ONE_PAGE],
"(call $sha512_half (i32.const 0) (i32.const 4) (i32.const 0) (i32.const 31))",
);
assert_eq!(status(&wat, &host), code(HostError::BufferTooSmall));
let wat = module(
&[import::SHA512_HALF, ONE_PAGE],
&format!(
"(call $sha512_half (i32.const 0) (i32.const 4) (i32.const {PAGE}) (i32.const 32))"
),
);
assert_eq!(status(&wat, &host), code(HostError::PointerOutOfBounds));
}
/// A refused value reaches guest memory in no part, however much of it the host
/// wrote. The host answers with 32 bytes it did write and a length it did not, so
/// the refusal happens with the value sitting in the run's output buffer — and the
/// guest's buffer has to come back untouched.
///
/// Stronger than the contract asks for: a guest must not read its buffer on a
/// negative status. It holds because the buffer is copied to the guest only after
/// the length, the bounds, the fit and the budget have all passed, so there is no
/// window in which a refused value is in guest memory.
#[test]
fn a_refused_value_leaves_nothing_in_guest_memory() {
const MARKER: u8 = 77;
// The two refusals a value can meet after the host has produced it: longer
// than the field cap, and longer than the buffer the guest offered.
let refusals = [
(MAX_FIELD_BYTES + 1, HASH_LEN, HostError::DataFieldTooLarge),
(HASH_LEN, HASH_LEN - 1, HostError::BufferTooSmall),
];
for (claimed, cap, expected) in refusals {
let host =
FakeHost::new().answering_digest(Answer::writing_but_claiming([MARKER; 32], claimed));
let call = format!(
"(call $sha512_half (i32.const 0) (i32.const 4) (i32.const 64) (i32.const {cap}))"
);
let refused = module(&[import::SHA512_HALF, ONE_PAGE], &call);
assert_eq!(
status(&refused, &host),
code(expected),
"claiming {claimed}"
);
// The same call, reporting what is at the output region afterwards.
let inspect = module(
&[import::SHA512_HALF, ONE_PAGE],
&format!("(drop {call}) (i32.load8_u (i32.const 64))"),
);
assert_eq!(
status(&inspect, &host),
0,
"claiming {claimed}: the refused value must not have been written"
);
}
}
/// An input region may overlap the output region: the host is served the input as
/// it stands and its answer lands afterwards, so the two cannot interfere. The
/// marker is any byte distinct from the input's first (`a`), so `finish` returning
/// it proves the write landed.
#[test]
fn an_input_may_overlap_the_output() {
const MARKER: u8 = 99;
let host = FakeHost::new().answering_digest(Answer::bytes([MARKER; HASH_LEN]));
let wat = module(
&[
import::SHA512_HALF,
ONE_PAGE,
r#"(data (i32.const 0) "abcd")"#,
],
&format!(
"(drop (call $sha512_half (i32.const 0) (i32.const 4)
(i32.const 0) (i32.const {HASH_LEN})))
(i32.load8_u (i32.const 0))"
),
);
assert_eq!(
status(&wat, &host),
i32::from(MARKER),
"the output overwrote the input"
);
assert_eq!(
*host.digested.borrow(),
vec![b"abcd".to_vec()],
"the host saw the input as it was"
);
}
// ---------------------------------------------------------------------------
// The memory export itself
// ---------------------------------------------------------------------------
/// A host call with no memory to work in ends the run instead of answering the
/// guest: there is no buffer for a status to describe, and nothing the guest could
/// do about the answer — which is what puts this beside out-of-gas on the fatal
/// channel. What the guest burned getting there is still charged.
fn assert_no_memory(wat: &str, host: &FakeHost) {
let failure = failure(wat, host);
assert!(
matches!(failure.error, RunError::NoMemory),
"expected the run to end for want of a memory export, got: {failure}"
);
assert!(failure.fuel_used > 0, "{failure}");
}
/// Every region is relative to the guest's exported memory, so a module without
/// one cannot make a host call at all.
#[test]
fn a_module_that_exports_no_memory_cannot_call_the_host() {
let host = FakeHost::new();
let wat = module(
&[import::LDGR_INDEX, "(memory 1)"],
"(call $ldgr_index (i32.const 0) (i32.const 4))",
);
assert_no_memory(&wat, &host);
}
/// Having no memory is answered before anything about a call's arguments, so a
/// module without one ends the run even when its arguments would have earned a
/// guest-visible code of their own (here an input over the field cap).
///
/// The order is deliberate: no memory is a fact about the instance, not about this
/// call, and a region cannot be validated against a memory that is not there. It
/// costs the guest nothing — every call such a module makes ends the run anyway.
#[test]
fn no_memory_is_answered_before_a_calls_arguments_are() {
let host = FakeHost::new();
let wat = module(
&[import::SHA512_HALF, "(memory 1)"],
&format!(
"(call $sha512_half (i32.const 0) (i32.const {OVER_CAP})
(i32.const 0) (i32.const {HASH_LEN}))"
),
);
assert_no_memory(&wat, &host);
}
/// The memory's export *name* is not part of the contract: the engine takes the
/// module's memory whatever it is called. Nothing in the wasm spec attaches meaning
/// to `"memory"` — it is a toolchain convention, so the kind decides.
#[test]
fn a_memory_exported_under_any_name_is_the_guests_memory() {
let host = FakeHost::new();
for name in ["mem", "linear", "the memory"] {
let wat = module(
&[
import::LDGR_INDEX,
&format!(r#"(memory (export "{name}") 1)"#),
],
"(drop (call $ldgr_index (i32.const 64) (i32.const 4)))
(i32.load (i32.const 64))",
);
assert_eq!(
status(&wat, &host),
7,
"the host wrote into the memory exported as '{name}'"
);
}
}
/// One memory exported under several names is one memory. The engine resolves the
/// first export of kind memory, and with at most one memory per module every such
/// export is that memory, so the order the exports are walked in cannot change the
/// answer.
#[test]
fn one_memory_exported_under_several_names_is_still_that_memory() {
let host = FakeHost::new();
let wat = module(
&[
import::LDGR_INDEX,
r#"(memory (export "memory") (export "mem") (export "linear") 1)"#,
],
"(drop (call $ldgr_index (i32.const 64) (i32.const 4)))
(i32.load (i32.const 64))",
);
assert_eq!(status(&wat, &host), 7);
}
/// The export has to *be* a memory: a global named `memory` is not one, and it
/// neither serves as the guest's memory nor hides the memory the module really
/// exports. The kind decides, so the conventional name carries no weight on
/// either side.
#[test]
fn an_export_named_memory_that_is_not_a_memory_is_not_the_guests_memory() {
let host = FakeHost::new();
let call = "(call $ldgr_index (i32.const 0) (i32.const 4))";
let wrong_kind = module(
&[
import::LDGR_INDEX,
"(memory 1)",
r#"(global (export "memory") i32 (i32.const 0))"#,
],
call,
);
assert_no_memory(&wrong_kind, &host);
let shadowed = module(
&[
import::LDGR_INDEX,
r#"(memory (export "mem") 1)"#,
r#"(global (export "memory") i32 (i32.const 0))"#,
],
call,
);
assert_eq!(
status(&shadowed, &host),
4,
"the real memory is found past the global that took its name"
);
}
/// Bounds follow the memory the module actually declared, not a fixed page.
#[test]
fn bounds_follow_the_declared_memory_size() {
let host = FakeHost::new();
let wat = module(
&[import::LDGR_INDEX, r#"(memory (export "memory") 2)"#],
&format!("(call $ldgr_index (i32.const {PAGE}) (i32.const 4))"),
);
assert_eq!(status(&wat, &host), 4, "the second page is in bounds");
}

View File

@@ -1,592 +0,0 @@
//! What screening refuses, and that it refuses nothing a run would have served.
//!
//! `check` reaches its verdict from the compiled module alone, so these tests take
//! no host — except the ones that put the same module through `run` to compare the
//! two.
mod support;
use support::{ENTRY, FakeHost, ONE_PAGE, PLENTY_OF_GAS, assemble, import, module};
use xrpl_host_functions::HostFunctionSpec;
use xrpl_wasm_vm::{CheckError, MAX_MEMORY_PAGES, MAX_TABLE_ELEMENTS, RunError};
/// Assert which stage screening refused a module at, because the caller maps the
/// stages separately. The error comes back out for the tests that also read its
/// message.
macro_rules! assert_stage {
($refusal:expr, $stage:pat) => {{
let refusal = $refusal;
assert!(
matches!(refusal, $stage),
concat!("expected a ", stringify!($stage), " refusal, got: {}"),
refusal
);
refusal
}};
}
/// Screens `wat`, which must assemble.
fn check(wat: &str) -> Result<(), CheckError> {
xrpl_wasm_vm::check(&assemble(wat), ENTRY)
}
fn refusal(wat: &str) -> CheckError {
check(wat).expect_err(&format!("expected this module to be refused:\n{wat}"))
}
fn passes(wat: &str) {
if let Err(refusal) = check(wat) {
panic!("expected this module to pass, but: {refusal}\n{wat}");
}
}
// ---------------------------------------------------------------------------
// Compiling
// ---------------------------------------------------------------------------
/// A contract that imports a host function, exports its memory and exports the
/// entry point is what screening is looking for.
#[test]
fn a_runnable_contract_passes() {
passes(&module(
&[import::LDGR_INDEX, ONE_PAGE],
"(call $ldgr_index (i32.const 0) (i32.const 4))",
));
}
/// Bytes that are not a wasm module at all.
#[test]
fn garbage_does_not_pass() {
for bytes in [b"".as_slice(), b"not wasm", &[0x00, 0x61, 0x73, 0x6d]] {
let refusal = xrpl_wasm_vm::check(bytes, ENTRY).expect_err("garbage must not pass");
assert_stage!(refusal, CheckError::Compile(_));
}
}
/// Screening takes wasm binaries, and text is not one — the same rule the VM
/// applies, from the same `wasmi` built without its `wat` feature. Turning that
/// feature on would make this transaction blob valid at both ends.
#[test]
fn a_text_format_module_does_not_pass() {
let text = module(&[ONE_PAGE], "(i32.const 0)");
let refusal =
xrpl_wasm_vm::check(text.as_bytes(), ENTRY).expect_err("text must not pass as a module");
assert_stage!(refusal, CheckError::Compile(_));
// The same module, assembled first, passes: the text is sound and only the
// format was refused.
passes(&text);
}
/// A feature the engine disables is refused here too, because both stages compile
/// against the one engine. `vm_limits.rs` walks every disabled feature; this pins
/// that screening sees the same configuration.
#[test]
fn a_disabled_feature_does_not_pass() {
let refusal = refusal(&module(
&[ONE_PAGE],
"(drop (f64.add (f64.const 1) (f64.const 2))) (i32.const 0)",
));
let refusal = assert_stage!(refusal, CheckError::Compile(_)).to_string();
assert!(refusal.contains("floating-point"), "{refusal}");
}
// ---------------------------------------------------------------------------
// Imports
// ---------------------------------------------------------------------------
/// Every host function the ABI declares, spelled as a guest imports it. The count
/// is asserted against the ABI so a function added to it cannot be left out here.
const ALL_IMPORTS: [&str; 61] = [
import::LDGR_INDEX,
import::PARENT_LDGR_TIME,
import::PARENT_LDGR_HASH,
import::BASE_FEE,
import::AMENDMENT_ENABLED,
import::CACHE_LE,
import::TX_FIELD,
import::HOME_LE_FIELD,
import::LE_FIELD,
import::TX_INNER,
import::HOME_LE_INNER,
import::LE_INNER,
import::TX_ARR_LEN,
import::HOME_LE_ARR_LEN,
import::LE_ARR_LEN,
import::TX_INNER_ARR_LEN,
import::HOME_LE_INNER_ARR_LEN,
import::LE_INNER_ARR_LEN,
import::CHECK_SIG,
import::ACCOUNTROOT_ID,
import::AMM_ID,
import::CHECK_ID,
import::CREDENTIAL_ID,
import::DELEGATE_ID,
import::DEPOSIT_PREAUTH_ID,
import::DID_ID,
import::ESCROW_ID,
import::TRUSTLINE_ID,
import::MPT_ISSUANCE_ID,
import::MPTOKEN_ID,
import::NFT_OFFER_ID,
import::OFFER_ID,
import::ORACLE_ID,
import::PAYCHAN_ID,
import::PERMISSIONED_DOMAIN_ID,
import::SIGNERS_ID,
import::TICKET_ID,
import::VAULT_ID,
import::SHA512_HALF,
import::TRACE,
import::SET_DATA,
import::NFT_URI,
import::NFT_ISSUER,
import::NFT_TAXON,
import::NFT_FLAGS,
import::NFT_XFER_FEE,
import::NFT_SERIAL,
import::FLOAT_FROM_INT,
import::FLOAT_FROM_UINT,
import::FLOAT_FROM_STAMOUNT,
import::FLOAT_FROM_STNUMBER,
import::FLOAT_TO_INT,
import::FLOAT_TO_MANT_EXP,
import::FLOAT_FROM_MANT_EXP,
import::FLOAT_CMP,
import::FLOAT_ADD,
import::FLOAT_SUB,
import::FLOAT_MULT,
import::FLOAT_DIV,
import::FLOAT_ROOT,
import::FLOAT_POW,
];
#[test]
fn every_declared_host_function_may_be_imported() {
assert_eq!(
ALL_IMPORTS.len(),
HostFunctionSpec::ALL.len(),
"the ABI gained a host function with no import declaration in this test"
);
let mut parts = ALL_IMPORTS.to_vec();
parts.push(ONE_PAGE);
passes(&module(&parts, "(i32.const 0)"));
}
/// A module may import fewer host functions than are registered, but not more.
#[test]
fn an_unknown_host_function_does_not_pass() {
let refusal = refusal(&module(
&[
r#"(import "host_lib" "no_such_function" (func $f (param i32) (result i32)))"#,
ONE_PAGE,
],
"(call $f (i32.const 0))",
));
let refusal = assert_stage!(refusal, CheckError::Import(_)).to_string();
assert!(
refusal.contains("no host function 'no_such_function'"),
"{refusal}"
);
}
/// Host functions live under one module name — `host_lib` — and an import naming
/// another is refused even when the function name is real. `env` is in the list
/// because that is what plain clang emits.
#[test]
fn an_import_from_another_module_does_not_pass() {
for module_name in ["host", "env", ""] {
let refusal = refusal(&module(
&[
&format!(
r#"(import "{module_name}" "ldgr_index" (func $f (param i32 i32) (result i32)))"#
),
ONE_PAGE,
],
"(call $f (i32.const 0) (i32.const 4))",
));
let refusal = assert_stage!(refusal, CheckError::Import(_)).to_string();
assert!(refusal.contains("is not from 'host_lib'"), "{refusal}");
}
}
/// A host function's name imported as something other than a function. The engine
/// defines it as a function and nothing else, so this does not link either.
#[test]
fn a_host_function_imported_as_a_global_does_not_pass() {
let refusal = refusal(&module(
&[
r#"(import "host_lib" "ldgr_index" (global $g i32))"#,
ONE_PAGE,
],
"(global.get $g)",
));
let refusal = assert_stage!(refusal, CheckError::Import(_)).to_string();
assert!(
refusal.contains("'host_lib::ldgr_index' is not a function"),
"{refusal}"
);
}
/// A module faulty at two stages is refused by the earlier one — it imports what no
/// engine serves *and* exports no entry point. The imports are what the rest of the
/// module depends on, so that is the message worth having.
#[test]
fn the_earlier_stage_is_the_one_reported() {
let refusal = refusal(
r#"(module
(import "host_lib" "no_such_function" (func $f (result i32)))
(memory (export "memory") 1)
(func (export "not_the_entry_point") (result i32) (call $f)))"#,
);
assert_stage!(refusal, CheckError::Import(_));
}
/// The signature is the one part of an import screening does not compare, so a
/// module that will not link can still pass. Recorded here because it is the gap
/// this stage leaves, not because it is wanted.
#[test]
fn an_import_with_the_wrong_signature_still_passes() {
let wat = module(
&[
r#"(import "host_lib" "ldgr_index" (func $f (param i64 i64) (result i32)))"#,
ONE_PAGE,
],
"(i32.const 0)",
);
passes(&wat);
let host = FakeHost::new();
let failure = xrpl_wasm_vm::run(&assemble(&wat), PLENTY_OF_GAS, &host, ENTRY)
.expect_err("a mistyped import must not link");
assert!(
matches!(failure.error, RunError::Instantiate(_)),
"{failure}"
);
}
// ---------------------------------------------------------------------------
// The entry point
// ---------------------------------------------------------------------------
#[test]
fn a_missing_entry_point_does_not_pass() {
let refusal = refusal(
r#"(module (memory (export "memory") 1)
(func (export "other") (result i32) (i32.const 0)))"#,
);
let refusal = assert_stage!(refusal, CheckError::EntryPoint(_)).to_string();
assert_eq!(refusal, "no entry point 'finish'");
}
/// The entry point is looked up by the name the caller asks for, as a run looks it
/// up: screening a contract for one entry point says nothing about another.
#[test]
fn the_entry_point_is_the_name_the_caller_gives() {
let wasm = assemble(
r#"(module (memory (export "memory") 1)
(func (export "other") (result i32) (i32.const 0)))"#,
);
assert!(xrpl_wasm_vm::check(&wasm, "other").is_ok());
assert!(xrpl_wasm_vm::check(&wasm, ENTRY).is_err());
}
/// Both halves of the entry point's type are screened: a module returning the
/// wrong thing, or taking anything at all, would fail the run's typed lookup.
#[test]
fn an_entry_point_of_the_wrong_type_does_not_pass() {
for (signature, body) in [
("(result i64)", "(i64.const 0)"),
("(param i32) (result i32)", "(i32.const 0)"),
("", "(nop)"),
] {
let refusal = refusal(&format!(
r#"(module (memory (export "memory") 1)
(func (export "finish") {signature} {body}))"#
));
let refusal = assert_stage!(refusal, CheckError::EntryPoint(_)).to_string();
assert_eq!(
refusal, "entry point 'finish' has the wrong signature, expected '() -> i32'",
"{signature}"
);
}
}
/// An export of the entry point's name that is not a function at all is a third
/// case, and named as such: nothing is missing and no signature is wrong.
#[test]
fn an_entry_point_that_is_not_a_function_does_not_pass() {
let refusal = refusal(
r#"(module (memory (export "memory") 1) (global (export "finish") i32 (i32.const 0)))"#,
);
let refusal = assert_stage!(refusal, CheckError::EntryPoint(_)).to_string();
assert_eq!(refusal, "export 'finish' is not a function");
}
// ---------------------------------------------------------------------------
// Agreement with a run
// ---------------------------------------------------------------------------
/// A module with no linear memory to export passes. A contract that makes no host
/// call needs none, and one that does is refused at the call and charged — a
/// runtime fault, not a malformed module.
#[test]
fn a_module_exporting_no_memory_passes() {
let wat = r#"(module (func (export "finish") (result i32) (i32.const 0)))"#;
passes(wat);
let host = FakeHost::new();
assert_eq!(
xrpl_wasm_vm::run(&assemble(wat), PLENTY_OF_GAS, &host, ENTRY)
.expect("a module that calls no host function needs no memory")
.result,
0
);
}
/// Modules spanning what screening decides, each also put through a run.
fn modules() -> Vec<(&'static str, String)> {
vec![
(
"a runnable contract",
module(&[import::LDGR_INDEX, ONE_PAGE], "(i32.const 0)"),
),
(
"a contract that traps",
module(&[ONE_PAGE], "(unreachable)"),
),
(
"a disabled feature",
module(&[ONE_PAGE], "(i32.extend8_s (i32.const 1))"),
),
(
"an unknown host function",
module(
&[
r#"(import "host_lib" "nope" (func $f (result i32)))"#,
ONE_PAGE,
],
"(call $f)",
),
),
(
"an import from another module",
module(
&[
r#"(import "env" "ldgr_index" (func $f (param i32 i32) (result i32)))"#,
ONE_PAGE,
],
"(i32.const 0)",
),
),
(
"a host function imported as a global",
module(
&[r#"(import "host_lib" "trace" (global $g i32))"#, ONE_PAGE],
"(global.get $g)",
),
),
(
"no entry point",
r#"(module (memory (export "memory") 1)
(func (export "other") (result i32) (i32.const 0)))"#
.to_string(),
),
(
"an entry point of the wrong type",
r#"(module (memory (export "memory") 1)
(func (export "finish") (result i64) (i64.const 0)))"#
.to_string(),
),
]
}
/// Screening refuses a module exactly when a run would refuse it at one of the
/// three stages screening covers — nothing it rejects would have run, and nothing
/// it passes stops before the entry point is called. The exceptions are the ones
/// [`what_static_screening_cannot_see`] lists.
#[test]
fn screening_and_a_run_agree() {
let host = FakeHost::new();
for (label, wat) in modules() {
let wasm = assemble(&wat);
let refused_early = match xrpl_wasm_vm::run(&wasm, PLENTY_OF_GAS, &host, ENTRY) {
Err(failure) => matches!(
failure.error,
RunError::Compile(_) | RunError::Instantiate(_) | RunError::EntryPoint(_)
),
Ok(_) => false,
};
assert_eq!(
xrpl_wasm_vm::check(&wasm, ENTRY).is_err(),
refused_early,
"{label}"
);
}
}
/// A module asking for more memory than the engine grants is refused, so the
/// contract that could never run does not reach the ledger. The cap itself passes.
#[test]
fn an_exported_memory_past_the_cap_does_not_pass() {
let wat = module(
&[&format!(
r#"(memory (export "memory") {})"#,
MAX_MEMORY_PAGES + 1
)],
"(i32.const 0)",
);
let refusal = assert_stage!(refusal(&wat), CheckError::Memory(_)).to_string();
assert!(refusal.contains("past the 128-page cap"), "{refusal}");
passes(&module(
&[&format!(r#"(memory (export "memory") {MAX_MEMORY_PAGES})"#)],
"(i32.const 0)",
));
}
/// A declared *maximum* past the cap is legal and simply unreachable, so screening
/// must not turn it away: `vm_limits` runs this very module to completion.
#[test]
fn a_declared_maximum_past_the_cap_still_passes() {
passes(&module(
&[&format!(
r#"(memory (export "memory") 1 {})"#,
MAX_MEMORY_PAGES + 1
)],
"(i32.const 0)",
));
}
/// A module asking for more table than the engine grants is refused for the same
/// reason a memory is. The cap itself passes.
#[test]
fn an_exported_table_past_the_cap_does_not_pass() {
let wat = module(
&[&format!(
r#"(table (export "t") {} funcref)"#,
MAX_TABLE_ELEMENTS + 1
)],
"(i32.const 0)",
);
let refusal = assert_stage!(refusal(&wat), CheckError::Table(_)).to_string();
assert!(refusal.contains("past the 1024-element cap"), "{refusal}");
passes(&module(
&[&format!(
r#"(table (export "t") {MAX_TABLE_ELEMENTS} funcref)"#
)],
"(i32.const 0)",
));
}
/// Both caps are applied in one pass over the exports, so neither may end the walk
/// early: a passing memory must not hide a failing table declared after it, and a
/// passing table must not hide a failing memory.
#[test]
fn one_pass_screens_both_resources() {
let after_a_passing_memory = refusal(&module(
&[
ONE_PAGE,
&format!(r#"(table (export "t") {} funcref)"#, MAX_TABLE_ELEMENTS + 1),
],
"(i32.const 0)",
));
assert_stage!(after_a_passing_memory, CheckError::Table(_));
let after_a_passing_table = refusal(&module(
&[
r#"(table (export "t") 1 funcref)"#,
&format!(r#"(memory (export "memory") {})"#, MAX_MEMORY_PAGES + 1),
],
"(i32.const 0)",
));
assert_stage!(after_a_passing_table, CheckError::Memory(_));
}
/// As with memory, a declared *maximum* past the cap is unreachable rather than
/// wrong: `vm_limits` runs this very module to completion.
#[test]
fn a_declared_table_maximum_past_the_cap_still_passes() {
passes(&module(
&[&format!(
r#"(table (export "t") 1 {} funcref)"#,
MAX_TABLE_ELEMENTS + 1
)],
"(i32.const 0)",
));
}
/// The gap, listed rather than described. A memory or a table a module keeps to
/// itself is not in its exports, so these are the modules that pass screening and
/// then fail to *instantiate* — which is why a run's refusal at that stage cannot be
/// read as the node's fault.
///
/// The two entries are not equally remote. A contract needs an exported memory to
/// make any host call, so the memory row can do nothing but compute and the SDK does
/// not produce one. A table, though, is *normally* unexported — Rust exports
/// `__indirect_function_table` only under `--export-table` — so the table row is the
/// shape a hostile module actually takes, and the store's limiter is the only thing
/// standing in front of it.
#[test]
fn what_static_screening_cannot_see() {
let host = FakeHost::new();
for (label, declaration) in [
("memory", format!("(memory {})", MAX_MEMORY_PAGES + 1)),
(
"table",
format!("(table {} funcref)", MAX_TABLE_ELEMENTS + 1),
),
] {
let wat = format!(
r#"(module {declaration}
(func (export "finish") (result i32) (i32.const 0)))"#
);
passes(&wat);
let failure = match xrpl_wasm_vm::run(&assemble(&wat), PLENTY_OF_GAS, &host, ENTRY) {
Err(failure) => failure,
Ok(outcome) => panic!(
"the store's limiter must refuse the {label}, but the module returned {}",
outcome.result
),
};
assert!(
matches!(failure.error, RunError::Instantiate(_)),
"{label}: {failure}"
);
}
}
/// A start section is guest code, so screening cannot see whether it traps — and does
/// not have to. A trap is the guest's fault wherever it happens, so the run charges the
/// contract for what it burned instead of reporting a module the node should have
/// screened.
#[test]
fn a_start_section_screening_cannot_see_is_charged_as_a_trap() {
let host = FakeHost::new();
let wat = format!(
r#"(module {ONE_PAGE}
(func $init (unreachable))
(start $init)
(func (export "finish") (result i32) (i32.const 0)))"#
);
passes(&wat);
let failure = xrpl_wasm_vm::run(&assemble(&wat), PLENTY_OF_GAS, &host, ENTRY)
.expect_err("a start section that traps must not complete the run");
assert!(matches!(failure.error, RunError::Trap(_)), "{failure}");
assert!(
failure.fuel_used > 0,
"charged for what it burned: {failure}"
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,642 +0,0 @@
//! What the engine refuses outright: modules it will not compile, will not
//! instantiate, or cannot find an entry point in — plus the memory and table caps.
//!
//! These are the sandbox's outer wall. Everything here fails the run rather than
//! returning a code to the guest, so each test reads the failure's message.
mod support;
use support::{
FakeHost, ONE_PAGE, PLENTY_OF_GAS, failure, import, module, run, run_entry, run_with_gas,
};
use xrpl_wasm_vm::{MAX_MEMORY_PAGES, MAX_TABLE_ELEMENTS, RunError};
/// Assert which stage a run failed at, because the caller maps the stages to
/// different outcomes. A stage is one `RunError` variant, so the expectation is a
/// pattern; the failure comes back out for the tests that also read its message.
macro_rules! assert_stage {
($failure:expr, $stage:pat) => {{
let failure = $failure;
assert!(
matches!(failure.error, $stage),
concat!("expected a ", stringify!($stage), " failure, got: {}"),
failure
);
failure
}};
}
// ---------------------------------------------------------------------------
// Linear memory
// ---------------------------------------------------------------------------
/// A module declaring more than the cap fails to instantiate — the limit applies
/// to the initial memory, not only to growth.
#[test]
fn an_initial_memory_past_the_cap_is_refused() {
let host = FakeHost::new();
let wat = module(
&[&format!(
r#"(memory (export "memory") {})"#,
MAX_MEMORY_PAGES + 1
)],
"(i32.const 0)",
);
assert_stage!(failure(&wat, &host), RunError::Instantiate(_));
}
/// The cap itself is allowed.
#[test]
fn an_initial_memory_at_the_cap_is_allowed() {
let host = FakeHost::new();
let wat = module(
&[&format!(r#"(memory (export "memory") {MAX_MEMORY_PAGES})"#)],
"(i32.const 0)",
);
assert_eq!(run(&wat, &host).expect("should run").result, 0);
}
/// Growth up to the cap succeeds; growth past it traps rather than answering -1 as
/// `memory.grow` otherwise would, because the engine's limiter sets
/// `trap_on_grow_failure(true)`.
#[test]
fn growth_stops_at_the_cap() {
let host = FakeHost::new();
let wat = module(
&[ONE_PAGE],
&format!("(memory.grow (i32.const {}))", MAX_MEMORY_PAGES - 1),
);
assert_eq!(
run(&wat, &host).expect("should run").result,
1,
"growing to exactly the cap answers the previous size"
);
let wat = module(
&[ONE_PAGE],
&format!("(memory.grow (i32.const {MAX_MEMORY_PAGES}))"),
);
assert_stage!(failure(&wat, &host), RunError::Trap(_));
}
/// A module may declare a maximum above the cap: the cap is enforced on the initial
/// memory and on growth, not on the memory type's declared bound.
#[test]
fn a_declared_maximum_past_the_cap_is_allowed_but_unreachable() {
let host = FakeHost::new();
let memory = format!(r#"(memory (export "memory") 1 {})"#, MAX_MEMORY_PAGES + 1);
let wat = module(&[&memory], "(i32.const 0)");
assert_eq!(run(&wat, &host).expect("should run").result, 0);
let wat = module(
&[&memory],
&format!("(memory.grow (i32.const {MAX_MEMORY_PAGES}))"),
);
assert_stage!(failure(&wat, &host), RunError::Trap(_));
}
// ---------------------------------------------------------------------------
// Tables
// ---------------------------------------------------------------------------
/// A table's whole cost is paid at instantiation: wasmi writes all 8 bytes of every
/// element before the guest's first instruction, so a module declaring more than the
/// cap must be refused there rather than charged for it.
#[test]
fn an_initial_table_past_the_cap_is_refused() {
let host = FakeHost::new();
let wat = module(
&[&format!("(table {} funcref)", MAX_TABLE_ELEMENTS + 1)],
"(i32.const 0)",
);
assert_stage!(failure(&wat, &host), RunError::Instantiate(_));
}
/// The cap itself is allowed.
#[test]
fn an_initial_table_at_the_cap_is_allowed() {
let host = FakeHost::new();
let wat = module(
&[&format!("(table {MAX_TABLE_ELEMENTS} funcref)")],
"(i32.const 0)",
);
assert_eq!(run(&wat, &host).expect("should run").result, 0);
}
/// The cap binds a table the module keeps to itself, which is the case that matters:
/// a contract has no reason to export its table, so screening never sees the one a
/// hostile module declares.
#[test]
fn the_table_cap_binds_an_unexported_table() {
let host = FakeHost::new();
let wat = module(
&[&format!("(table {} funcref)", u32::from(u16::MAX) * 100)],
"(i32.const 0)",
);
assert_stage!(failure(&wat, &host), RunError::Instantiate(_));
}
/// A declared *maximum* past the cap is legal and simply unreachable, mirroring what
/// linear memory allows. Nothing can reach it: `table.grow` is a reference-types
/// instruction and the engine turns that feature off, so a table's declared minimum
/// is also its final size.
#[test]
fn a_declared_table_maximum_past_the_cap_is_allowed_but_unreachable() {
let host = FakeHost::new();
let wat = module(
&[&format!(
"(table 1 {} funcref)",
u64::try_from(MAX_TABLE_ELEMENTS).expect("fits") + 1
)],
"(i32.const 0)",
);
assert_eq!(run(&wat, &host).expect("should run").result, 0);
}
// ---------------------------------------------------------------------------
// Engine configuration
// ---------------------------------------------------------------------------
/// One row per feature `build_wasm_engine` turns off: the smallest module that uses
/// it, and the fragment of wasmi's refusal that names the feature. A row declaring
/// its own memory omits [`ONE_PAGE`], or it is refused for having two memories
/// instead.
fn disabled_features() -> Vec<(&'static str, Vec<&'static str>, &'static str, &'static str)> {
vec![
(
"wasm_multi_value",
vec![
ONE_PAGE,
"(func $two (result i32 i32) (i32.const 1) (i32.const 2))",
],
"(call $two) (drop) (drop) (i32.const 0)",
"multi-value",
),
(
"wasm_sign_extension",
vec![ONE_PAGE],
"(i32.extend8_s (i32.const 1))",
"sign extension",
),
(
"wasm_bulk_memory",
vec![ONE_PAGE],
"(memory.fill (i32.const 0) (i32.const 0) (i32.const 1)) (i32.const 0)",
"bulk memory",
),
(
"wasm_reference_types",
vec![ONE_PAGE, "(table 1 externref)"],
"(i32.const 0)",
"reference types",
),
// The proposal covers mutable globals crossing the module boundary; an
// internal one is core wasm and stays allowed — see the test below.
(
"wasm_mutable_global",
vec![ONE_PAGE, r#"(global (export "g") (mut i32) (i32.const 0))"#],
"(i32.const 0)",
"mutable global",
),
(
"wasm_tail_call",
vec![ONE_PAGE, "(func $f (result i32) (i32.const 0))"],
"(return_call $f)",
"tail call",
),
// Arithmetic in a constant initialiser. wasmi names the operator rather
// than the proposal here.
(
"wasm_extended_const",
vec![
ONE_PAGE,
"(global $g i32 (i32.add (i32.const 1) (i32.const 2)))",
],
"(global.get $g)",
"non-constant operator",
),
(
"wasm_multi_memory",
vec![ONE_PAGE, "(memory 1)"],
"(i32.const 0)",
"multiple memories",
),
(
"wasm_memory64",
vec![r#"(memory (export "memory") i64 1)"#],
"(i32.const 0)",
"memory64",
),
(
"wasm_custom_page_sizes",
vec![r#"(memory (export "memory") 1 (pagesize 1))"#],
"(i32.const 0)",
"custom page sizes",
),
(
"wasm_wide_arithmetic",
vec![ONE_PAGE],
"(drop (i64.add128 (i64.const 1) (i64.const 2) (i64.const 3) (i64.const 4)))
(i32.const 0)",
"wide arithmetic",
),
// Determinism across nodes is the reason floats are off.
(
"floats",
vec![ONE_PAGE],
"(drop (f64.add (f64.const 1) (f64.const 2))) (i32.const 0)",
"floating-point",
),
]
}
/// Every feature the engine disables is refused, and refused for that reason.
///
/// `wasm_custom_page_sizes` and `wasm_wide_arithmetic` are off by default in wasmi
/// 1.1 (`engine/config.rs:72,74`), so their rows guard against wasmi changing that
/// default rather than against this engine's own config.
#[test]
fn every_disabled_feature_is_refused_by_name() {
let host = FakeHost::new();
for (knob, parts, body, expected) in disabled_features() {
let wat = module(&parts, body);
let failure = assert_stage!(failure(&wat, &host), RunError::Compile(_)).to_string();
assert!(
failure.contains(expected),
"{knob}: expected a refusal mentioning {expected:?}, got: {failure}"
);
}
}
/// The three knobs [`every_disabled_feature_is_refused_by_name`] cannot cover. The
/// engine is a process-wide `LazyLock`, so a test observes the one configuration
/// `build_wasm_engine` makes: a knob masked by another, or with no caller-visible
/// effect, has no distinguishing module.
#[test]
fn the_knobs_without_a_module_of_their_own() {
let host = FakeHost::new();
// `wasm_saturating_float_to_int(false)`: every saturating conversion takes a
// float operand, so `floats(false)` refuses it first, as the message shows.
let wat = module(&[ONE_PAGE], "(i32.trunc_sat_f32_s (f32.const 1))");
let refusal = failure(&wat, &host).to_string();
assert!(refusal.contains("floating-point"), "{refusal}");
assert!(!refusal.contains("saturating"), "{refusal}");
// `ignore_custom_sections(true)`: governs whether wasmi retains custom
// sections, not accept/reject, so this pins only that one is harmless.
let wat = module(
&[ONE_PAGE, r#"(@custom "note" "ignored")"#],
"(i32.const 0)",
);
assert_eq!(run(&wat, &host).expect("should run").result, 0);
// `consume_fuel(true)`: with it off, `Store::set_fuel` fails and `run` returns
// before instantiating, so every test in the suite fails.
let wat = module(&[ONE_PAGE], "(i32.const 0)");
assert!(run(&wat, &host).expect("should run").fuel_used > 0);
}
/// A mutable global the module keeps to itself is core wasm, so the disabled
/// proposal does not reach it: a guest can still have mutable state.
#[test]
fn an_internal_mutable_global_is_still_allowed() {
let host = FakeHost::new();
let wat = module(
&[ONE_PAGE, "(global $g (mut i32) (i32.const 0))"],
"(global.set $g (i32.const 7)) (global.get $g)",
);
assert_eq!(run(&wat, &host).expect("should run").result, 7);
}
/// Bytes that are not a wasm module at all.
#[test]
fn garbage_does_not_compile() {
let host = FakeHost::new();
for bytes in [b"".as_slice(), b"not wasm", &[0x00, 0x61, 0x73, 0x6d]] {
let failure = xrpl_wasm_vm::run(bytes, PLENTY_OF_GAS, &host, support::ENTRY)
.expect_err("garbage must not compile");
assert_stage!(failure, RunError::Compile(_));
}
}
/// The VM takes wasm binaries, and text is not one. wasmi's `wat` feature is on by
/// default and would have `Module::new` assemble text too, so the crate builds
/// wasmi without it; turning it back on would make this transaction blob valid.
#[test]
fn the_vm_refuses_a_text_format_module() {
let host = FakeHost::new();
let text = module(&[ONE_PAGE], "(i32.const 0)");
let failure = xrpl_wasm_vm::run(text.as_bytes(), PLENTY_OF_GAS, &host, support::ENTRY)
.expect_err("text must not compile as a module");
assert_stage!(failure, RunError::Compile(_));
// The same module, assembled first, runs: the text is sound and only the
// format was refused.
assert_eq!(run(&text, &host).expect("should run").result, 0);
}
// ---------------------------------------------------------------------------
// Imports
// ---------------------------------------------------------------------------
/// A module may import fewer host functions than are registered, but not more:
/// an import the linker does not define fails instantiation.
#[test]
fn an_unknown_import_fails_instantiation() {
let host = FakeHost::new();
let wat = module(
&[
r#"(import "host_lib" "no_such_function" (func $f (param i32) (result i32)))"#,
ONE_PAGE,
],
"(call $f (i32.const 0))",
);
assert_stage!(failure(&wat, &host), RunError::Instantiate(_));
}
/// Host functions are registered under one module name — `host_lib`, the name the
/// guest SDK and this repo's fixtures import from — and a guest naming a different
/// one does not link. `env` is in the list because that is what plain clang emits.
#[test]
fn the_import_module_name_must_match() {
let host = FakeHost::new();
for module_name in ["host", "env", ""] {
let wat = module(
&[
&format!(
r#"(import "{module_name}" "ldgr_index" (func $f (param i32 i32) (result i32)))"#
),
ONE_PAGE,
],
"(call $f (i32.const 0) (i32.const 4))",
);
assert_stage!(failure(&wat, &host), RunError::Instantiate(_));
}
}
/// An import spelled with the wrong signature does not link even under the right
/// name, which is what makes the registered signatures load-bearing.
#[test]
fn an_import_with_the_wrong_signature_fails_instantiation() {
let host = FakeHost::new();
for signature in [
"(param i32) (result i32)", // too few parameters
"(param i32 i32 i32) (result i32)", // too many
"(param i64 i64) (result i32)", // wrong parameter types
"(param i32 i32) (result i64)", // wrong result type
"(param i32 i32)", // no result
] {
let wat = module(
&[
&format!(r#"(import "host_lib" "ldgr_index" (func $f {signature}))"#),
ONE_PAGE,
],
"(i32.const 0)",
);
assert_stage!(failure(&wat, &host), RunError::Instantiate(_));
}
}
/// A module that imports a host function it never calls still has to link.
#[test]
fn an_unused_import_is_still_linked() {
let host = FakeHost::new();
let wat = module(
&[import::LDGR_INDEX, import::TRACE, ONE_PAGE],
"(i32.const 0)",
);
assert_eq!(run(&wat, &host).expect("should run").result, 0);
}
// ---------------------------------------------------------------------------
// The start section
// ---------------------------------------------------------------------------
/// A start section runs guest code during instantiation, before the entry point
/// is even looked up, and `set_fuel` and the memory limiter are both installed by
/// then — so it is metered like any other guest code, and a run it stops is
/// charged for what it burned.
///
/// Reported as a **trap**, not as a module that would not instantiate: a trap is the
/// guest's fault wherever it happens, and the stage a run stopped at is not what the
/// caller maps. Filing it under the stage would put a contract's own defect among the
/// faults a caller treats as the node's, and charge nothing for the instructions the
/// contract burned reaching it.
#[test]
fn a_trapping_start_section_is_a_guest_trap_and_is_charged() {
let host = FakeHost::new();
let wat = format!(
r#"(module {ONE_PAGE}
(func $init (unreachable))
(start $init)
(func (export "finish") (result i32) (i32.const 0)))"#
);
let failure = assert_stage!(
run_with_gas(&wat, PLENTY_OF_GAS, &host)
.expect_err("a start section that traps must not complete the run"),
RunError::Trap(_)
);
assert!(
failure.fuel_used > 0,
"the start section's instructions are metered: {failure}"
);
}
/// What `RunError::Instantiate` is left to mean: a module the linker or the store
/// would not accept, rather than one whose guest code failed. Its two shapes, so the
/// variant is not left standing for nothing.
#[test]
fn instantiation_failure_is_a_module_the_engine_will_not_accept() {
let host = FakeHost::new();
// The linker defines no such import.
let wat = module(
&[
r#"(import "host_lib" "no_such_function" (func $f (result i32)))"#,
ONE_PAGE,
],
"(call $f)",
);
assert_stage!(failure(&wat, &host), RunError::Instantiate(_));
// The store's limiter will not grant the memory, and does not trap to say so.
let wat = module(
&[&format!("(memory {})", MAX_MEMORY_PAGES + 1)],
"(i32.const 0)",
);
assert_stage!(failure(&wat, &host), RunError::Instantiate(_));
}
/// A start section that runs out of gas is reported as out of gas, not as a module
/// that would not instantiate. The stage a run stopped at is not what the caller
/// maps — the reason is — and gas exhaustion is one outcome wherever the guest
/// reaches it.
#[test]
fn a_start_section_that_exhausts_gas_is_out_of_gas_not_an_instantiation_failure() {
const GAS: u64 = 10_000;
let host = FakeHost::new();
let wat = format!(
r#"(module {ONE_PAGE}
(func $init (loop $l (br $l)))
(start $init)
(func (export "finish") (result i32) (i32.const 0)))"#
);
let failure = assert_stage!(
run_with_gas(&wat, GAS, &host).expect_err("an endless start section must not instantiate"),
RunError::OutOfGas
);
assert_eq!(
failure.fuel_used, GAS,
"a runaway start section burns the whole limit"
);
}
/// A start section cannot make a host call that needs guest memory, even in a
/// module that exports one: the memory is resolved from the *instance's* exports,
/// and instantiation is what produces the instance, so a call made while it is
/// still running has no memory to work in and ends the run.
///
/// Not a choice: `Module::instantiate` is `pub(crate)` in wasmi, so instantiation
/// cannot be split from the start section to resolve the memory in between.
#[test]
fn a_start_section_cannot_make_a_host_call() {
let host = FakeHost::new();
let wat = format!(
r#"(module {ldgr_index} {ONE_PAGE}
(func $init (drop (call $ldgr_index (i32.const 0) (i32.const 4))))
(start $init)
(func (export "finish") (result i32) (i32.const 0)))"#,
ldgr_index = import::LDGR_INDEX
);
let failure = assert_stage!(
run_with_gas(&wat, PLENTY_OF_GAS, &host)
.expect_err("a host call from a start section must not be served"),
RunError::NoMemory
);
assert!(
failure.fuel_used > 0,
"the start section is metered up to the refused call: {failure}"
);
}
// ---------------------------------------------------------------------------
// The entry point
// ---------------------------------------------------------------------------
#[test]
fn a_missing_entry_point_fails() {
let host = FakeHost::new();
let wat = r#"(module (memory (export "memory") 1) (func (export "other") (result i32) (i32.const 0)))"#;
let failure = assert_stage!(
run_with_gas(wat, PLENTY_OF_GAS, &host)
.expect_err("a module without the entry point must not run"),
RunError::EntryPoint(_)
);
assert!(
failure.to_string().contains("no entry point 'finish'"),
"{failure}"
);
}
/// The entry point is looked up by the name the caller asks for.
#[test]
fn the_entry_point_is_the_name_the_caller_gives() {
let host = FakeHost::new();
let wat = r#"(module (memory (export "memory") 1) (func (export "other") (result i32) (i32.const 9)))"#;
let outcome = run_entry(wat, &host, "other").expect("should run");
assert_eq!(outcome.result, 9);
}
/// The entry point must take nothing and return an `i32`. A module that exports the
/// name with another signature is told so, rather than being told the export is
/// missing: wasmi answers both cases with one error, and "no entry point" would send
/// a contract author looking for a function they already have.
#[test]
fn an_entry_point_of_the_wrong_type_fails() {
let host = FakeHost::new();
for signature in ["(result i64)", "(param i32) (result i32)", ""] {
let body = if signature.contains("result i64") {
"(i64.const 0)"
} else if signature.is_empty() {
"(nop)"
} else {
"(i32.const 0)"
};
let wat = format!(
r#"(module (memory (export "memory") 1) (func (export "finish") {signature} {body}))"#
);
let failure = assert_stage!(
run_with_gas(&wat, PLENTY_OF_GAS, &host)
.expect_err("a wrongly-typed entry point must not run"),
RunError::EntryPoint(_)
)
.to_string();
assert!(
failure.contains("entry point 'finish' has the wrong signature"),
"{signature}: {failure}"
);
assert!(
!failure.contains("no entry point"),
"a present export must not be reported as absent — {signature}: {failure}"
);
}
}
/// An export of the entry point's name that is not a function at all is a third
/// case, and named as such: nothing is missing and no signature is wrong.
#[test]
fn an_entry_point_that_is_not_a_function_fails() {
let host = FakeHost::new();
let wat =
r#"(module (memory (export "memory") 1) (global (export "finish") i32 (i32.const 0)))"#;
let failure = assert_stage!(
run_with_gas(wat, PLENTY_OF_GAS, &host).expect_err("a non-function export must not run"),
RunError::EntryPoint(_)
)
.to_string();
assert!(
failure.contains("export 'finish' is not a function"),
"{failure}"
);
}
/// A guest that traps fails the run rather than returning a value.
#[test]
fn a_trapping_guest_fails_the_run() {
let host = FakeHost::new();
let wat = module(&[ONE_PAGE], "(unreachable)");
assert_stage!(failure(&wat, &host), RunError::Trap(_));
// An out-of-bounds guest access is a trap too, caught by the engine rather
// than anything the host is asked about.
let wat = module(&[ONE_PAGE], "(i32.load (i32.const 100000))");
assert_stage!(failure(&wat, &host), RunError::Trap(_));
}

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More