mirror of
https://github.com/XRPLF/rippled.git
synced 2026-08-28 17:50:55 +00:00
Compare commits
437 Commits
develop
...
timothyban
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5973d60665 | ||
|
|
2787af8cd0 | ||
|
|
abc888707a | ||
|
|
e29e24dd50 | ||
|
|
37128fb8bd | ||
|
|
072c7cb501 | ||
|
|
8320dd4982 | ||
|
|
7e3ac18345 | ||
|
|
e850e10651 | ||
|
|
6b8c28fddd | ||
|
|
d1cf8c2214 | ||
|
|
20750084ab | ||
|
|
e101ef7923 | ||
|
|
3f66957fd2 | ||
|
|
b6cb3c9591 | ||
|
|
ef7f6025e4 | ||
|
|
b268a25f24 | ||
|
|
be2fbd4bf7 | ||
|
|
a8316180e4 | ||
|
|
71d0f26ed5 | ||
|
|
433955f022 | ||
|
|
3d14bce9d8 | ||
|
|
03eabd537e | ||
|
|
cabbb15c6c | ||
|
|
31b2fa5b37 | ||
|
|
135e395e8d | ||
|
|
17a00ed449 | ||
|
|
49049f9592 | ||
|
|
7898a5f402 | ||
|
|
a0794738a6 | ||
|
|
31d8267c2e | ||
|
|
bb90d20511 | ||
|
|
b57ead1a8a | ||
|
|
27859c1f6f | ||
|
|
e4baece501 | ||
|
|
86c09d8acc | ||
|
|
21f1a3e1f7 | ||
|
|
57ae858f14 | ||
|
|
e3ceab3f49 | ||
|
|
4300c5d7d6 | ||
|
|
cc0a6ef77f | ||
|
|
804b7d2dcd | ||
|
|
ddaa958754 | ||
|
|
3530a869cf | ||
|
|
9f5e08de66 | ||
|
|
2851ff46ab | ||
|
|
000fcf88bd | ||
|
|
36e6dfaf62 | ||
|
|
0c759f6cbd | ||
|
|
35fab72e97 | ||
|
|
ee7ac58ffb | ||
|
|
82876ca47c | ||
|
|
1761d83b8a | ||
|
|
579c44bb22 | ||
|
|
17d7bceadd | ||
|
|
ec64f23736 | ||
|
|
dc0326a2a0 | ||
|
|
80d8e827d7 | ||
|
|
b84d25fbed | ||
|
|
fda9c6995e | ||
|
|
c6c68090f2 | ||
|
|
6c32ca3538 | ||
|
|
7122bb8a43 | ||
|
|
c5524e4881 | ||
|
|
37e2f23ee6 | ||
|
|
e863db5061 | ||
|
|
d469fc2cdf | ||
|
|
362ea7a5d1 | ||
|
|
1b4fede15b | ||
|
|
2605b4a78b | ||
|
|
00488bf0b5 | ||
|
|
ecbfb8ea06 | ||
|
|
e91a30d004 | ||
|
|
694fbb7ce3 | ||
|
|
b67a896bff | ||
|
|
9288432da5 | ||
|
|
952450255f | ||
|
|
00dd93e77d | ||
|
|
4526a97c54 | ||
|
|
ecc2f07ea3 | ||
|
|
c5d25e3055 | ||
|
|
0749043d09 | ||
|
|
95526371b0 | ||
|
|
97f32869df | ||
|
|
454c651c44 | ||
|
|
91a23fc92c | ||
|
|
7d52867e3c | ||
|
|
98cf3a0532 | ||
|
|
98abdef208 | ||
|
|
7e7014c7ca | ||
|
|
d6a66d7249 | ||
|
|
6eab6c7c28 | ||
|
|
ca560ed6b0 | ||
|
|
58e47b0101 | ||
|
|
5f6f367b23 | ||
|
|
f52eb08d8a | ||
|
|
2821cc3e8e | ||
|
|
c1008c473c | ||
|
|
50623665c7 | ||
|
|
78c8128c98 | ||
|
|
c5605b6bcd | ||
|
|
a321a5dbfb | ||
|
|
3e113db4f5 | ||
|
|
9c423d2743 | ||
|
|
e60029d5a0 | ||
|
|
13196b839e | ||
|
|
17fb37871c | ||
|
|
3caaecff07 | ||
|
|
229377abd9 | ||
|
|
e23f8e266a | ||
|
|
1d9485c926 | ||
|
|
930ba88921 | ||
|
|
c619ae0263 | ||
|
|
93db40a25e | ||
|
|
8cae773691 | ||
|
|
fe325ea96a | ||
|
|
881d040a22 | ||
|
|
ce5e724b93 | ||
|
|
1656a19fe6 | ||
|
|
3a2cf64a69 | ||
|
|
accd0cac6c | ||
|
|
9a6efde771 | ||
|
|
6abd492ebb | ||
|
|
8da36db515 | ||
|
|
152406b698 | ||
|
|
b5a90e76ee | ||
|
|
abfa572370 | ||
|
|
d8e44687bf | ||
|
|
8a3363752f | ||
|
|
226ec160bb | ||
|
|
6bc7a9a858 | ||
|
|
ba7bf92725 | ||
|
|
8f0eff4dc4 | ||
|
|
7f9ece3891 | ||
|
|
ffb2d37d17 | ||
|
|
6c02c45cbe | ||
|
|
eb946d23af | ||
|
|
994967521e | ||
|
|
52d2008797 | ||
|
|
4d685d4bad | ||
|
|
3f159d624b | ||
|
|
f8f8a6e059 | ||
|
|
5b2fc952d3 | ||
|
|
8ecb77dcfb | ||
|
|
f2271ecc03 | ||
|
|
0bf4739efa | ||
|
|
0df034a685 | ||
|
|
047a3f5cb8 | ||
|
|
e484a2902c | ||
|
|
ef0b5dd1ac | ||
|
|
041869ff3d | ||
|
|
25afc04420 | ||
|
|
ed6f0f3019 | ||
|
|
b61b18a92c | ||
|
|
011235f9a7 | ||
|
|
be7532e3f7 | ||
|
|
14e7dea7ed | ||
|
|
9e28519e56 | ||
|
|
2cc8b87c87 | ||
|
|
d8d1ec46dc | ||
|
|
641ecb47bd | ||
|
|
94ed8e2f49 | ||
|
|
c4ce52c810 | ||
|
|
be3d98e8ff | ||
|
|
7915202cbc | ||
|
|
7ebd92ad3e | ||
|
|
f458c0f981 | ||
|
|
abcdeab11e | ||
|
|
55911cf526 | ||
|
|
b7059deb9f | ||
|
|
11fd30b02a | ||
|
|
1b52b6519a | ||
|
|
45e3d3f73e | ||
|
|
fcf376d3a9 | ||
|
|
ab97f4be20 | ||
|
|
974b17f427 | ||
|
|
91de196312 | ||
|
|
ac71553abe | ||
|
|
9a8ec56d42 | ||
|
|
7a3bd8ace2 | ||
|
|
012f894b0a | ||
|
|
cb6dad0bc4 | ||
|
|
d54ca4cd3b | ||
|
|
5a0269aca7 | ||
|
|
0774ca32a9 | ||
|
|
8feb75011e | ||
|
|
088c5b3b03 | ||
|
|
1c4abe8c5e | ||
|
|
91a5747bf9 | ||
|
|
bc892f0bd3 | ||
|
|
8950530b04 | ||
|
|
5cbebf3c58 | ||
|
|
c634c75aee | ||
|
|
9801c23bad | ||
|
|
f6472347e9 | ||
|
|
aeee77f6ec | ||
|
|
35583fcf97 | ||
|
|
4277c806e4 | ||
|
|
9e427ae3ba | ||
|
|
8022fc33cf | ||
|
|
465fa8ec4b | ||
|
|
b5990be624 | ||
|
|
9767d86de4 | ||
|
|
ca2d999618 | ||
|
|
5ee903befc | ||
|
|
d582ae7990 | ||
|
|
0dbe51c740 | ||
|
|
63fff4b518 | ||
|
|
d85bf722ea | ||
|
|
b664989cfb | ||
|
|
e77934302a | ||
|
|
ef7aeca6bf | ||
|
|
eec1d29b92 | ||
|
|
971ba2281e | ||
|
|
90357eeae1 | ||
|
|
1dce793acc | ||
|
|
597202a6f0 | ||
|
|
1600b3e7f3 | ||
|
|
5be406e2df | ||
|
|
bc483b2a1d | ||
|
|
175259df28 | ||
|
|
8bb8c3b24b | ||
|
|
abb2ef3bec | ||
|
|
d009ef221f | ||
|
|
5fdedd7e99 | ||
|
|
ffc55797b2 | ||
|
|
ecee732187 | ||
|
|
ce2586c039 | ||
|
|
8cc2169939 | ||
|
|
826f613ad8 | ||
|
|
1259c1d5ca | ||
|
|
d2641d85bd | ||
|
|
75f66bd9fe | ||
|
|
7cd71cb659 | ||
|
|
9917f96166 | ||
|
|
e1cc82587b | ||
|
|
2cc9439fde | ||
|
|
52af9582e2 | ||
|
|
46e88dc732 | ||
|
|
bc24f2e211 | ||
|
|
7a7c993b15 | ||
|
|
9733ca8f91 | ||
|
|
18d5e3e226 | ||
|
|
b30b4e1d65 | ||
|
|
d435893602 | ||
|
|
00b0cf50f6 | ||
|
|
7ef256499c | ||
|
|
1338062be7 | ||
|
|
4fc1778ec8 | ||
|
|
65322d9e78 | ||
|
|
c5598a4284 | ||
|
|
0deb6bcadf | ||
|
|
9b013b559b | ||
|
|
1d4a3c00b8 | ||
|
|
4b34102e8e | ||
|
|
d006433579 | ||
|
|
a7ab8ee923 | ||
|
|
e0073a4402 | ||
|
|
2930ef217f | ||
|
|
9dbb301699 | ||
|
|
531e8b6ebd | ||
|
|
90397e1a52 | ||
|
|
888ca2e6d9 | ||
|
|
b6514b680f | ||
|
|
913e4b919e | ||
|
|
196e6a1b27 | ||
|
|
27468ddbcf | ||
|
|
bce5d91e45 | ||
|
|
654338fa66 | ||
|
|
9c25d18851 | ||
|
|
3a825a41e1 | ||
|
|
a9ebf786c6 | ||
|
|
5afe8cc321 | ||
|
|
bc5ec3c962 | ||
|
|
1775251e90 | ||
|
|
61bcb7621f | ||
|
|
a3f71b1774 | ||
|
|
4df7d1a4bb | ||
|
|
125df7a425 | ||
|
|
b08bcf5d21 | ||
|
|
dc413aef0c | ||
|
|
77dfd56ace | ||
|
|
953b9a3500 | ||
|
|
1d9ec84350 | ||
|
|
0392846a17 | ||
|
|
1b4a564369 | ||
|
|
fd524c4be9 | ||
|
|
495dda7f58 | ||
|
|
9c3c0280b1 | ||
|
|
f73d8a6cf2 | ||
|
|
6728ab52b7 | ||
|
|
77673663ca | ||
|
|
c1381f8ddd | ||
|
|
bd16f7989d | ||
|
|
65f9cf80c0 | ||
|
|
de55a5ebfc | ||
|
|
2ec4a1114e | ||
|
|
ba03a8a9d2 | ||
|
|
7c8279ec83 | ||
|
|
0418ffb26a | ||
|
|
b2627039f6 | ||
|
|
8f97ec3bde | ||
|
|
e85e7b1b1a | ||
|
|
803a344c65 | ||
|
|
4eb34f381a | ||
|
|
72fffb6e51 | ||
|
|
f7ee580f01 | ||
|
|
122d405750 | ||
|
|
c1c1b4ea67 | ||
|
|
977caea0a5 | ||
|
|
d7ed6d6512 | ||
|
|
f1f2e2629f | ||
|
|
917c610f96 | ||
|
|
317e533d81 | ||
|
|
4160677878 | ||
|
|
df98db1452 | ||
|
|
673476ef1b | ||
|
|
8bc6f9cd70 | ||
|
|
ba5debfecd | ||
|
|
f4a27c9b6d | ||
|
|
fd1cb318e3 | ||
|
|
8c3544a58c | ||
|
|
ed5139d4e3 | ||
|
|
42494dd4cf | ||
|
|
ce84cc8b44 | ||
|
|
9a9a7aab01 | ||
|
|
209a1a6ffa | ||
|
|
fc35a9f9c8 | ||
|
|
c5e50aa221 | ||
|
|
074b1f00d5 | ||
|
|
7a9d245950 | ||
|
|
1809fe07f2 | ||
|
|
409c67494a | ||
|
|
c626b6403a | ||
|
|
81cbc91927 | ||
|
|
1c812a6c4d | ||
|
|
0724927799 | ||
|
|
d83ec96848 | ||
|
|
375dd50b35 | ||
|
|
419d53ec4c | ||
|
|
d4d70d5675 | ||
|
|
6ab15f8377 | ||
|
|
91f3d51f3d | ||
|
|
9ed60b45f8 | ||
|
|
d5c53dcfd2 | ||
|
|
e94321fb41 | ||
|
|
bbc28b3b1c | ||
|
|
843e981c8a | ||
|
|
5aab274b7a | ||
|
|
2c30e41191 | ||
|
|
8ea5106b0b | ||
|
|
f57f67a8ae | ||
|
|
a98269f049 | ||
|
|
b66bc47ca9 | ||
|
|
0e9c7458bb | ||
|
|
1d89940653 | ||
|
|
1a1a6806ec | ||
|
|
1977df9c2e | ||
|
|
6c95548df5 | ||
|
|
69ab39d658 | ||
|
|
b9eb66eecc | ||
|
|
881087dd3d | ||
|
|
90e0bbd0fc | ||
|
|
b57df290de | ||
|
|
8a403f1241 | ||
|
|
6d2640871d | ||
|
|
c145598ff9 | ||
|
|
50e5608d86 | ||
|
|
7a7b96107c | ||
|
|
500bb68831 | ||
|
|
53eb0f60bc | ||
|
|
41205ae928 | ||
|
|
c33b0ae463 | ||
|
|
16087c9680 | ||
|
|
56bc6d58f6 | ||
|
|
ef5d335e09 | ||
|
|
25c3060fef | ||
|
|
ce9f0b38a4 | ||
|
|
35f7cbf772 | ||
|
|
0db564d261 | ||
|
|
427b7ea104 | ||
|
|
7bf6878b4b | ||
|
|
0bc1a115ff | ||
|
|
334bcfa5ef | ||
|
|
106dea4559 | ||
|
|
3ffdcf8114 | ||
|
|
4021a7eb28 | ||
|
|
0690fda0f1 | ||
|
|
d0cc48c6d3 | ||
|
|
d66e3c949e | ||
|
|
0c65a386b5 | ||
|
|
29f5430881 | ||
|
|
101f285bcd | ||
|
|
286dc6322b | ||
|
|
c9346cd40d | ||
|
|
1c5683ec78 | ||
|
|
9bee155d59 | ||
|
|
f34b05f4de | ||
|
|
97ce25f4ce | ||
|
|
9e14c14a26 | ||
|
|
c507880d8f | ||
|
|
3f8328bbf8 | ||
|
|
c10a5f9ef6 | ||
|
|
3c141de695 | ||
|
|
da2b9455f2 | ||
|
|
cb622488c0 | ||
|
|
32f971fec6 | ||
|
|
8dea76baa4 | ||
|
|
299fbe04c4 | ||
|
|
57fc1df7d7 | ||
|
|
eaba76f9e6 | ||
|
|
cb702cc238 | ||
|
|
b69b4a0a4a | ||
|
|
50d6072a73 | ||
|
|
d24cd50e61 | ||
|
|
9f5875158c | ||
|
|
c3dc33c861 | ||
|
|
6be8f2124c | ||
|
|
edfed06001 | ||
|
|
1c646dba91 | ||
|
|
6781068058 | ||
|
|
cfe57c1dfe | ||
|
|
c34d09a971 | ||
|
|
ebd90c4742 | ||
|
|
ba52d34828 | ||
|
|
1b6312afb3 | ||
|
|
bf32dc2e72 | ||
|
|
a15d65f7a2 | ||
|
|
2de8488855 | ||
|
|
129aa4bfaa | ||
|
|
b1d70db63b | ||
|
|
f03c3aafe4 | ||
|
|
51a9f106d1 | ||
|
|
bfc048e3fe | ||
|
|
83418644f7 | ||
|
|
dbc9dd5bfc | ||
|
|
45ab15d4b5 |
@@ -7,6 +7,8 @@ ignorePaths:
|
||||
- cmake/**
|
||||
- LICENSE.md
|
||||
- .clang-tidy
|
||||
- src/test/app/wasm_fixtures/**/*.wat
|
||||
- src/test/app/wasm_fixtures/*.c
|
||||
- nix/check-tools/*.txt # generated, and full of Nix store hashes
|
||||
language: en
|
||||
allowCompoundWords: true # TODO (#6334)
|
||||
@@ -68,6 +70,7 @@ words:
|
||||
- Btrfs
|
||||
- Buildx
|
||||
- canonicality
|
||||
- cdylib
|
||||
- canonicalised
|
||||
- cctools
|
||||
- changespq
|
||||
@@ -105,6 +108,7 @@ words:
|
||||
- deleteme
|
||||
- demultiplexer
|
||||
- deserializaton
|
||||
- desugars
|
||||
- desync
|
||||
- desynced
|
||||
- determ
|
||||
@@ -131,6 +135,7 @@ words:
|
||||
- gcov
|
||||
- gcovr
|
||||
- ghead
|
||||
- gmock
|
||||
- Gnutella
|
||||
- godexsoft
|
||||
- gpgcheck
|
||||
@@ -140,7 +145,9 @@ words:
|
||||
- hwaddress
|
||||
- hwrap
|
||||
- ifndef
|
||||
- impls
|
||||
- inequation
|
||||
- initialiser
|
||||
- insuf
|
||||
- insuff
|
||||
- invasively
|
||||
@@ -250,6 +257,7 @@ words:
|
||||
- pyparsing
|
||||
- qalloc
|
||||
- qbsprofile
|
||||
- qself
|
||||
- queuable
|
||||
- Raphson
|
||||
- rcflags
|
||||
@@ -307,6 +315,7 @@ words:
|
||||
- STATSDCOLLECTOR
|
||||
- stissue
|
||||
- stnum
|
||||
- stnumber
|
||||
- stobj
|
||||
- stobject
|
||||
- stpath
|
||||
@@ -347,6 +356,7 @@ words:
|
||||
- unflatten
|
||||
- unfund
|
||||
- unimpair
|
||||
- unmetered
|
||||
- unroutable
|
||||
- unscalable
|
||||
- unserviced
|
||||
@@ -367,6 +377,8 @@ words:
|
||||
- vfalco
|
||||
- vinnie
|
||||
- wasmi
|
||||
- wasmparser
|
||||
- Werror
|
||||
- wextra
|
||||
- wptr
|
||||
- writeme
|
||||
@@ -375,6 +387,7 @@ words:
|
||||
- xbridge
|
||||
- xchain
|
||||
- xcrun
|
||||
- xfloat
|
||||
- ximinez
|
||||
- XMACRO
|
||||
- xored
|
||||
|
||||
1
.github/scripts/strategy-matrix/generate.py
vendored
1
.github/scripts/strategy-matrix/generate.py
vendored
@@ -12,7 +12,6 @@ _BASE_CMAKE_ARGS = [
|
||||
"-Dwerr=ON",
|
||||
"-Dxrpld=ON",
|
||||
"-Dwextra=ON",
|
||||
"-Drust=ON",
|
||||
]
|
||||
|
||||
# Maps sanitizer names (as used in cmake) to short config-name suffixes.
|
||||
|
||||
@@ -373,7 +373,10 @@ jobs:
|
||||
- name: Run Rust tests
|
||||
if: ${{ !inputs.build_only }}
|
||||
working-directory: crates
|
||||
run: cargo nextest run --workspace --all-features --locked --no-tests=warn
|
||||
# `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
|
||||
|
||||
1
.github/workflows/reusable-clang-tidy.yml
vendored
1
.github/workflows/reusable-clang-tidy.yml
vendored
@@ -87,7 +87,6 @@ jobs:
|
||||
-Dwerr=ON \
|
||||
-Dxrpld=ON \
|
||||
-Dverify_headers=ON \
|
||||
-Drust=ON \
|
||||
..
|
||||
|
||||
- name: Build clang-tidy prerequisites
|
||||
|
||||
29
BUILD.md
29
BUILD.md
@@ -1,6 +1,6 @@
|
||||
| :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. |
|
||||
| :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. |
|
||||
|
||||
## Minimum Requirements
|
||||
|
||||
@@ -304,7 +304,6 @@ See [Sanitizers docs](./docs/build/sanitizers.md) for more details.
|
||||
| ---------------- | ------------- | ----------------------------------------------------------------------------- |
|
||||
| `assert` | OFF | Force enabling assertions. |
|
||||
| `coverage` | OFF | Prepare the coverage report. |
|
||||
| `rust` | OFF | Build the Rust crates and the C++ code that depends on them. |
|
||||
| `tests` | OFF | Build tests. |
|
||||
| `unity` | OFF | Configure a unity build. |
|
||||
| `verify_headers` | ON | Make the `verify-headers` target available to compile each header on its own. |
|
||||
@@ -319,23 +318,15 @@ builds may be faster for incremental builds, and can be helpful for detecting
|
||||
|
||||
### Rust crates
|
||||
|
||||
The Rust crates in `crates/` are only part of the build when `rust` is ON. With
|
||||
`-Drust=OFF` (the default) the `crates` directory is not added to the build, no
|
||||
cxxbridge bindings are generated, and the C++ tests that exercise the Rust
|
||||
interop are not compiled — so no Rust toolchain is needed. CI builds always pass
|
||||
`-Drust=ON`.
|
||||
|
||||
With `-Drust=ON` you need one extra dependency: a Rust toolchain (`cargo`,
|
||||
`rustc`) matching the channel pinned in
|
||||
[`rust-toolchain.toml`](./rust-toolchain.toml), which compiles the crates and
|
||||
generates the cxxbridge bindings. It is provided by the
|
||||
[Nix development shell](./docs/build/nix.md), so `-Drust=ON` works there without
|
||||
any extra setup; otherwise install it as described in
|
||||
[Rust](./docs/build/environment.md#rust).
|
||||
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 and of the `rust` option
|
||||
(CI runs them with `cargo nextest`):
|
||||
need only the Rust toolchain, independently of CMake (CI runs them with
|
||||
`cargo nextest`):
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path crates/Cargo.toml --workspace
|
||||
|
||||
@@ -160,11 +160,8 @@ endif()
|
||||
|
||||
add_custom_target(tidy_prerequisites)
|
||||
|
||||
if(rust)
|
||||
add_subdirectory(crates)
|
||||
endif()
|
||||
add_subdirectory(crates)
|
||||
include(XrplCore)
|
||||
|
||||
include(XrplProtocolAutogen)
|
||||
include(XrplInstall)
|
||||
include(XrplValidatorKeys)
|
||||
|
||||
@@ -321,7 +321,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, when the project is configured with `-Drust=ON`, 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:
|
||||
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
|
||||
|
||||
@@ -29,6 +29,14 @@ function(xrpl_add_benchmark name)
|
||||
# XrplCore.cmake. Each file compiles fine on its own.
|
||||
set_target_properties(${target} PROPERTIES UNITY_BUILD OFF)
|
||||
|
||||
# Land next to `xrpl_tests` in the build root rather than buried under
|
||||
# `src/benchmarks/libxrpl/`. A benchmark is something a person runs by hand,
|
||||
# repeatedly, and comparing two of them should not mean typing two long paths.
|
||||
set_target_properties(
|
||||
${target}
|
||||
PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}"
|
||||
)
|
||||
|
||||
isolate_headers(
|
||||
${target}
|
||||
"${CMAKE_SOURCE_DIR}/src"
|
||||
|
||||
@@ -207,7 +207,11 @@ target_link_libraries(
|
||||
)
|
||||
|
||||
add_module(xrpl tx)
|
||||
target_link_libraries(xrpl.libxrpl.tx PUBLIC xrpl.libxrpl.ledger)
|
||||
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(
|
||||
|
||||
@@ -32,11 +32,6 @@ endif()
|
||||
|
||||
option(benchmark "Build benchmarks" ON)
|
||||
|
||||
# When OFF, the crates directory is not added to the build at all: no Rust
|
||||
# toolchain is required, no cxxbridge bindings are generated, and the C++ tests
|
||||
# that consume those bindings are left out of the build tree.
|
||||
option(rust "Build the Rust crates and the C++ code that depends on them" OFF)
|
||||
|
||||
# Enabled by default so every header is compiled on its own as the main file of
|
||||
# its own compile_commands.json entry - this is what lets clang-tidy (and clangd
|
||||
# and IDEs) analyse a header's own includes directly. The per-header objects are
|
||||
|
||||
@@ -152,8 +152,12 @@ class Xrpl(ConanFile):
|
||||
"CMakeLists.txt",
|
||||
"cfg/*",
|
||||
"cmake/*",
|
||||
"crates/*",
|
||||
"crates/.cargo/*",
|
||||
"!crates/target/*",
|
||||
"external/*",
|
||||
"include/*",
|
||||
"rust-toolchain.toml",
|
||||
"src/*",
|
||||
)
|
||||
|
||||
|
||||
@@ -101,4 +101,11 @@ function(add_xrpl_crate name)
|
||||
add_dependencies(xrpl_crates ${name}_cxxbridge)
|
||||
endfunction()
|
||||
|
||||
add_xrpl_crate(rs_hello_world CRATE rs_hello_world FILES lib.rs)
|
||||
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
|
||||
)
|
||||
|
||||
215
crates/Cargo.lock
generated
215
crates/Cargo.lock
generated
@@ -8,6 +8,18 @@ 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"
|
||||
@@ -66,7 +78,7 @@ dependencies = [
|
||||
"cxxbridge-cmd",
|
||||
"cxxbridge-flags",
|
||||
"cxxbridge-macro",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
"link-cplusplus",
|
||||
]
|
||||
|
||||
@@ -129,12 +141,27 @@ 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"
|
||||
@@ -148,9 +175,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"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"
|
||||
@@ -160,6 +199,12 @@ 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"
|
||||
@@ -178,19 +223,18 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rs-hello_world"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cxx",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -227,6 +271,22 @@ 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"
|
||||
@@ -276,6 +336,99 @@ 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 = "2.0.0-beta.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab57cbb8db5ee46c6667b642544d7664adfbc0ea6a1ab219c92d734b795f36b1"
|
||||
dependencies = [
|
||||
"spin",
|
||||
"wasmi_collections",
|
||||
"wasmi_core",
|
||||
"wasmi_ir",
|
||||
"wasmparser 0.228.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmi_collections"
|
||||
version = "2.0.0-beta.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55ea3ee266456966465c55a1f440e33116caf2b05a4fc30da36cb0c9813059d5"
|
||||
dependencies = [
|
||||
"string-interner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmi_core"
|
||||
version = "2.0.0-beta.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f8285efe48a9e1afbcdfcc19cd807b3eb20129b7e199c7a99efd30ba192926b"
|
||||
dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmi_ir"
|
||||
version = "2.0.0-beta.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6227be1aebba39b4815ab6a312d0528590f0db2473621ec9285606410889b0a6"
|
||||
dependencies = [
|
||||
"wasmi_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.228.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3"
|
||||
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"
|
||||
@@ -299,3 +452,47 @@ 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",
|
||||
"xrpl-host-functions",
|
||||
]
|
||||
|
||||
[[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",
|
||||
]
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
[workspace]
|
||||
members = ["hello_world"]
|
||||
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.198", features = ["c++20"] }
|
||||
cxx = { version = "1.0.199", features = ["c++20"] }
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
[package]
|
||||
name = "rs-hello_world"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["staticlib"]
|
||||
|
||||
[dependencies]
|
||||
cxx.workspace = true
|
||||
@@ -1,10 +0,0 @@
|
||||
#[cxx::bridge(namespace = "rs::hello_world")]
|
||||
mod ffi {
|
||||
extern "Rust" {
|
||||
fn hello_world() -> String;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hello_world() -> String {
|
||||
"hello_world".to_string()
|
||||
}
|
||||
18
crates/xrpl-host-functions-macros/Cargo.toml
Normal file
18
crates/xrpl-host-functions-macros/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[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"
|
||||
12
crates/xrpl-host-functions-macros/src/errors.rs
Normal file
12
crates/xrpl-host-functions-macros/src/errors.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
/// 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
|
||||
})
|
||||
}
|
||||
405
crates/xrpl-host-functions-macros/src/lib.rs
Normal file
405
crates/xrpl-host-functions-macros/src/lib.rs
Normal file
@@ -0,0 +1,405 @@
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
859
crates/xrpl-host-functions-macros/src/parsed_host_function.rs
Normal file
859
crates/xrpl-host-functions-macros/src/parsed_host_function.rs
Normal file
@@ -0,0 +1,859 @@
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
7
crates/xrpl-host-functions/Cargo.toml
Normal file
7
crates/xrpl-host-functions/Cargo.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "xrpl-host-functions"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
xrpl-host-functions-macros.path = "../xrpl-host-functions-macros"
|
||||
508
crates/xrpl-host-functions/src/lib.rs
Normal file
508
crates/xrpl-host-functions/src/lib.rs
Normal file
@@ -0,0 +1,508 @@
|
||||
//! 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>;
|
||||
}
|
||||
102
crates/xrpl-host-functions/src/macros.rs
Normal file
102
crates/xrpl-host-functions/src/macros.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
//! 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
34
crates/xrpl-host-functions/tests/expansion_hygiene.rs
Normal file
34
crates/xrpl-host-functions/tests/expansion_hygiene.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
//! `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));
|
||||
}
|
||||
1006
crates/xrpl-host-functions/tests/generated_abi.rs
Normal file
1006
crates/xrpl-host-functions/tests/generated_abi.rs
Normal file
File diff suppressed because it is too large
Load Diff
102
crates/xrpl-host-functions/tests/host_errors.rs
Normal file
102
crates/xrpl-host-functions/tests/host_errors.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
//! 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
12
crates/xrpl-wasm-testkit/Cargo.toml
Normal file
12
crates/xrpl-wasm-testkit/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "xrpl-wasm-testkit"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["staticlib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
cxx.workspace = true
|
||||
wat = "1"
|
||||
xrpl-host-functions = { path = "../xrpl-host-functions" }
|
||||
107
crates/xrpl-wasm-testkit/src/lib.rs
Normal file
107
crates/xrpl-wasm-testkit/src/lib.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
//! 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>>;
|
||||
|
||||
/// The gas a host function is charged before it runs, by its guest import name.
|
||||
///
|
||||
/// For the C++ gas benchmarks, which measure what a host call actually costs and
|
||||
/// report it against what the table says it costs. Reading the declaration through
|
||||
/// here rather than copying the numbers into C++ is the point: 61 transcribed
|
||||
/// constants would drift from `lib.rs` the first time a price changed, and drift
|
||||
/// silently, because a benchmark has nothing to fail.
|
||||
///
|
||||
/// Throws `rust::Error` on an unknown name — a typo should fail loudly rather than
|
||||
/// quietly compare against zero.
|
||||
fn declared_gas(wasm_name: &str) -> Result<u64>;
|
||||
}
|
||||
}
|
||||
|
||||
fn compile_wat(wat: &str) -> Result<Vec<u8>, wat::Error> {
|
||||
wat::parse_str(wat)
|
||||
}
|
||||
|
||||
fn declared_gas(wasm_name: &str) -> Result<u64, UnknownHostFunction> {
|
||||
xrpl_host_functions::HostFunctionSpec::ALL
|
||||
.iter()
|
||||
.find(|op| op.wasm_name() == wasm_name)
|
||||
.map(|op| op.gas())
|
||||
.ok_or_else(|| UnknownHostFunction(wasm_name.to_owned()))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct UnknownHostFunction(String);
|
||||
|
||||
impl std::fmt::Display for UnknownHostFunction {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "no host function is imported as `{}`", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for UnknownHostFunction {}
|
||||
|
||||
#[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_host_function_reports_the_gas_its_declaration_gives_it() {
|
||||
// `trace` is the cheapest declaration in the table; the point is not the number but
|
||||
// that the lookup reaches the same constant the engine charges from.
|
||||
assert_eq!(
|
||||
super::declared_gas("trace").expect("trace is a host function"),
|
||||
xrpl_host_functions::HostFunctionSpec::Trace.gas()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_host_function_is_reachable_by_its_import_name() {
|
||||
for op in xrpl_host_functions::HostFunctionSpec::ALL {
|
||||
assert_eq!(
|
||||
super::declared_gas(op.wasm_name()).expect("declared"),
|
||||
op.gas(),
|
||||
"{} must be reachable by name",
|
||||
op.wasm_name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_name_is_an_error_rather_than_zero_gas() {
|
||||
super::declared_gas("not_a_host_function").expect_err("must not resolve");
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
}
|
||||
12
crates/xrpl-wasm-vm-ffi/Cargo.toml
Normal file
12
crates/xrpl-wasm-vm-ffi/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[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" }
|
||||
1293
crates/xrpl-wasm-vm-ffi/src/lib.rs
Normal file
1293
crates/xrpl-wasm-vm-ffi/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
11
crates/xrpl-wasm-vm/Cargo.toml
Normal file
11
crates/xrpl-wasm-vm/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "xrpl-wasm-vm"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
wasmi = { version = "2.0.0-beta.10", default-features = false, features = ["std", "validate", "portable-dispatch"] }
|
||||
xrpl-host-functions = { path = "../xrpl-host-functions" }
|
||||
|
||||
[dev-dependencies]
|
||||
wat = "1"
|
||||
847
crates/xrpl-wasm-vm/src/abi.rs
Normal file
847
crates/xrpl-wasm-vm/src/abi.rs
Normal file
@@ -0,0 +1,847 @@
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_u32_arg_success() {
|
||||
let number: u32 = 0x12345678;
|
||||
let le_array: [u8; 4] = number.to_le_bytes();
|
||||
assert_eq!(le_array, [0x78, 0x56, 0x34, 0x12]);
|
||||
|
||||
let result = read_u32_arg(&le_array);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), number.try_into().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_u32_arg_invalid_length() {
|
||||
let le_array = [0x56, 0x34, 0x12];
|
||||
|
||||
let result = read_u32_arg(&le_array);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), HostError::InvalidParams);
|
||||
}
|
||||
}
|
||||
28
crates/xrpl-wasm-vm/src/lib.rs
Normal file
28
crates/xrpl-wasm-vm/src/lib.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
//! 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,
|
||||
};
|
||||
407
crates/xrpl-wasm-vm/src/preflight.rs
Normal file
407
crates/xrpl-wasm-vm/src/preflight.rs
Normal file
@@ -0,0 +1,407 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
50
crates/xrpl-wasm-vm/src/region.rs
Normal file
50
crates/xrpl-wasm-vm/src/region.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
1215
crates/xrpl-wasm-vm/src/register.rs
Normal file
1215
crates/xrpl-wasm-vm/src/register.rs
Normal file
File diff suppressed because it is too large
Load Diff
405
crates/xrpl-wasm-vm/src/vm.rs
Normal file
405
crates/xrpl-wasm-vm/src/vm.rs
Normal file
@@ -0,0 +1,405 @@
|
||||
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);
|
||||
// Disabled through the crate feature flag.
|
||||
// config.wasm_memory64(false);
|
||||
config.wasm_wide_arithmetic(false);
|
||||
config.allow_start_fn(false);
|
||||
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");
|
||||
}
|
||||
}
|
||||
994
crates/xrpl-wasm-vm/tests/budgets.rs
Normal file
994
crates/xrpl-wasm-vm/tests/budgets.rs
Normal file
@@ -0,0 +1,994 @@
|
||||
//! 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 small constants: 15 per
|
||||
/// `*.const`, and the `call` itself is free. Our gas sits on top.
|
||||
///
|
||||
/// This holds only while every operand is a small constant. wasmi widens a
|
||||
/// constant's encoding past a threshold, and a wider const costs more, so a call
|
||||
/// built with a large constant fails here. Every call in [`call_for`] keeps its
|
||||
/// operands small for that reason.
|
||||
fn wasmi_call_fuel(small_const_operands: u64) -> u64 {
|
||||
15 * small_const_operands
|
||||
}
|
||||
|
||||
/// 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 = 15;
|
||||
|
||||
/// 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 = 22;
|
||||
|
||||
/// 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}"
|
||||
);
|
||||
// The cost break down is as follows:
|
||||
// 1. There is a function entry charge (finish function) which seems to be 63 units of fuel.
|
||||
// 2. Each iteration costs 2 units of fuel.
|
||||
// For a GAS amount of 100,000, we will be limited to burning an odd number of fuel.
|
||||
// So the way the test is written, the most fuel that will be used is 99,999 units.
|
||||
assert_eq!(
|
||||
failure.fuel_used,
|
||||
GAS - 1,
|
||||
"a runaway guest burns all but the last unit of the 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"
|
||||
);
|
||||
}
|
||||
|
||||
// cspell:disable
|
||||
/// Measures each pinned fuel figure straight from wasmi and asserts the constant
|
||||
/// still matches. This is what fails first when a wasmi upgrade shifts the fuel
|
||||
/// table, and it prints every measured number so the constants can be re-derived:
|
||||
///
|
||||
/// cargo test -p xrpl-wasm-vm --test budgets probe_fuel -- --exact --nocapture
|
||||
///
|
||||
/// The behavioural tests above build totals out of these constants; this one ties
|
||||
/// each constant back to the one measurement that defines it.
|
||||
// cspell:enable
|
||||
#[test]
|
||||
fn probe_fuel() {
|
||||
let h = FakeHost::new().answering_field(1, Answer::bytes([0xaa]));
|
||||
|
||||
// EMPTY_MODULE_FUEL: a module that only returns a constant.
|
||||
let empty = fuel_for("(i32.const 0)", &[ONE_PAGE], &h);
|
||||
eprintln!("EMPTY_MODULE_FUEL = {empty}");
|
||||
assert_eq!(empty, EMPTY_MODULE_FUEL, "EMPTY_MODULE_FUEL");
|
||||
|
||||
// wasmi_call_fuel(operands) = fuel(1 call) - empty - gas, for every op. Asserting
|
||||
// it against the formula across all operand counts pins both slope and intercept.
|
||||
eprintln!("--- wasmi_call_fuel by operand count ---");
|
||||
for &op in HostFunctionSpec::ALL {
|
||||
let c = call_for(op);
|
||||
// A no-result op's body ends in a trailing constant, not the call's result,
|
||||
// so its total carries an extra push; it is pinned in the NO_RESULT section.
|
||||
if !c.yields {
|
||||
continue;
|
||||
}
|
||||
let one = fuel_for(&c.body(1), &[c.import, ONE_PAGE], &h);
|
||||
let measured = one - empty - op.gas();
|
||||
eprintln!(
|
||||
"operands={:2} wasmi_call_fuel={measured:3} {}",
|
||||
c.operands, c.call
|
||||
);
|
||||
assert_eq!(
|
||||
measured,
|
||||
wasmi_call_fuel(c.operands),
|
||||
"wasmi_call_fuel({}) for {}",
|
||||
c.operands,
|
||||
c.call
|
||||
);
|
||||
}
|
||||
|
||||
// WASMI_DROP_FUEL: a second yielding call adds one call plus one drop.
|
||||
let g = call_for(HostFunctionSpec::GetLedgerSqn);
|
||||
let g1 = fuel_for(&g.body(1), &[g.import, ONE_PAGE], &h);
|
||||
let g2 = fuel_for(&g.body(2), &[g.import, ONE_PAGE], &h);
|
||||
let drop = (g2 - g1) - (wasmi_call_fuel(g.operands) + HostFunctionSpec::GetLedgerSqn.gas());
|
||||
eprintln!("WASMI_DROP_FUEL = {drop}");
|
||||
assert_eq!(drop, WASMI_DROP_FUEL, "WASMI_DROP_FUEL");
|
||||
|
||||
// WASMI_NO_RESULT_FUEL: trace is the only no-result op; its module ends in a
|
||||
// trailing constant instead of the call's result.
|
||||
let t = call_for(HostFunctionSpec::Trace);
|
||||
let t1 = fuel_for(&t.body(1), &[t.import, ONE_PAGE], &h);
|
||||
let no_result = t1 - empty - wasmi_call_fuel(t.operands) - HostFunctionSpec::Trace.gas();
|
||||
eprintln!("WASMI_NO_RESULT_FUEL = {no_result}");
|
||||
assert_eq!(no_result, WASMI_NO_RESULT_FUEL, "WASMI_NO_RESULT_FUEL");
|
||||
}
|
||||
1406
crates/xrpl-wasm-vm/tests/host_calls.rs
Normal file
1406
crates/xrpl-wasm-vm/tests/host_calls.rs
Normal file
File diff suppressed because it is too large
Load Diff
621
crates/xrpl-wasm-vm/tests/memory_policy.rs
Normal file
621
crates/xrpl-wasm-vm/tests/memory_policy.rs
Normal file
@@ -0,0 +1,621 @@
|
||||
//! 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");
|
||||
}
|
||||
656
crates/xrpl-wasm-vm/tests/preflight.rs
Normal file
656
crates/xrpl-wasm-vm/tests/preflight.rs
Normal file
@@ -0,0 +1,656 @@
|
||||
//! 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 runs guest code at instantiation, before the entry point. The
|
||||
/// engine disallows it, so screening refuses the module outright rather than letting
|
||||
/// any code run ahead of the entry point.
|
||||
#[test]
|
||||
fn a_start_section_is_refused_by_screening() {
|
||||
let wat = format!(
|
||||
r#"(module {ONE_PAGE}
|
||||
(func $init (unreachable))
|
||||
(start $init)
|
||||
(func (export "finish") (result i32) (i32.const 0)))"#
|
||||
);
|
||||
|
||||
let refusal = assert_stage!(refusal(&wat), CheckError::Compile(_)).to_string();
|
||||
assert!(refusal.contains("start"), "{refusal}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_memory64_memory_is_refused_by_screening() {
|
||||
let wat = r#"(module
|
||||
(memory i64 1)
|
||||
(func (export "finish") (result i32) (i32.const 0)))"#;
|
||||
|
||||
let refusal = assert_stage!(refusal(wat), CheckError::Compile(_)).to_string();
|
||||
assert!(
|
||||
refusal.contains("memory64") || refusal.contains("i64"),
|
||||
"{refusal}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Malformed modules crafted to abuse the parser rather than merely be invalid — a vector
|
||||
/// length that lies about its size, a section that overruns its payload, a locals-count bomb,
|
||||
/// and a non-terminating LEB128 — are refused at compile like any other garbage. These guard
|
||||
/// the parser against resource-exhaustion shapes (ported from the old Beast section-corruption
|
||||
/// fixtures); the plainer "bad magic / wrong version" shapes are covered by `garbage_does_not_pass`.
|
||||
#[test]
|
||||
fn parser_abuse_shapes_are_refused() {
|
||||
fn hex(s: &str) -> Vec<u8> {
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
|
||||
.collect()
|
||||
}
|
||||
let cases = [
|
||||
("vector length lies", "0061736d010000000105ffffffff0f"),
|
||||
("section overruns its payload", "0061736d01000000010a0160"),
|
||||
(
|
||||
"locals-count bomb",
|
||||
"0061736d01000000010401600000030201000a0f010d01ffffffff0f7f0b",
|
||||
),
|
||||
(
|
||||
"non-terminating LEB128",
|
||||
"0061736d0100000001058080808080808080808080808080808080808080808080808080808080808080808080808080808080808080808080808080808080808080808080808080808080808080808080808080808080808080808080808080808080808080808080808080808080808080808080",
|
||||
),
|
||||
];
|
||||
for (label, h) in cases {
|
||||
let refusal = xrpl_wasm_vm::check(&hex(h), ENTRY).expect_err(label);
|
||||
assert_stage!(refusal, CheckError::Compile(_));
|
||||
}
|
||||
}
|
||||
|
||||
/// The plain structurally-malformed modules from the old section-corruption fixtures — a
|
||||
/// corrupt magic, a wrong version, a lying section length, sections out of order, junk after
|
||||
/// the last section, an unknown section id — are all refused at compile. Belt-and-suspenders
|
||||
/// alongside `garbage_does_not_pass`: guards against a wasmi upgrade loosening the validator.
|
||||
#[test]
|
||||
fn structurally_malformed_modules_are_refused() {
|
||||
fn hex(s: &str) -> Vec<u8> {
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
|
||||
.collect()
|
||||
}
|
||||
let cases = [
|
||||
("corrupt magic number", "0161736d01000000"),
|
||||
("wrong version", "0061736d02000000"),
|
||||
("lying section length", "0061736d01000000018080808008"),
|
||||
("sections out of order", "0061736d010000000a02000b03020000"),
|
||||
(
|
||||
"junk after last section",
|
||||
"0061736d01000000010a01600000000000000000",
|
||||
),
|
||||
("unknown section id", "0061736d01000000ff0100"),
|
||||
];
|
||||
for (label, h) in cases {
|
||||
let refusal = xrpl_wasm_vm::check(&hex(h), ENTRY).expect_err(label);
|
||||
assert_stage!(refusal, CheckError::Compile(_));
|
||||
}
|
||||
}
|
||||
1585
crates/xrpl-wasm-vm/tests/support/mod.rs
Normal file
1585
crates/xrpl-wasm-vm/tests/support/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
711
crates/xrpl-wasm-vm/tests/vm_limits.rs
Normal file
711
crates/xrpl-wasm-vm/tests/vm_limits.rs
Normal file
@@ -0,0 +1,711 @@
|
||||
//! 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The engine disallows start sections, so a module carrying one is rejected at
|
||||
/// compile and never runs. No guest code executes ahead of the entry point, whatever
|
||||
/// that code would have done — trap, loop, or call the host — so nothing is metered
|
||||
/// and no fuel is burned. Screening catches the same module up front
|
||||
/// (`preflight::a_start_section_is_refused_by_screening`); this pins that `run`
|
||||
/// refuses it the same way rather than instantiating it.
|
||||
#[test]
|
||||
fn a_start_section_module_is_rejected_at_compile() {
|
||||
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 module with a start section must not run"),
|
||||
RunError::Compile(_)
|
||||
);
|
||||
assert_eq!(
|
||||
failure.fuel_used, 0,
|
||||
"no guest code runs, so nothing is charged: {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(_));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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(_));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_memory64_module_is_rejected_at_compile() {
|
||||
let host = FakeHost::new();
|
||||
let wat = r#"(module
|
||||
(memory i64 1)
|
||||
(func (export "finish") (result i32) (i32.const 0)))"#;
|
||||
|
||||
let failure = assert_stage!(
|
||||
run_with_gas(wat, PLENTY_OF_GAS, &host)
|
||||
.expect_err("a module using 64-bit memory must not run"),
|
||||
RunError::Compile(_)
|
||||
);
|
||||
assert_eq!(
|
||||
failure.fuel_used, 0,
|
||||
"rejected before instantiation, so nothing is charged: {failure}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A function declaring more parameters than wasm allows (1000) is refused at compile, so a
|
||||
/// contract cannot smuggle an unbounded signature past screening.
|
||||
#[test]
|
||||
fn a_function_with_too_many_params_is_refused() {
|
||||
let host = FakeHost::new();
|
||||
let params = " i32".repeat(1001);
|
||||
let wat = format!(
|
||||
"(module {ONE_PAGE} (func (param{params}) (result i32) (i32.const 0)) \
|
||||
(func (export \"finish\") (result i32) (i32.const 0)))"
|
||||
);
|
||||
assert_stage!(failure(&wat, &host), RunError::Compile(_));
|
||||
}
|
||||
|
||||
/// A function declaring more locals than wasm allows (50 000) is refused at compile.
|
||||
#[test]
|
||||
fn a_function_with_too_many_locals_is_refused() {
|
||||
let host = FakeHost::new();
|
||||
let locals = format!("(local{})", " i32".repeat(50_001));
|
||||
let wat = module(&[ONE_PAGE], &format!("{locals} (i32.const 0)"));
|
||||
assert_stage!(failure(&wat, &host), RunError::Compile(_));
|
||||
}
|
||||
|
||||
/// Below the compile cap but past the engine's register frame, a locals-heavy function is
|
||||
/// refused when the frame is built rather than at compile — still refused, just later.
|
||||
#[test]
|
||||
fn a_function_past_the_register_frame_is_refused() {
|
||||
let host = FakeHost::new();
|
||||
let locals = format!("(local{})", " i32".repeat(40_000));
|
||||
let wat = module(&[ONE_PAGE], &format!("{locals} (i32.const 0)"));
|
||||
assert_stage!(failure(&wat, &host), RunError::Trap(_));
|
||||
}
|
||||
|
||||
/// Unbounded recursion is stopped by the engine's call-stack limit — it traps rather than
|
||||
/// running the host's native stack off the end (the portable dispatcher makes loops safe;
|
||||
/// this pins that guest *calls* are bounded too).
|
||||
#[test]
|
||||
fn unbounded_recursion_is_stopped_by_the_call_stack_limit() {
|
||||
let host = FakeHost::new();
|
||||
let wat = format!(
|
||||
"(module {ONE_PAGE} \
|
||||
(func $rec (param i32) (result i32) \
|
||||
(if (result i32) (i32.eqz (local.get 0)) (then (i32.const 0)) \
|
||||
(else (call $rec (i32.sub (local.get 0) (i32.const 1)))))) \
|
||||
(func (export \"finish\") (result i32) (call $rec (i32.const 1000000))))"
|
||||
);
|
||||
assert_stage!(failure(&wat, &host), RunError::Trap(_));
|
||||
}
|
||||
|
||||
/// A module with many functions currently compiles and runs: wasmi's only cap is its
|
||||
/// 1,000,000 hard limit, so the ticket's ~24k-function module — a CodeMap-growth DoS, since
|
||||
/// every validation appends to the engine's append-only CodeMap — is not refused here.
|
||||
/// Enforcing a tighter bound (a function-count / average-bytes-per-function limit) belongs in
|
||||
/// a future preflight pass that parses the module before the engine sees it. Ignored until
|
||||
/// then, so this documents the gap without asserting it is acceptable.
|
||||
#[test]
|
||||
#[ignore = "CodeMap-DoS unmitigated; a function-count limit is deferred to preflight parsing"]
|
||||
fn many_functions_currently_run_unbounded() {
|
||||
let host = FakeHost::new();
|
||||
let funcs: String = (0..24_000)
|
||||
.map(|i| format!("(func $f{i} (result i32) (i32.const {}))", i % 7))
|
||||
.collect();
|
||||
let wat =
|
||||
format!("(module {ONE_PAGE} {funcs} (func (export \"finish\") (result i32) (call $f0)))");
|
||||
assert!(
|
||||
run(&wat, &host).is_ok(),
|
||||
"a large-function module currently compiles and runs"
|
||||
);
|
||||
}
|
||||
|
||||
/// The trap *kinds* wasmi distinguishes all reach the caller identically — a guest trap
|
||||
/// charged as the contract's fault — so the `unreachable` representative pins the mapping.
|
||||
/// These pin the individual kinds too, guarding against a wasmi upgrade reclassifying any of
|
||||
/// them as something other than a trap.
|
||||
#[test]
|
||||
fn a_division_by_zero_traps() {
|
||||
let host = FakeHost::new();
|
||||
let wat = module(&[ONE_PAGE], "(i32.div_s (i32.const 1) (i32.const 0))");
|
||||
assert_stage!(failure(&wat, &host), RunError::Trap(_));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signed_integer_overflow_traps() {
|
||||
let host = FakeHost::new();
|
||||
let wat = module(
|
||||
&[ONE_PAGE],
|
||||
"(i32.div_s (i32.const 0x80000000) (i32.const -1))",
|
||||
);
|
||||
assert_stage!(failure(&wat, &host), RunError::Trap(_));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_indirect_call_to_a_null_table_entry_traps() {
|
||||
let host = FakeHost::new();
|
||||
let wat = format!(
|
||||
"(module {ONE_PAGE} (type $t (func (result i32))) (table 1 funcref) \
|
||||
(func (export \"finish\") (result i32) (call_indirect (type $t) (i32.const 0))))"
|
||||
);
|
||||
assert_stage!(failure(&wat, &host), RunError::Trap(_));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_indirect_call_with_a_mismatched_signature_traps() {
|
||||
let host = FakeHost::new();
|
||||
let wat = format!(
|
||||
"(module {ONE_PAGE} (type $void (func)) (type $i32 (func (result i32))) \
|
||||
(table 1 funcref) (elem (i32.const 0) $f) (func $f (type $void)) \
|
||||
(func (export \"finish\") (result i32) (call_indirect (type $i32) (i32.const 0))))"
|
||||
);
|
||||
assert_stage!(failure(&wat, &host), RunError::Trap(_));
|
||||
}
|
||||
28
docs/build/environment.md
vendored
28
docs/build/environment.md
vendored
@@ -1,5 +1,5 @@
|
||||
Our [build instructions][BUILD.md] assume you have a C++ development
|
||||
environment complete with Git, Python, Conan, CMake, and a C++ compiler.
|
||||
environment complete with Git, Python, Conan, CMake, Rust, and a C++ compiler.
|
||||
This document explains how to set one up.
|
||||
|
||||
[BUILD.md]: ../../BUILD.md
|
||||
@@ -36,19 +36,17 @@ compiler building. Treat support for anything outside the table as best-effort.
|
||||
|
||||
Besides a compiler, building `xrpld` requires:
|
||||
|
||||
| Tool | Minimum version |
|
||||
| ------------------------------------------- | --------------- |
|
||||
| [Git](https://git-scm.com/downloads) | any recent |
|
||||
| [Python](https://www.python.org/downloads/) | 3.11 |
|
||||
| [Conan](https://conan.io/downloads.html) | 2.17 |
|
||||
| [CMake](https://cmake.org/download/) | 3.16 |
|
||||
| Tool | Minimum version |
|
||||
| ------------------------------------------- | ------------------------ |
|
||||
| [Git](https://git-scm.com/downloads) | any recent |
|
||||
| [Python](https://www.python.org/downloads/) | 3.11 |
|
||||
| [Conan](https://conan.io/downloads.html) | 2.17 |
|
||||
| [CMake](https://cmake.org/download/) | 3.16 |
|
||||
| [Rust](https://rustup.rs) | 1.95 (see [Rust](#rust)) |
|
||||
|
||||
On Linux and macOS, the [Nix development shell](./nix.md) provides all of them
|
||||
(see below). On Windows they have to be installed manually.
|
||||
|
||||
Building with `-Drust=ON` additionally requires a Rust toolchain, see
|
||||
[Rust](#rust). A default build does not, so it is not in the table above.
|
||||
|
||||
Once they are in place, verify that everything is installed and runnable with:
|
||||
|
||||
```bash
|
||||
@@ -122,18 +120,14 @@ manually:
|
||||
"x64 Native Tools Command Prompt". CI configures CMake with the
|
||||
`Visual Studio 18 2026` generator.
|
||||
- [Git for Windows](https://git-scm.com/download/win)
|
||||
- Python, Conan, and CMake, at the versions listed in
|
||||
- Python, Conan, CMake, and Rust, at the versions listed in
|
||||
[Required tools](#required-tools).
|
||||
- a [Rust toolchain](https://rustup.rs) — only needed to build with
|
||||
`-Drust=ON`, see [Rust](#rust)
|
||||
|
||||
## Rust
|
||||
|
||||
The repository contains a Rust workspace in [`crates/`](../../crates), whose
|
||||
crates are exposed to C++ through [cxx](https://cxx.rs) bindings. It is **not**
|
||||
part of a default build: the CMake `rust` option is OFF by default, and with it
|
||||
off no Rust toolchain is needed. It is only required when configuring with
|
||||
`-Drust=ON` (which is what CI does), see [Options](../../BUILD.md#options).
|
||||
crates are exposed to C++ through [cxx](https://cxx.rs) bindings and compiled by
|
||||
the CMake build, so a Rust toolchain is required.
|
||||
|
||||
The toolchain (`cargo`, `rustc`) is pinned to the channel in
|
||||
[`rust-toolchain.toml`](../../rust-toolchain.toml) at the repository root. If
|
||||
|
||||
5
docs/build/nix.md
vendored
5
docs/build/nix.md
vendored
@@ -128,9 +128,8 @@ Coverage builds (`-Dcoverage=ON`) work in the `gcc` shell (and `gcc-plain` on Li
|
||||
each ships a `gcov` matching its compiler, since Nix's cc-wrapper does not expose one.
|
||||
The `clang` shells do not include `llvm-cov`, so use a `gcc` shell for coverage.
|
||||
|
||||
Builds of the Rust crates (`-Drust=ON`) also work out of the box: every shell
|
||||
provides the Rust toolchain pinned in
|
||||
[`rust-toolchain.toml`](../../rust-toolchain.toml) (see
|
||||
The Rust toolchain the build needs is included too: every shell provides the
|
||||
channel pinned in [`rust-toolchain.toml`](../../rust-toolchain.toml) (see
|
||||
[Rust](./environment.md#rust)), plus the `cargo-audit`, `cargo-llvm-cov` and
|
||||
`cargo-nextest` plugins.
|
||||
|
||||
|
||||
@@ -543,8 +543,21 @@ public:
|
||||
setround(RoundingMode inMode);
|
||||
|
||||
/**
|
||||
* Returns which mantissa scale is currently in use for normalization.
|
||||
* Convert an integer to a RoundingMode, validating that it is in range.
|
||||
*
|
||||
* Returns std::nullopt if the value does not correspond to a valid
|
||||
* RoundingMode.
|
||||
*/
|
||||
static std::optional<RoundingMode>
|
||||
checkedRoundingMode(int mode) noexcept
|
||||
{
|
||||
if (mode < static_cast<int>(RoundingMode::ToNearest) ||
|
||||
mode > static_cast<int>(RoundingMode::Upward))
|
||||
return std::nullopt;
|
||||
return static_cast<RoundingMode>(mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* If you think you need to call this outside of unit tests, no you don't.
|
||||
*/
|
||||
static MantissaRange::MantissaScale
|
||||
|
||||
@@ -364,6 +364,16 @@ constexpr std::uint32_t kMaxInvestmentPeriod = std::chrono::seconds{std::chrono:
|
||||
*/
|
||||
constexpr std::uint8_t kMaxAssetCheckDepth = 5;
|
||||
|
||||
/**
|
||||
* Maximum length of a Data field in Escrow object that can be updated by WASM code.
|
||||
*/
|
||||
constexpr std::size_t kMaxWasmDataLength = 1 * 1024; // 1KB
|
||||
|
||||
/**
|
||||
* Maximum amount of data transfer across hostfunction<->wasm border.
|
||||
*/
|
||||
constexpr std::size_t kWasmTransferLimit = 1 << 20; // 1MB
|
||||
|
||||
/**
|
||||
* A ledger index.
|
||||
*/
|
||||
|
||||
@@ -129,8 +129,10 @@ enum TEMcodes : TERUnderlyingType {
|
||||
temARRAY_TOO_LARGE,
|
||||
temBAD_TRANSFER_FEE,
|
||||
temINVALID_INNER_BATCH,
|
||||
|
||||
temBAD_MPT,
|
||||
temBAD_CIPHERTEXT,
|
||||
temBAD_WASM,
|
||||
};
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
@@ -370,6 +372,7 @@ enum TECcodes : TERUnderlyingType {
|
||||
tecNO_DELEGATE_PERMISSION = 198,
|
||||
tecBAD_PROOF = 199,
|
||||
tecNO_SPONSOR_PERMISSION = 200,
|
||||
tecOUT_OF_GAS = 201,
|
||||
};
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
426
include/xrpl/tx/wasm/HostContext.h
Normal file
426
include/xrpl/tx/wasm/HostContext.h
Normal file
@@ -0,0 +1,426 @@
|
||||
#pragma once
|
||||
|
||||
#include <rust/cxx.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace xrpl {
|
||||
// `xrpl::HostFunctions` is forward-declared rather than included: this header is
|
||||
// `include!()`d by the cxxbridge-generated translation unit, whose target gets only the
|
||||
// project's `include/` directory - not the Boost paths that HostFunc.h -> Slice.h ->
|
||||
// strHex.h transitively need. A reference member and declarations alone do not require a
|
||||
// complete type; HostContext.cpp, compiled into libxrpl, includes the real header.
|
||||
class HostFunctions;
|
||||
|
||||
// Defined by the cxx bridge, which emits it into `xrpl_wasm_vm_ffi_cxxbridge/lib.h` from the
|
||||
// declaration in `crates/xrpl-wasm-vm-ffi` - so the data types and their wire values are
|
||||
// written once, in Rust, rather than kept in step with a copy here.
|
||||
//
|
||||
// Forward-declared for the reason `HostFunctions` above is: that generated header includes
|
||||
// this one, so naming its definition here would be circular. A scoped enum with a fixed
|
||||
// underlying type needs no definition to appear in a signature; `HostContext.cpp` includes
|
||||
// the generated header for the `switch`.
|
||||
enum class TraceDataType : std::int32_t;
|
||||
|
||||
// The host handed to the Rust wasm engine: one method per entry in the wasm host ABI,
|
||||
// each forwarding to `xrpl::HostFunctions` - the single source of truth for ledger
|
||||
// access - and lowering its typed `std::expected` result onto the ABI's wire form.
|
||||
//
|
||||
// Every method is `noexcept`, and every body catches everything: a C++ exception
|
||||
// unwinding into the Rust frames that called it would be undefined behaviour, so a caught
|
||||
// one leaves here as `HostFunctionError::InternalFatal`, which the engine reads as a fatal
|
||||
// error and reports as `tecINTERNAL`.
|
||||
//
|
||||
// Not an owner: it borrows the `HostFunctions` it is built over for the length of one run.
|
||||
class HostContext
|
||||
{
|
||||
// Non-const so a host function that mutates (`cacheLedgerObj`, `updateData`) can be
|
||||
// reached from the `const` methods below: constness of the reference is not
|
||||
// constness of the referent.
|
||||
HostFunctions& hostFunctions_;
|
||||
|
||||
public:
|
||||
HostContext(HostFunctions& hostFunctions);
|
||||
|
||||
// A byte-producing call is handed `out` - a slice aliasing either guest linear
|
||||
// memory or the engine's output buffer - writes the value only if the whole of it
|
||||
// fits, and returns the value's *true* length, which may exceed `out`. That is how a
|
||||
// guest learns the size to ask for, and it is why these methods never need to know
|
||||
// the guest's capacity: the engine owns the buffer-fit, field-cap and transfer-budget
|
||||
// rules and derives all three from the length returned here.
|
||||
//
|
||||
// A negative return is a `HostFunctionError` code.
|
||||
[[nodiscard]] std::int32_t
|
||||
getLedgerSqn(rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getParentLedgerTime(rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getParentLedgerHash(rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getBaseFee(rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The amendment is either a 32-byte id or a name; a 32-byte input is tried as an
|
||||
// id first and falls back to a name lookup. Answers 1 or 0, or a negative
|
||||
// `HostFunctionError` code.
|
||||
[[nodiscard]] std::int32_t
|
||||
isAmendmentEnabled(rust::Slice<std::uint8_t const> amendment) const noexcept;
|
||||
|
||||
// The object id must be a 32-byte uint256, else `InvalidParams`. `cacheIdx` selects
|
||||
// the slot (0 = pick a free one). Answers the slot used, or a negative
|
||||
// `HostFunctionError` code.
|
||||
[[nodiscard]] std::int32_t
|
||||
cacheLedgerObj(rust::Slice<std::uint8_t const> objId, std::int32_t cacheIdx) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getTxField(std::int32_t field, rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getCurrentLedgerObjField(std::int32_t field, rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getLedgerObjField(std::int32_t cacheIdx, std::int32_t field, rust::Slice<std::uint8_t> out)
|
||||
const noexcept;
|
||||
|
||||
// The locator is a path of little-endian i32 steps, so its byte length must be a
|
||||
// non-zero multiple of 4, else `LocatorMalformed`.
|
||||
[[nodiscard]] std::int32_t
|
||||
getTxNestedField(rust::Slice<std::uint8_t const> locator, rust::Slice<std::uint8_t> out)
|
||||
const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getCurrentLedgerObjNestedField(
|
||||
rust::Slice<std::uint8_t const> locator,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getLedgerObjNestedField(
|
||||
std::int32_t cacheIdx,
|
||||
rust::Slice<std::uint8_t const> locator,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// Answers the array's element count directly, or a negative `HostFunctionError`
|
||||
// code (`NoArray` if the field is not an array).
|
||||
[[nodiscard]] std::int32_t
|
||||
getTxArrayLen(std::int32_t field) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getCurrentLedgerObjArrayLen(std::int32_t field) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getLedgerObjArrayLen(std::int32_t cacheIdx, std::int32_t field) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getTxNestedArrayLen(rust::Slice<std::uint8_t const> locator) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getCurrentLedgerObjNestedArrayLen(rust::Slice<std::uint8_t const> locator) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getLedgerObjNestedArrayLen(std::int32_t cacheIdx, rust::Slice<std::uint8_t const> locator)
|
||||
const noexcept;
|
||||
|
||||
// Answers 1/0 for a valid/invalid signature, or a negative `HostFunctionError`.
|
||||
[[nodiscard]] std::int32_t
|
||||
checkSignature(
|
||||
rust::Slice<std::uint8_t const> message,
|
||||
rust::Slice<std::uint8_t const> signature,
|
||||
rust::Slice<std::uint8_t const> pubkey) const noexcept;
|
||||
|
||||
// The account id must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
accountKeylet(rust::Slice<std::uint8_t const> account, rust::Slice<std::uint8_t> out)
|
||||
const noexcept;
|
||||
|
||||
// Each asset is decoded by length (24 = MPT, 20 = XRP, 40 = issue), else
|
||||
// `InvalidParams`. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
ammKeylet(
|
||||
rust::Slice<std::uint8_t const> asset1,
|
||||
rust::Slice<std::uint8_t const> asset2,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The account id must be 20 bytes, else `InvalidParams`. `seq` carries the guest's
|
||||
// u32 as its i32 bit pattern. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
checkKeylet(
|
||||
rust::Slice<std::uint8_t const> account,
|
||||
std::int32_t seq,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// Subject and issuer must each be 20 bytes, else `InvalidParams`. Writes the
|
||||
// 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
credentialKeylet(
|
||||
rust::Slice<std::uint8_t const> subject,
|
||||
rust::Slice<std::uint8_t const> issuer,
|
||||
rust::Slice<std::uint8_t const> credentialType,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// Both accounts must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
delegateKeylet(
|
||||
rust::Slice<std::uint8_t const> account,
|
||||
rust::Slice<std::uint8_t const> authorize,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// Both accounts must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
depositPreauthKeylet(
|
||||
rust::Slice<std::uint8_t const> account,
|
||||
rust::Slice<std::uint8_t const> authorize,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The account id must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
didKeylet(rust::Slice<std::uint8_t const> account, rust::Slice<std::uint8_t> out)
|
||||
const noexcept;
|
||||
|
||||
// The account id must be 20 bytes, else `InvalidParams`. `seq` carries the guest's
|
||||
// u32 as its i32 bit pattern. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
escrowKeylet(
|
||||
rust::Slice<std::uint8_t const> account,
|
||||
std::int32_t seq,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// Both accounts and the currency must each be 20 bytes, else `InvalidParams`.
|
||||
// Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
trustLineKeylet(
|
||||
rust::Slice<std::uint8_t const> account1,
|
||||
rust::Slice<std::uint8_t const> account2,
|
||||
rust::Slice<std::uint8_t const> currency,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The issuer id must be 20 bytes, else `InvalidParams`. `seq` carries the guest's
|
||||
// u32 as its i32 bit pattern. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
mptokenIssuanceKeylet(
|
||||
rust::Slice<std::uint8_t const> issuer,
|
||||
std::int32_t seq,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The MPT id must be 24 bytes and the holder 20, else `InvalidParams`. Writes the
|
||||
// 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
mptokenKeylet(
|
||||
rust::Slice<std::uint8_t const> mptid,
|
||||
rust::Slice<std::uint8_t const> holder,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The account id must be 20 bytes, else `InvalidParams`. `seq` carries the guest's
|
||||
// u32 as its i32 bit pattern. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
nftokenOfferKeylet(
|
||||
rust::Slice<std::uint8_t const> account,
|
||||
std::int32_t seq,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The account id must be 20 bytes, else `InvalidParams`. `seq` carries the guest's
|
||||
// u32 as its i32 bit pattern. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
offerKeylet(
|
||||
rust::Slice<std::uint8_t const> account,
|
||||
std::int32_t seq,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The account id must be 20 bytes, else `InvalidParams`. `docId` carries the
|
||||
// guest's u32 as its i32 bit pattern. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
oracleKeylet(
|
||||
rust::Slice<std::uint8_t const> account,
|
||||
std::int32_t docId,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// Both account ids must be 20 bytes, else `InvalidParams`. `seq` carries the
|
||||
// guest's u32 as its i32 bit pattern. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
paychannelKeylet(
|
||||
rust::Slice<std::uint8_t const> account,
|
||||
rust::Slice<std::uint8_t const> destination,
|
||||
std::int32_t seq,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The account id must be 20 bytes, else `InvalidParams`. `seq` carries the guest's
|
||||
// u32 as its i32 bit pattern. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
permissionedDomainKeylet(
|
||||
rust::Slice<std::uint8_t const> account,
|
||||
std::int32_t seq,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The account id must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
signerListKeylet(rust::Slice<std::uint8_t const> account, rust::Slice<std::uint8_t> out)
|
||||
const noexcept;
|
||||
|
||||
// The account id must be 20 bytes, else `InvalidParams`. `seq` carries the guest's
|
||||
// u32 as its i32 bit pattern. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
ticketKeylet(
|
||||
rust::Slice<std::uint8_t const> account,
|
||||
std::int32_t seq,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The account id must be 20 bytes, else `InvalidParams`. `seq` carries the guest's
|
||||
// u32 as its i32 bit pattern. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
vaultKeylet(
|
||||
rust::Slice<std::uint8_t const> account,
|
||||
std::int32_t seq,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
sha512Half(rust::Slice<std::uint8_t const> data, rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// Renders `data` as `dataType` says, and hands the text to `HostFunctions::trace`, which
|
||||
// is what puts it in this node's log.
|
||||
//
|
||||
// The one call that answers nothing: the guest's wasm function has no result, and this
|
||||
// node's own log is the only thing a trace touches, so a buffer that does not hold what
|
||||
// it claims is logged here and dropped rather than reported to a contract.
|
||||
void
|
||||
trace(rust::Str msg, rust::Slice<std::uint8_t const> data, TraceDataType dataType)
|
||||
const noexcept;
|
||||
|
||||
// Stores `data` as the current object's data field and returns the number of bytes
|
||||
// stored, or a negative `HostFunctionError` code.
|
||||
[[nodiscard]] std::int32_t
|
||||
updateData(rust::Slice<std::uint8_t const> data) const noexcept;
|
||||
|
||||
// The account id must be 20 bytes and the nft id 32 bytes, else `InvalidParams`.
|
||||
// Writes the token's URI bytes.
|
||||
[[nodiscard]] std::int32_t
|
||||
getNFT(
|
||||
rust::Slice<std::uint8_t const> account,
|
||||
rust::Slice<std::uint8_t const> nftId,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The nft id must be 32 bytes, else `InvalidParams`. Writes the 20-byte issuer
|
||||
// account encoded in the id.
|
||||
[[nodiscard]] std::int32_t
|
||||
getNFTIssuer(rust::Slice<std::uint8_t const> nftId, rust::Slice<std::uint8_t> out)
|
||||
const noexcept;
|
||||
|
||||
// The nft id must be 32 bytes, else `InvalidParams`. Writes the taxon as its four
|
||||
// little-endian bytes.
|
||||
[[nodiscard]] std::int32_t
|
||||
getNFTTaxon(rust::Slice<std::uint8_t const> nftId, rust::Slice<std::uint8_t> out)
|
||||
const noexcept;
|
||||
|
||||
// The nft id must be 32 bytes, else `InvalidParams`. Returns the flags, or a
|
||||
// negative `HostFunctionError` code.
|
||||
[[nodiscard]] std::int32_t
|
||||
getNFTFlags(rust::Slice<std::uint8_t const> nftId) const noexcept;
|
||||
|
||||
// The nft id must be 32 bytes, else `InvalidParams`. Returns the transfer fee, or a
|
||||
// negative `HostFunctionError` code.
|
||||
[[nodiscard]] std::int32_t
|
||||
getNFTTransferFee(rust::Slice<std::uint8_t const> nftId) const noexcept;
|
||||
|
||||
// The nft id must be 32 bytes, else `InvalidParams`. Writes the sequence number as
|
||||
// its four little-endian bytes.
|
||||
[[nodiscard]] std::int32_t
|
||||
getNFTSequence(rust::Slice<std::uint8_t const> nftId, rust::Slice<std::uint8_t> out)
|
||||
const noexcept;
|
||||
|
||||
// Float / number arithmetic. A float is an XRPL `Number` in serialized form;
|
||||
// `mode` is a rounding mode. Each writes the result float bytes unless noted.
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
floatFromInt(std::int64_t x, std::int32_t mode, rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The integer region must be eight bytes, else `InvalidParams`.
|
||||
[[nodiscard]] std::int32_t
|
||||
floatFromUint(
|
||||
rust::Slice<std::uint8_t const> x,
|
||||
std::int32_t mode,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// `amount` must be a serialized `STAmount`, else `InvalidParams`.
|
||||
[[nodiscard]] std::int32_t
|
||||
floatFromSTAmount(
|
||||
rust::Slice<std::uint8_t const> amount,
|
||||
std::int32_t mode,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// `number` must be a serialized `STNumber`, else `InvalidParams`.
|
||||
[[nodiscard]] std::int32_t
|
||||
floatFromSTNumber(
|
||||
rust::Slice<std::uint8_t const> number,
|
||||
std::int32_t mode,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// Rounds the float to an integer, written as its eight little-endian bytes.
|
||||
[[nodiscard]] std::int32_t
|
||||
floatToInt(rust::Slice<std::uint8_t const> x, std::int32_t mode, rust::Slice<std::uint8_t> out)
|
||||
const noexcept;
|
||||
|
||||
// Writes the mantissa (eight little-endian bytes) and the exponent (four little-
|
||||
// endian bytes) to two output regions; returns their total size.
|
||||
[[nodiscard]] std::int32_t
|
||||
floatToMantExp(
|
||||
rust::Slice<std::uint8_t const> x,
|
||||
rust::Slice<std::uint8_t> mantissaOut,
|
||||
rust::Slice<std::uint8_t> exponentOut) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
floatFromMantExp(
|
||||
std::int64_t mantissa,
|
||||
std::int32_t exponent,
|
||||
std::int32_t mode,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// Returns a negative, zero, or positive scalar as `x` is less than, equal to, or
|
||||
// greater than `y`, or a negative `HostFunctionError` code on failure.
|
||||
[[nodiscard]] std::int32_t
|
||||
floatCompare(rust::Slice<std::uint8_t const> x, rust::Slice<std::uint8_t const> y)
|
||||
const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
floatAdd(
|
||||
rust::Slice<std::uint8_t const> x,
|
||||
rust::Slice<std::uint8_t const> y,
|
||||
std::int32_t mode,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
floatSubtract(
|
||||
rust::Slice<std::uint8_t const> x,
|
||||
rust::Slice<std::uint8_t const> y,
|
||||
std::int32_t mode,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
floatMultiply(
|
||||
rust::Slice<std::uint8_t const> x,
|
||||
rust::Slice<std::uint8_t const> y,
|
||||
std::int32_t mode,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
floatDivide(
|
||||
rust::Slice<std::uint8_t const> x,
|
||||
rust::Slice<std::uint8_t const> y,
|
||||
std::int32_t mode,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
floatRoot(
|
||||
rust::Slice<std::uint8_t const> x,
|
||||
std::int32_t n,
|
||||
std::int32_t mode,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
floatPower(
|
||||
rust::Slice<std::uint8_t const> x,
|
||||
std::int32_t n,
|
||||
std::int32_t mode,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
};
|
||||
|
||||
} // namespace xrpl
|
||||
473
include/xrpl/tx/wasm/HostFunc.h
Normal file
473
include/xrpl/tx/wasm/HostFunc.h
Normal file
@@ -0,0 +1,473 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/basics/Slice.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/UintTypes.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
namespace wasm_float {
|
||||
|
||||
std::string
|
||||
floatToString(Slice const& data);
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatFromIntImpl(int64_t x, int32_t mode);
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatFromUintImpl(uint64_t x, int32_t mode);
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatFromSTAmountImpl(STAmount const& x, int32_t mode);
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatFromSTNumberImpl(STNumber const& x, int32_t mode);
|
||||
|
||||
std::expected<int64_t, HostFunctionError>
|
||||
floatToIntImpl(Slice const& x, int32_t mode);
|
||||
|
||||
std::expected<FloatPair, HostFunctionError>
|
||||
floatToMantExpImpl(Slice const& x);
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatFromMantExpImpl(int64_t mantissa, int32_t exponent, int32_t mode);
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
floatCompareImpl(Slice const& x, Slice const& y);
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatAddImpl(Slice const& x, Slice const& y, int32_t mode);
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatSubtractImpl(Slice const& x, Slice const& y, int32_t mode);
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatMultiplyImpl(Slice const& x, Slice const& y, int32_t mode);
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatDivideImpl(Slice const& x, Slice const& y, int32_t mode);
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatRootImpl(Slice const& x, int32_t n, int32_t mode);
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatPowerImpl(Slice const& x, int32_t n, int32_t mode);
|
||||
|
||||
} // namespace wasm_float
|
||||
|
||||
// Intended to work only through wasm runtime. Don't call them directly, except with unit tests
|
||||
class HostFunctions
|
||||
{
|
||||
protected:
|
||||
beast::Journal j_;
|
||||
|
||||
public:
|
||||
HostFunctions(beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) : j_(j)
|
||||
{
|
||||
}
|
||||
|
||||
[[nodiscard]] beast::Journal
|
||||
getJournal() const
|
||||
{
|
||||
return j_;
|
||||
}
|
||||
|
||||
// LCOV_EXCL_START
|
||||
|
||||
[[nodiscard]] virtual bool
|
||||
checkSelf() const
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<std::uint32_t, HostFunctionError>
|
||||
getLedgerSqn() const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<std::uint32_t, HostFunctionError>
|
||||
getParentLedgerTime() const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Hash, HostFunctionError>
|
||||
getParentLedgerHash() const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<uint32_t, HostFunctionError>
|
||||
getBaseFee() const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<int32_t, HostFunctionError>
|
||||
isAmendmentEnabled(uint256 const& amendmentId) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<int32_t, HostFunctionError>
|
||||
isAmendmentEnabled(std::string_view const& amendmentName) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
virtual std::expected<int32_t, HostFunctionError>
|
||||
cacheLedgerObj(uint256 const& objId, int32_t cacheIdx)
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
getTxField(SField const& fname) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
getCurrentLedgerObjField(SField const& fname) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
getLedgerObjField(int32_t cacheIdx, SField const& fname) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
getTxNestedField(FieldLocator const& locator) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
getCurrentLedgerObjNestedField(FieldLocator const& locator) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
getLedgerObjNestedField(int32_t cacheIdx, FieldLocator const& locator) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<int32_t, HostFunctionError>
|
||||
getTxArrayLen(SField const& fname) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<int32_t, HostFunctionError>
|
||||
getCurrentLedgerObjArrayLen(SField const& fname) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<int32_t, HostFunctionError>
|
||||
getLedgerObjArrayLen(int32_t cacheIdx, SField const& fname) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<int32_t, HostFunctionError>
|
||||
getTxNestedArrayLen(FieldLocator const& locator) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<int32_t, HostFunctionError>
|
||||
getCurrentLedgerObjNestedArrayLen(FieldLocator const& locator) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<int32_t, HostFunctionError>
|
||||
getLedgerObjNestedArrayLen(int32_t cacheIdx, FieldLocator const& locator) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
virtual std::expected<int32_t, HostFunctionError>
|
||||
updateData(Slice const& data)
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<int32_t, HostFunctionError>
|
||||
checkSignature(Slice const& message, Slice const& signature, Slice const& pubkey) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Hash, HostFunctionError>
|
||||
computeSha512HalfHash(Slice const& data) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
accountKeylet(AccountID const& account) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
ammKeylet(Asset const& issue1, Asset const& issue2) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
checkKeylet(AccountID const& account, std::uint32_t seq) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
credentialKeylet(AccountID const& subject, AccountID const& issuer, Slice const& credentialType)
|
||||
const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
didKeylet(AccountID const& account) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
delegateKeylet(AccountID const& account, AccountID const& authorize) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
depositPreauthKeylet(AccountID const& account, AccountID const& authorize) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
escrowKeylet(AccountID const& account, std::uint32_t seq) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
trustLineKeylet(AccountID const& account1, AccountID const& account2, Currency const& currency)
|
||||
const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
mptokenIssuanceKeylet(AccountID const& issuer, std::uint32_t seq) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
mptokenKeylet(MPTID const& mptid, AccountID const& holder) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
nftokenOfferKeylet(AccountID const& account, std::uint32_t seq) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
offerKeylet(AccountID const& account, std::uint32_t seq) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
oracleKeylet(AccountID const& account, std::uint32_t docId) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
paychannelKeylet(AccountID const& account, AccountID const& destination, std::uint32_t seq)
|
||||
const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
permissionedDomainKeylet(AccountID const& account, std::uint32_t seq) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
signerListKeylet(AccountID const& account) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
ticketKeylet(AccountID const& account, std::uint32_t seq) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
vaultKeylet(AccountID const& account, std::uint32_t seq) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
getNFT(AccountID const& account, uint256 const& nftId) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
getNFTIssuer(uint256 const& nftId) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<std::uint32_t, HostFunctionError>
|
||||
getNFTTaxon(uint256 const& nftId) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<int32_t, HostFunctionError>
|
||||
getNFTFlags(uint256 const& nftId) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<int32_t, HostFunctionError>
|
||||
getNFTTransferFee(uint256 const& nftId) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<std::uint32_t, HostFunctionError>
|
||||
getNFTSequence(uint256 const& nftId) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
// A no-op rather than Unimplemented: trace only writes to the local log.
|
||||
// trace_wrap has already rendered the guest's buffer into `data`.
|
||||
virtual void
|
||||
trace(std::string_view const& msg, std::string_view const& data) const
|
||||
{
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
floatFromInt(int64_t x, int32_t mode) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
floatFromUint(uint64_t x, int32_t mode) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
floatFromSTAmount(STAmount const& x, int32_t mode) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
floatFromSTNumber(STNumber const& x, int32_t mode) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<int64_t, HostFunctionError>
|
||||
floatToInt(Slice const& x, int32_t mode) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<FloatPair, HostFunctionError>
|
||||
floatToMantExp(Slice const& x) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
floatFromMantExp(int64_t mantissa, int32_t exponent, int32_t mode) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<int32_t, HostFunctionError>
|
||||
floatCompare(Slice const& x, Slice const& y) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
floatAdd(Slice const& x, Slice const& y, int32_t mode) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
floatSubtract(Slice const& x, Slice const& y, int32_t mode) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
floatMultiply(Slice const& x, Slice const& y, int32_t mode) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
floatDivide(Slice const& x, Slice const& y, int32_t mode) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
floatRoot(Slice const& x, int32_t n, int32_t mode) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
floatPower(Slice const& x, int32_t n, int32_t mode) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
virtual ~HostFunctions() = default;
|
||||
// LCOV_EXCL_STOP
|
||||
};
|
||||
|
||||
} // namespace xrpl
|
||||
290
include/xrpl/tx/wasm/HostFuncImpl.h
Normal file
290
include/xrpl/tx/wasm/HostFuncImpl.h
Normal file
@@ -0,0 +1,290 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/basics/Slice.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/core/ServiceRegistry.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/Keylet.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STAmount.h>
|
||||
#include <xrpl/protocol/UintTypes.h>
|
||||
#include <xrpl/tx/ApplyContext.h>
|
||||
#include <xrpl/tx/wasm/HostFunc.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
// Intended to work only through wasm runtime. Don't call them directly, except with unit tests
|
||||
class WasmHostFunctionsImpl : public HostFunctions
|
||||
{
|
||||
ApplyContext& ctx_;
|
||||
|
||||
Keylet leKey_;
|
||||
mutable std::optional<std::shared_ptr<SLE const>> currentLedgerObj_;
|
||||
|
||||
static int constexpr maxCache = 256;
|
||||
std::array<std::shared_ptr<SLE const>, maxCache> cache_;
|
||||
|
||||
std::optional<Bytes> data_;
|
||||
|
||||
public:
|
||||
std::expected<std::shared_ptr<SLE const>, HostFunctionError>
|
||||
getCurrentLedgerObj() const
|
||||
{
|
||||
if (!currentLedgerObj_)
|
||||
currentLedgerObj_ = ctx_.view().read(leKey_);
|
||||
if (*currentLedgerObj_)
|
||||
return *currentLedgerObj_;
|
||||
return std::unexpected(HostFunctionError::LedgerObjNotFound);
|
||||
}
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
normalizeCacheIndex(int32_t cacheIdx) const
|
||||
{
|
||||
--cacheIdx;
|
||||
if (cacheIdx < 0 || cacheIdx >= maxCache)
|
||||
return std::unexpected(HostFunctionError::SlotOutRange);
|
||||
if (!cache_[cacheIdx])
|
||||
return std::unexpected(HostFunctionError::EmptySlot);
|
||||
return cacheIdx;
|
||||
}
|
||||
|
||||
template <typename F>
|
||||
void
|
||||
log(std::string_view const& msg, F&& dataFn) const
|
||||
{
|
||||
#ifdef DEBUG_OUTPUT
|
||||
auto& j = std::cerr;
|
||||
#else
|
||||
if (!getJournal().active(beast::Severity::Trace))
|
||||
return;
|
||||
auto j = getJournal().trace();
|
||||
#endif
|
||||
j << "WasmTrace[" << toShortString(leKey_.key) << "]: " << msg << " " << dataFn();
|
||||
|
||||
#ifdef DEBUG_OUTPUT
|
||||
j << std::endl;
|
||||
#endif
|
||||
}
|
||||
|
||||
public:
|
||||
WasmHostFunctionsImpl(ApplyContext& ct, Keylet const& leKey)
|
||||
: HostFunctions(ct.journal), ctx_(ct), leKey_(leKey)
|
||||
{
|
||||
}
|
||||
|
||||
bool
|
||||
checkSelf() const override
|
||||
{
|
||||
return !currentLedgerObj_ && !data_ &&
|
||||
std::ranges::none_of(cache_, [](auto const& p) { return !!p; });
|
||||
}
|
||||
|
||||
std::optional<Bytes> const&
|
||||
getData() const
|
||||
{
|
||||
return data_;
|
||||
}
|
||||
|
||||
std::expected<std::uint32_t, HostFunctionError>
|
||||
getLedgerSqn() const override;
|
||||
|
||||
std::expected<std::uint32_t, HostFunctionError>
|
||||
getParentLedgerTime() const override;
|
||||
|
||||
std::expected<Hash, HostFunctionError>
|
||||
getParentLedgerHash() const override;
|
||||
|
||||
std::expected<std::uint32_t, HostFunctionError>
|
||||
getBaseFee() const override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
isAmendmentEnabled(uint256 const& amendmentId) const override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
isAmendmentEnabled(std::string_view const& amendmentName) const override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
cacheLedgerObj(uint256 const& objId, int32_t cacheIdx) override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
getTxField(SField const& fname) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
getCurrentLedgerObjField(SField const& fname) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
getLedgerObjField(int32_t cacheIdx, SField const& fname) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
getTxNestedField(FieldLocator const& locator) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
getCurrentLedgerObjNestedField(FieldLocator const& locator) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
getLedgerObjNestedField(int32_t cacheIdx, FieldLocator const& locator) const override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
getTxArrayLen(SField const& fname) const override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
getCurrentLedgerObjArrayLen(SField const& fname) const override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
getLedgerObjArrayLen(int32_t cacheIdx, SField const& fname) const override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
getTxNestedArrayLen(FieldLocator const& locator) const override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
getCurrentLedgerObjNestedArrayLen(FieldLocator const& locator) const override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
getLedgerObjNestedArrayLen(int32_t cacheIdx, FieldLocator const& locator) const override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
updateData(Slice const& data) override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
checkSignature(Slice const& message, Slice const& signature, Slice const& pubkey)
|
||||
const override;
|
||||
|
||||
std::expected<Hash, HostFunctionError>
|
||||
computeSha512HalfHash(Slice const& data) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
accountKeylet(AccountID const& account) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
ammKeylet(Asset const& issue1, Asset const& issue2) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
checkKeylet(AccountID const& account, std::uint32_t seq) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
credentialKeylet(AccountID const& subject, AccountID const& issuer, Slice const& credentialType)
|
||||
const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
didKeylet(AccountID const& account) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
delegateKeylet(AccountID const& account, AccountID const& authorize) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
depositPreauthKeylet(AccountID const& account, AccountID const& authorize) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
escrowKeylet(AccountID const& account, std::uint32_t seq) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
trustLineKeylet(AccountID const& account1, AccountID const& account2, Currency const& currency)
|
||||
const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
mptokenIssuanceKeylet(AccountID const& issuer, std::uint32_t seq) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
mptokenKeylet(MPTID const& mptid, AccountID const& holder) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
nftokenOfferKeylet(AccountID const& account, std::uint32_t seq) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
offerKeylet(AccountID const& account, std::uint32_t seq) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
oracleKeylet(AccountID const& account, std::uint32_t docId) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
paychannelKeylet(AccountID const& account, AccountID const& destination, std::uint32_t seq)
|
||||
const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
permissionedDomainKeylet(AccountID const& account, std::uint32_t seq) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
signerListKeylet(AccountID const& account) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
ticketKeylet(AccountID const& account, std::uint32_t seq) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
vaultKeylet(AccountID const& account, std::uint32_t seq) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
getNFT(AccountID const& account, uint256 const& nftId) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
getNFTIssuer(uint256 const& nftId) const override;
|
||||
|
||||
std::expected<std::uint32_t, HostFunctionError>
|
||||
getNFTTaxon(uint256 const& nftId) const override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
getNFTFlags(uint256 const& nftId) const override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
getNFTTransferFee(uint256 const& nftId) const override;
|
||||
|
||||
std::expected<std::uint32_t, HostFunctionError>
|
||||
getNFTSequence(uint256 const& nftId) const override;
|
||||
|
||||
void
|
||||
trace(std::string_view const& msg, std::string_view const& data) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatFromInt(int64_t x, int32_t mode) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatFromUint(uint64_t x, int32_t mode) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatFromSTAmount(STAmount const& x, int32_t mode) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatFromSTNumber(STNumber const& x, int32_t mode) const override;
|
||||
|
||||
std::expected<int64_t, HostFunctionError>
|
||||
floatToInt(Slice const& x, int32_t mode) const override;
|
||||
|
||||
std::expected<FloatPair, HostFunctionError>
|
||||
floatToMantExp(Slice const& x) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatFromMantExp(int64_t mantissa, int32_t exponent, int32_t mode) const override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
floatCompare(Slice const& x, Slice const& y) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatAdd(Slice const& x, Slice const& y, int32_t mode) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatSubtract(Slice const& x, Slice const& y, int32_t mode) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatMultiply(Slice const& x, Slice const& y, int32_t mode) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatDivide(Slice const& x, Slice const& y, int32_t mode) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatRoot(Slice const& x, int32_t n, int32_t mode) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatPower(Slice const& x, int32_t n, int32_t mode) const override;
|
||||
};
|
||||
|
||||
} // namespace xrpl
|
||||
41
include/xrpl/tx/wasm/README.md
Normal file
41
include/xrpl/tx/wasm/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# WASM Module for Programmable Escrows
|
||||
|
||||
WebAssembly execution for programmable escrows. When an escrow is finished, its contract
|
||||
runs to decide whether the release conditions are met. Specification:
|
||||
[XLS-0102: WASM VM](https://xls.xrpl.org/xls/XLS-0102-wasm-vm.html).
|
||||
|
||||
The engine itself is Rust (`crates/xrpl-wasm-vm`, over wasmi), reached through a cxx
|
||||
bridge.
|
||||
|
||||
## What is in this directory
|
||||
|
||||
- **`WasmVM.h`** — the entry points xrpld calls: `runEscrowWasm` (execute a contract,
|
||||
returning a result and its gas cost, or a `WasmTER`) and `preflightEscrowWasm` (screen a
|
||||
module with no host and no execution). Both own their TER maps.
|
||||
- **`HostFunc.h`** — the `HostFunctions` interface: one virtual per host function, each
|
||||
defaulting to `Unimplemented`, returning `std::expected<T, HostFunctionError>`.
|
||||
- **`HostFuncImpl.h`** — `WasmHostFunctionsImpl`, the implementation over an
|
||||
`ApplyContext&`. Bodies are split across `HostFuncImpl*.cpp` by category.
|
||||
- **`HostContext.h`** — the bridge's C++ half: an ABI-shaped, `noexcept` view of
|
||||
`HostFunctions` that the engine calls back into. Nothing may unwind into Rust, so every
|
||||
method catches everything — through `guarded()`, except `trace`, which answers the guest
|
||||
nothing and so has its own catch that only logs.
|
||||
- **`WasmCommon.h`** — the shared vocabulary: `HostFunctionError` (the codes a contract
|
||||
sees), `Bytes`, `FieldLocator`, `WasmTER`, `adjustWasmEndianess`, which is where the
|
||||
boundary's byte order is decided, and `guarded()`, the catch that turns a throwing host
|
||||
body into a code the engine can read.
|
||||
|
||||
## Host functions
|
||||
|
||||
Grouped by what they reach: ledger information; transaction and ledger-object field access;
|
||||
keylet construction; cryptography; float arithmetic; NFT queries; tracing.
|
||||
|
||||
The wire names and per-call gas costs are declared in `crates/xrpl-host-functions` —
|
||||
one `host_functions!` block that generates the ABI trait and the spec table. That
|
||||
declaration is the single source of truth; `HostFunc.h` is the C++ side of it.
|
||||
|
||||
## Entry point
|
||||
|
||||
A module must export `escrow_finish` (`escrowFunctionName`) taking no parameters and
|
||||
returning `int32_t`: positive means the conditions are met, zero or negative rejects the
|
||||
finish. Everything the contract needs it asks for through a host call.
|
||||
187
include/xrpl/tx/wasm/WasmCommon.h
Normal file
187
include/xrpl/tx/wasm/WasmCommon.h
Normal file
@@ -0,0 +1,187 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/basics/Log.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/basics/contract.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
|
||||
#include <bit>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <exception>
|
||||
#include <limits>
|
||||
#include <optional>
|
||||
#include <source_location>
|
||||
#include <stdexcept>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
using Bytes = std::vector<std::uint8_t>;
|
||||
using Hash = xrpl::uint256;
|
||||
using FloatPair = std::pair<int64_t, int32_t>;
|
||||
|
||||
enum class HostFunctionError : int32_t {
|
||||
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,
|
||||
|
||||
// The call was not served at all, so the engine stops the run and the transaction is
|
||||
// tecINTERNAL rather than the contract being handed a code to interpret. `guarded`
|
||||
// answers it for a host body that throws.
|
||||
//
|
||||
// The only entry outside the -1 ..= -20 range a contract reads: it needs no number
|
||||
// there, and INT32_MIN cannot collide with a code appended above. Negative so that a
|
||||
// reader treating it as an ordinary failure is still right.
|
||||
InternalFatal = std::numeric_limits<int32_t>::min(),
|
||||
};
|
||||
|
||||
template <typename T>
|
||||
struct WasmResult
|
||||
{
|
||||
T result;
|
||||
int64_t cost;
|
||||
};
|
||||
using EscrowResult = WasmResult<int32_t>;
|
||||
|
||||
// Engine error when wasm does not run to completion. `cost` is the gas consumed
|
||||
// when meaningful (tecOUT_OF_GAS / tecFAILED_PROCESSING; caller writes it to tx
|
||||
// metadata); std::nullopt for tecINTERNAL and malformed input (no gas reported).
|
||||
struct WasmTER
|
||||
{
|
||||
TER ter;
|
||||
std::optional<int64_t> cost;
|
||||
};
|
||||
|
||||
class FieldLocator
|
||||
{
|
||||
int32_t const* ptr_ = nullptr;
|
||||
uint32_t size_ = 0;
|
||||
std::vector<int32_t> buf_;
|
||||
|
||||
public:
|
||||
FieldLocator(std::vector<int32_t>&& buf)
|
||||
: ptr_(&buf[0]), size_(buf.size()), buf_(std::move(buf))
|
||||
{
|
||||
}
|
||||
|
||||
FieldLocator(int32_t const* ptr, uint32_t const size) : ptr_(ptr), size_(size)
|
||||
{
|
||||
}
|
||||
|
||||
FieldLocator(FieldLocator const&) = delete;
|
||||
FieldLocator&
|
||||
operator=(FieldLocator const&) = delete;
|
||||
FieldLocator(FieldLocator&&) = default;
|
||||
FieldLocator&
|
||||
operator=(FieldLocator&&) = default;
|
||||
|
||||
int32_t
|
||||
operator[](unsigned i) const
|
||||
{
|
||||
if (i >= size_)
|
||||
Throw<std::runtime_error>("index out of bounds");
|
||||
return ptr_[i];
|
||||
}
|
||||
|
||||
[[nodiscard]] uint32_t
|
||||
size() const
|
||||
{
|
||||
return size_;
|
||||
}
|
||||
|
||||
[[nodiscard]] int32_t const*
|
||||
data() const
|
||||
{
|
||||
return ptr_;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool
|
||||
empty() const
|
||||
{
|
||||
return size_ == 0;
|
||||
}
|
||||
};
|
||||
|
||||
template <typename T, size_t Size = sizeof(T)>
|
||||
constexpr T
|
||||
adjustWasmEndianessHlp(T x)
|
||||
{
|
||||
static_assert(std::is_integral_v<T>, "Only integral types");
|
||||
if constexpr (Size > 1)
|
||||
{
|
||||
using U = std::make_unsigned_t<T>;
|
||||
U u = static_cast<U>(x);
|
||||
U const low = (u & 0xFF) << ((Size - 1) << 3);
|
||||
u = adjustWasmEndianessHlp<U, Size - 1>(u >> 8);
|
||||
return static_cast<T>(low | u);
|
||||
}
|
||||
|
||||
return x;
|
||||
}
|
||||
|
||||
template <typename T, size_t Size = sizeof(T)>
|
||||
constexpr T
|
||||
adjustWasmEndianess(T x)
|
||||
{
|
||||
// LCOV_EXCL_START
|
||||
static_assert(std::is_integral_v<T>, "Only integral types");
|
||||
if constexpr (std::endian::native == std::endian::big)
|
||||
{
|
||||
return adjustWasmEndianessHlp(x);
|
||||
}
|
||||
return x;
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
constexpr int32_t
|
||||
hfErrorToInt(HostFunctionError e)
|
||||
{
|
||||
return static_cast<int32_t>(e);
|
||||
}
|
||||
|
||||
template <class Body>
|
||||
std::invoke_result_t<Body>
|
||||
guarded(
|
||||
beast::Journal journal,
|
||||
std::invoke_result_t<Body> onThrow,
|
||||
Body&& body,
|
||||
std::source_location const location = std::source_location::current()) noexcept
|
||||
{
|
||||
try
|
||||
{
|
||||
return body();
|
||||
}
|
||||
catch (std::exception const& e)
|
||||
{
|
||||
JLOG(journal.error()) << "wasm: " << location.function_name() << " threw: " << e.what();
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
JLOG(journal.error()) << "wasm: " << location.function_name() << " threw";
|
||||
}
|
||||
|
||||
return onThrow;
|
||||
}
|
||||
|
||||
} // namespace xrpl
|
||||
50
include/xrpl/tx/wasm/WasmVM.h
Normal file
50
include/xrpl/tx/wasm/WasmVM.h
Normal file
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/tx/wasm/HostFunc.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
// The export a programmable escrow's contract is run through.
|
||||
std::string_view inline constexpr escrowFunctionName = "escrow_finish";
|
||||
|
||||
// Run `wasmCode`'s `funcName` export with `gasLimit` gas, servicing its host calls
|
||||
// through `hfs`.
|
||||
//
|
||||
// On success the result is what the contract returned - positive means the escrow may
|
||||
// finish - together with the gas it consumed. On failure it is the TER to apply and,
|
||||
// when the number means anything, the gas to write to transaction metadata: a contract
|
||||
// that traps or exhausts its budget is charged for what it burned, while a `tecINTERNAL`
|
||||
// reports no cost because the fault is the node's rather than the transaction's.
|
||||
std::expected<EscrowResult, WasmTER>
|
||||
runEscrowWasm(
|
||||
Bytes const& wasmCode,
|
||||
HostFunctions& hfs,
|
||||
std::int64_t gasLimit,
|
||||
std::string_view funcName = escrowFunctionName) noexcept;
|
||||
|
||||
// Screen `wasmCode`: whether `runEscrowWasm` would refuse it before the contract's
|
||||
// first instruction. Compiles the module and reads its imports and exports; runs
|
||||
// nothing.
|
||||
//
|
||||
// Takes no `HostFunctions`, because the verdict comes from the compiled module alone.
|
||||
// That is what makes this callable from a transactor's `preflight`, which has no view
|
||||
// to build a host over.
|
||||
//
|
||||
// `temBAD_WASM` for every fault in the module - the transaction carries something this
|
||||
// engine cannot run, so it is refused before it can reach the ledger.
|
||||
// `telFAILED_PROCESSING` if the engine itself failed: nothing was learned about the
|
||||
// module, and a defect here is not evidence that the transaction is malformed.
|
||||
NotTEC
|
||||
preflightEscrowWasm(
|
||||
Bytes const& wasmCode,
|
||||
beast::Journal j,
|
||||
std::string_view funcName = escrowFunctionName) noexcept;
|
||||
|
||||
} // namespace xrpl
|
||||
@@ -20,3 +20,77 @@ target_link_libraries(
|
||||
xrpl_add_benchmark(nodestore)
|
||||
target_link_libraries(xrpl.bench.nodestore PRIVATE xrpl.imports.bench)
|
||||
add_dependencies(xrpl.benchmarks xrpl.bench.nodestore)
|
||||
|
||||
# xrpl.bench.wasm — gas calibration for the wasm host functions.
|
||||
#
|
||||
# Each `*.bench.cpp` sits beside the test that covers the same host function, under
|
||||
# `src/tests/libxrpl/tx/wasm/`. Keeping the benchmark next to the test means the
|
||||
# two move together and share one fixture; a separate executable means benchmark
|
||||
# runtime never lands on the `ctest` path (the test binary filters `*.bench.cpp`
|
||||
# back out).
|
||||
#
|
||||
# Reusing those fixtures means compiling a few test sources and linking GTest:
|
||||
# `RealHostFixture` derives from `testing::Test`. Nothing here registers a test.
|
||||
find_package(GTest QUIET)
|
||||
if(TARGET GTest::gtest)
|
||||
file(
|
||||
GLOB_RECURSE wasm_bench_sources
|
||||
CONFIGURE_DEPENDS
|
||||
"${CMAKE_SOURCE_DIR}/src/tests/libxrpl/tx/wasm/*.bench.cpp"
|
||||
)
|
||||
|
||||
add_executable(
|
||||
xrpl.bench.wasm
|
||||
${wasm_bench_sources}
|
||||
"${CMAKE_SOURCE_DIR}/src/tests/libxrpl/helpers/Account.cpp"
|
||||
"${CMAKE_SOURCE_DIR}/src/tests/libxrpl/helpers/TestSink.cpp"
|
||||
"${CMAKE_SOURCE_DIR}/src/tests/libxrpl/helpers/TxTest.cpp"
|
||||
"${CMAKE_SOURCE_DIR}/src/tests/libxrpl/tx/wasm/RealHostFixture.cpp"
|
||||
"${CMAKE_SOURCE_DIR}/src/tests/libxrpl/tx/wasm/NFTFixture.cpp"
|
||||
# The benchmark harness and its shared ledger setup. Named without the
|
||||
# `.bench.cpp` suffix because they are infrastructure rather than a benchmark,
|
||||
# so the glob above does not find them and `xrpl_tests` excludes them by name.
|
||||
"${CMAKE_SOURCE_DIR}/src/tests/libxrpl/tx/wasm/BenchFixtures.cpp"
|
||||
"${CMAKE_SOURCE_DIR}/src/tests/libxrpl/tx/wasm/WasmBench.cpp"
|
||||
# The WAT assembler, shared with the test binary rather than benchmark-only:
|
||||
# both binaries compile their own copy.
|
||||
"${CMAKE_SOURCE_DIR}/src/tests/libxrpl/tx/wasm/WasmRun.cpp"
|
||||
)
|
||||
|
||||
# Google Benchmark registers cases through static registrars declared in
|
||||
# anonymous namespaces; merging several such files into one unity translation
|
||||
# unit collides them. Same reason as `xrpl_add_benchmark`.
|
||||
#
|
||||
# The output directory matches too: benchmarks land in the build root beside
|
||||
# `xrpl_tests`, because they are run by hand and comparing two of them should
|
||||
# not mean typing two long paths.
|
||||
set_target_properties(
|
||||
xrpl.bench.wasm
|
||||
PROPERTIES
|
||||
UNITY_BUILD OFF
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}"
|
||||
)
|
||||
|
||||
# The fixtures include their siblings as <helpers/...> and <tx/wasm/...>, the
|
||||
# same spelling the test binary gives them.
|
||||
target_include_directories(
|
||||
xrpl.bench.wasm
|
||||
PRIVATE "${CMAKE_SOURCE_DIR}/src/tests/libxrpl"
|
||||
)
|
||||
target_link_libraries(
|
||||
xrpl.bench.wasm
|
||||
PRIVATE
|
||||
xrpl.imports.bench
|
||||
GTest::gtest
|
||||
GTest::gmock
|
||||
xrpl_wasm_testkit_cxxbridge
|
||||
)
|
||||
add_dependencies(xrpl.bench.wasm xrpl_crates)
|
||||
|
||||
add_dependencies(xrpl.benchmarks xrpl.bench.wasm)
|
||||
else()
|
||||
message(
|
||||
STATUS
|
||||
"GTest not found; skipping xrpl.bench.wasm (it reuses the test fixtures)."
|
||||
)
|
||||
endif()
|
||||
|
||||
@@ -108,6 +108,7 @@ transResults()
|
||||
MAKE_ERROR(tecPRECISION_LOSS, "The amounts used by the transaction cannot interact."),
|
||||
MAKE_ERROR(tecBAD_PROOF, "Proof cannot be verified"),
|
||||
MAKE_ERROR(tecNO_SPONSOR_PERMISSION, "Sponsor has not authorized this transaction."),
|
||||
MAKE_ERROR(tecOUT_OF_GAS, "The WASM code ran out of gas during execution."),
|
||||
|
||||
MAKE_ERROR(tefALREADY, "The exact transaction was already in this ledger."),
|
||||
MAKE_ERROR(tefBAD_ADD_AUTH, "Not authorized to add account."),
|
||||
@@ -204,6 +205,7 @@ transResults()
|
||||
MAKE_ERROR(temBAD_TRANSFER_FEE, "Malformed: Transfer fee is outside valid range."),
|
||||
MAKE_ERROR(temINVALID_INNER_BATCH, "Malformed: Invalid inner batch transaction."),
|
||||
MAKE_ERROR(temBAD_CIPHERTEXT, "Malformed: Invalid ciphertext."),
|
||||
MAKE_ERROR(temBAD_WASM, "Malformed: Provided WASM code is invalid."),
|
||||
|
||||
MAKE_ERROR(terRETRY, "Retry transaction."),
|
||||
MAKE_ERROR(terFUNDS_SPENT, "DEPRECATED."),
|
||||
|
||||
1249
src/libxrpl/tx/wasm/HostContext.cpp
Normal file
1249
src/libxrpl/tx/wasm/HostContext.cpp
Normal file
File diff suppressed because it is too large
Load Diff
59
src/libxrpl/tx/wasm/HostFuncImpl.cpp
Normal file
59
src/libxrpl/tx/wasm/HostFuncImpl.cpp
Normal file
@@ -0,0 +1,59 @@
|
||||
#include <xrpl/tx/wasm/HostFuncImpl.h>
|
||||
|
||||
#include <xrpl/basics/Slice.h>
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
#include <xrpl/protocol/PublicKey.h>
|
||||
#include <xrpl/protocol/digest.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
// =========================================================
|
||||
// SECTION: WRITE FUNCTION
|
||||
// =========================================================
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
WasmHostFunctionsImpl::updateData(Slice const& data)
|
||||
{
|
||||
if (data.size() > kMaxWasmDataLength)
|
||||
return std::unexpected(HostFunctionError::DataFieldTooLarge);
|
||||
|
||||
data_ = Bytes(data.begin(), data.end());
|
||||
return data_->size();
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// SECTION: UTILS
|
||||
// =========================================================
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
WasmHostFunctionsImpl::checkSignature(
|
||||
Slice const& message,
|
||||
Slice const& signature,
|
||||
Slice const& pubkey) const
|
||||
{
|
||||
if (!publicKeyType(pubkey))
|
||||
return std::unexpected(HostFunctionError::InvalidParams);
|
||||
|
||||
PublicKey const pk(pubkey);
|
||||
return verify(pk, message, signature);
|
||||
}
|
||||
|
||||
std::expected<Hash, HostFunctionError>
|
||||
WasmHostFunctionsImpl::computeSha512HalfHash(Slice const& data) const
|
||||
{
|
||||
auto const hash = sha512Half(data);
|
||||
return hash;
|
||||
}
|
||||
|
||||
void
|
||||
WasmHostFunctionsImpl::trace(std::string_view const& msg, std::string_view const& data) const
|
||||
{
|
||||
log(msg, [&data] { return data; });
|
||||
}
|
||||
|
||||
} // namespace xrpl
|
||||
530
src/libxrpl/tx/wasm/HostFuncImplFloat.cpp
Normal file
530
src/libxrpl/tx/wasm/HostFuncImplFloat.cpp
Normal file
@@ -0,0 +1,530 @@
|
||||
#include <xrpl/basics/Number.h>
|
||||
#include <xrpl/basics/Slice.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STNumber.h>
|
||||
#include <xrpl/protocol/Serializer.h>
|
||||
#include <xrpl/tx/wasm/HostFunc.h>
|
||||
#include <xrpl/tx/wasm/HostFuncImpl.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <boost/algorithm/hex.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <iterator>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#ifdef _DEBUG
|
||||
// #define DEBUG_OUTPUT 1
|
||||
#endif
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
namespace wasm_float {
|
||||
|
||||
namespace detail {
|
||||
|
||||
// Decode a serialized STNumber float payload. Returns nullopt if the data is
|
||||
// not a well-formed encoding.
|
||||
std::optional<Number>
|
||||
floatDecode(Slice const& data)
|
||||
{
|
||||
static unsigned constexpr encodedFloatSize = 12;
|
||||
if (data.size() != encodedFloatSize)
|
||||
return std::nullopt;
|
||||
try
|
||||
{
|
||||
SerialIter it(data);
|
||||
return STNumber(it, sfNumber).value();
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
// Build a Number from a raw mantissa/exponent pair. Returns nullopt if the
|
||||
// value cannot be represented, e.g. the exponent is out of range.
|
||||
std::optional<Number>
|
||||
numberFromMantExp(int64_t mantissa, int32_t exponent)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Number(mantissa, exponent);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize a Number to the STNumber float encoding.
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatEncode(Number const& n)
|
||||
{
|
||||
Serializer msg;
|
||||
STNumber(sfNumber, n).add(msg);
|
||||
auto data = msg.getData();
|
||||
|
||||
#ifdef DEBUG_OUTPUT
|
||||
std::cout << "m: " << std::setw(20) << n.mantissa() << ", e: " << std::setw(12) << n.exponent()
|
||||
<< ", hex: ";
|
||||
std::cout << std::hex << std::uppercase << std::setfill('0');
|
||||
for (auto const& c : data)
|
||||
std::cout << std::setw(2) << (unsigned)c << " ";
|
||||
std::cout << std::dec << std::setfill(' ') << std::endl;
|
||||
#endif
|
||||
return std::expected<Bytes, HostFunctionError>(std::move(data));
|
||||
}
|
||||
|
||||
struct FloatState
|
||||
{
|
||||
// Set only when the requested mode is valid; sets the rounding mode on
|
||||
// construction and restores the previous mode on destruction.
|
||||
std::optional<NumberRoundModeGuard> guard;
|
||||
|
||||
explicit FloatState(int32_t mode)
|
||||
{
|
||||
if (auto const rm = Number::checkedRoundingMode(mode))
|
||||
guard.emplace(*rm);
|
||||
}
|
||||
|
||||
explicit
|
||||
operator bool() const
|
||||
{
|
||||
return guard.has_value();
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace detail
|
||||
|
||||
std::string
|
||||
floatToString(Slice const& data)
|
||||
{
|
||||
// set default mode as we don't expect it will be used here
|
||||
detail::FloatState const rm(static_cast<int32_t>(Number::RoundingMode::ToNearest));
|
||||
auto const num = detail::floatDecode(data);
|
||||
if (!num)
|
||||
{
|
||||
std::string hex;
|
||||
hex.reserve(data.size() * 2);
|
||||
boost::algorithm::hex(data.begin(), data.end(), std::back_inserter(hex));
|
||||
return "Invalid data: " + hex;
|
||||
}
|
||||
return to_string(*num);
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatFromIntImpl(int64_t x, int32_t mode)
|
||||
{
|
||||
try
|
||||
{
|
||||
detail::FloatState const rm(mode);
|
||||
if (!rm)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
|
||||
return detail::floatEncode(Number(x));
|
||||
}
|
||||
// LCOV_EXCL_START
|
||||
catch (...)
|
||||
{
|
||||
return std::unexpected(HostFunctionError::FloatComputationError);
|
||||
}
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatFromUintImpl(uint64_t x, int32_t mode)
|
||||
{
|
||||
try
|
||||
{
|
||||
detail::FloatState const rm(mode);
|
||||
if (!rm)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
|
||||
return detail::floatEncode(Number(x, 0, Number::Normalized{}));
|
||||
}
|
||||
// LCOV_EXCL_START
|
||||
catch (...)
|
||||
{
|
||||
return std::unexpected(HostFunctionError::FloatComputationError);
|
||||
}
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatFromSTAmountImpl(STAmount const& x, int32_t mode)
|
||||
{
|
||||
try
|
||||
{
|
||||
detail::FloatState const rm(mode);
|
||||
if (!rm)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
|
||||
return detail::floatEncode(static_cast<Number>(x));
|
||||
}
|
||||
// LCOV_EXCL_START
|
||||
catch (...)
|
||||
{
|
||||
return std::unexpected(HostFunctionError::FloatComputationError);
|
||||
}
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatFromSTNumberImpl(STNumber const& x, int32_t mode)
|
||||
{
|
||||
try
|
||||
{
|
||||
detail::FloatState const rm(mode);
|
||||
if (!rm)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
|
||||
return detail::floatEncode(x.value());
|
||||
}
|
||||
// LCOV_EXCL_START
|
||||
catch (...)
|
||||
{
|
||||
return std::unexpected(HostFunctionError::FloatComputationError);
|
||||
}
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
std::expected<int64_t, HostFunctionError>
|
||||
floatToIntImpl(Slice const& x, int32_t mode)
|
||||
{
|
||||
try
|
||||
{
|
||||
detail::FloatState const rm(mode);
|
||||
if (!rm)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
|
||||
auto const num = detail::floatDecode(x);
|
||||
if (!num)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed); // LCOV_EXCL_LINE
|
||||
return static_cast<int64_t>(*num);
|
||||
}
|
||||
// LCOV_EXCL_START
|
||||
catch (...)
|
||||
{
|
||||
return std::unexpected(HostFunctionError::FloatComputationError);
|
||||
}
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
std::expected<FloatPair, HostFunctionError>
|
||||
floatToMantExpImpl(Slice const& x)
|
||||
{
|
||||
try
|
||||
{
|
||||
detail::FloatState const rm(static_cast<int32_t>(Number::RoundingMode::ToNearest));
|
||||
if (!rm)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
|
||||
auto const num = detail::floatDecode(x);
|
||||
if (!num)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed); // LCOV_EXCL_LINE
|
||||
|
||||
return FloatPair(num->mantissa(), num->exponent());
|
||||
}
|
||||
// LCOV_EXCL_START
|
||||
catch (...)
|
||||
{
|
||||
return std::unexpected(HostFunctionError::FloatComputationError);
|
||||
}
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatFromMantExpImpl(int64_t mantissa, int32_t exponent, int32_t mode)
|
||||
{
|
||||
try
|
||||
{
|
||||
detail::FloatState const rm(mode);
|
||||
if (!rm)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
auto const num = detail::numberFromMantExp(mantissa, exponent);
|
||||
if (!num)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
return detail::floatEncode(*num);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
return std::unexpected(HostFunctionError::FloatComputationError);
|
||||
}
|
||||
}
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
floatCompareImpl(Slice const& x, Slice const& y)
|
||||
{
|
||||
try
|
||||
{
|
||||
// set default mode as we don't expect it will be used here
|
||||
detail::FloatState const rm(static_cast<int32_t>(Number::RoundingMode::ToNearest));
|
||||
|
||||
auto const xx = detail::floatDecode(x);
|
||||
if (!xx)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
auto const yy = detail::floatDecode(y);
|
||||
if (!yy)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
if (*xx < *yy)
|
||||
return 2;
|
||||
if (*xx == *yy)
|
||||
return 0;
|
||||
return 1;
|
||||
}
|
||||
// LCOV_EXCL_START
|
||||
catch (...)
|
||||
{
|
||||
return std::unexpected(HostFunctionError::FloatComputationError);
|
||||
}
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatAddImpl(Slice const& x, Slice const& y, int32_t mode)
|
||||
{
|
||||
try
|
||||
{
|
||||
detail::FloatState const rm(mode);
|
||||
if (!rm)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
|
||||
auto const xx = detail::floatDecode(x);
|
||||
if (!xx)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
auto const yy = detail::floatDecode(y);
|
||||
if (!yy)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
|
||||
return detail::floatEncode(*xx + *yy);
|
||||
}
|
||||
// LCOV_EXCL_START
|
||||
catch (...)
|
||||
{
|
||||
return std::unexpected(HostFunctionError::FloatComputationError);
|
||||
}
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatSubtractImpl(Slice const& x, Slice const& y, int32_t mode)
|
||||
{
|
||||
try
|
||||
{
|
||||
detail::FloatState const rm(mode);
|
||||
if (!rm)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
auto const xx = detail::floatDecode(x);
|
||||
if (!xx)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
auto const yy = detail::floatDecode(y);
|
||||
if (!yy)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
|
||||
return detail::floatEncode(*xx - *yy);
|
||||
}
|
||||
// LCOV_EXCL_START
|
||||
catch (...)
|
||||
{
|
||||
return std::unexpected(HostFunctionError::FloatComputationError);
|
||||
}
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatMultiplyImpl(Slice const& x, Slice const& y, int32_t mode)
|
||||
{
|
||||
try
|
||||
{
|
||||
detail::FloatState const rm(mode);
|
||||
if (!rm)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
auto const xx = detail::floatDecode(x);
|
||||
if (!xx)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
auto const yy = detail::floatDecode(y);
|
||||
if (!yy)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
|
||||
return detail::floatEncode(*xx * *yy);
|
||||
}
|
||||
// LCOV_EXCL_START
|
||||
catch (...)
|
||||
{
|
||||
return std::unexpected(HostFunctionError::FloatComputationError);
|
||||
}
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatDivideImpl(Slice const& x, Slice const& y, int32_t mode)
|
||||
{
|
||||
try
|
||||
{
|
||||
detail::FloatState const rm(mode);
|
||||
if (!rm)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
auto const xx = detail::floatDecode(x);
|
||||
if (!xx)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
auto const yy = detail::floatDecode(y);
|
||||
if (!yy)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
|
||||
return detail::floatEncode(*xx / *yy);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
return std::unexpected(HostFunctionError::FloatComputationError);
|
||||
}
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatRootImpl(Slice const& x, int32_t n, int32_t mode)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (n < 1)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
|
||||
detail::FloatState const rm(mode);
|
||||
if (!rm)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
|
||||
auto const xx = detail::floatDecode(x);
|
||||
if (!xx)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
|
||||
return detail::floatEncode(root(*xx, n));
|
||||
}
|
||||
// LCOV_EXCL_START
|
||||
catch (...)
|
||||
{
|
||||
return std::unexpected(HostFunctionError::FloatComputationError);
|
||||
}
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatPowerImpl(Slice const& x, int32_t n, int32_t mode)
|
||||
{
|
||||
try
|
||||
{
|
||||
if ((n < 0) || (n > Number::kMaxExponent))
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
|
||||
detail::FloatState const rm(mode);
|
||||
if (!rm)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
|
||||
auto const xx = detail::floatDecode(x);
|
||||
if (!xx)
|
||||
return std::unexpected(HostFunctionError::FloatInputMalformed);
|
||||
if (*xx == Number() && (n == 0))
|
||||
return std::unexpected(HostFunctionError::InvalidParams);
|
||||
|
||||
return detail::floatEncode(power(*xx, n, 1));
|
||||
}
|
||||
// LCOV_EXCL_START
|
||||
catch (...)
|
||||
{
|
||||
return std::unexpected(HostFunctionError::FloatComputationError);
|
||||
}
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
} // namespace wasm_float
|
||||
|
||||
// =========================================================
|
||||
// ACTUAL HOST FUNCTIONS
|
||||
// =========================================================
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::floatFromInt(int64_t x, int32_t mode) const
|
||||
{
|
||||
return wasm_float::floatFromIntImpl(x, mode);
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::floatFromUint(uint64_t x, int32_t mode) const
|
||||
{
|
||||
return wasm_float::floatFromUintImpl(x, mode);
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::floatFromSTAmount(STAmount const& x, int32_t mode) const
|
||||
{
|
||||
return wasm_float::floatFromSTAmountImpl(x, mode);
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::floatFromSTNumber(STNumber const& x, int32_t mode) const
|
||||
{
|
||||
return wasm_float::floatFromSTNumberImpl(x, mode);
|
||||
}
|
||||
|
||||
std::expected<int64_t, HostFunctionError>
|
||||
WasmHostFunctionsImpl::floatToInt(Slice const& x, int32_t mode) const
|
||||
{
|
||||
return wasm_float::floatToIntImpl(x, mode);
|
||||
}
|
||||
|
||||
std::expected<FloatPair, HostFunctionError>
|
||||
WasmHostFunctionsImpl::floatToMantExp(Slice const& x) const
|
||||
{
|
||||
return wasm_float::floatToMantExpImpl(x);
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::floatFromMantExp(int64_t mantissa, int32_t exponent, int32_t mode) const
|
||||
{
|
||||
return wasm_float::floatFromMantExpImpl(mantissa, exponent, mode);
|
||||
}
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
WasmHostFunctionsImpl::floatCompare(Slice const& x, Slice const& y) const
|
||||
{
|
||||
return wasm_float::floatCompareImpl(x, y);
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::floatAdd(Slice const& x, Slice const& y, int32_t mode) const
|
||||
{
|
||||
return wasm_float::floatAddImpl(x, y, mode);
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::floatSubtract(Slice const& x, Slice const& y, int32_t mode) const
|
||||
{
|
||||
return wasm_float::floatSubtractImpl(x, y, mode);
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::floatMultiply(Slice const& x, Slice const& y, int32_t mode) const
|
||||
{
|
||||
return wasm_float::floatMultiplyImpl(x, y, mode);
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::floatDivide(Slice const& x, Slice const& y, int32_t mode) const
|
||||
{
|
||||
return wasm_float::floatDivideImpl(x, y, mode);
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::floatRoot(Slice const& x, int32_t n, int32_t mode) const
|
||||
{
|
||||
return wasm_float::floatRootImpl(x, n, mode);
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::floatPower(Slice const& x, int32_t n, int32_t mode) const
|
||||
{
|
||||
return wasm_float::floatPowerImpl(x, n, mode);
|
||||
}
|
||||
|
||||
} // namespace xrpl
|
||||
400
src/libxrpl/tx/wasm/HostFuncImplGetter.cpp
Normal file
400
src/libxrpl/tx/wasm/HostFuncImplGetter.cpp
Normal file
@@ -0,0 +1,400 @@
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/basics/contract.h>
|
||||
#include <xrpl/beast/utility/instrumentation.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/MPTIssue.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STBase.h>
|
||||
#include <xrpl/protocol/STBitString.h>
|
||||
#include <xrpl/protocol/STObject.h>
|
||||
#include <xrpl/protocol/Serializer.h>
|
||||
#include <xrpl/tx/wasm/HostFuncImpl.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <stdexcept>
|
||||
#include <utility>
|
||||
#include <variant>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
using FieldValue = std::variant<STBase const*, uint256 const*>;
|
||||
|
||||
template <class T>
|
||||
Bytes
|
||||
getIntBytes(STBase const* obj)
|
||||
{
|
||||
static_assert(std::is_integral_v<T>, "Only integral types");
|
||||
XRPL_ASSERT(obj, "getIntBytes null pointer");
|
||||
|
||||
auto const* num(static_cast<STInteger<T> const*>(obj)); // NOLINT
|
||||
T const data = adjustWasmEndianess(num->value());
|
||||
auto const* b = reinterpret_cast<uint8_t const*>(&data);
|
||||
return Bytes{b, b + sizeof(T)};
|
||||
}
|
||||
|
||||
static std::expected<Bytes, HostFunctionError>
|
||||
getAnyFieldData(STBase const* obj)
|
||||
{
|
||||
if (obj == nullptr)
|
||||
return std::unexpected(HostFunctionError::FieldNotFound);
|
||||
|
||||
auto const stype = obj->getSType();
|
||||
switch (stype)
|
||||
{
|
||||
// LCOV_EXCL_START
|
||||
case STI_UNKNOWN:
|
||||
case STI_NOTPRESENT:
|
||||
return std::unexpected(HostFunctionError::FieldNotFound);
|
||||
// LCOV_EXCL_STOP
|
||||
|
||||
case STI_OBJECT:
|
||||
case STI_ARRAY:
|
||||
case STI_VECTOR256:
|
||||
return std::unexpected(HostFunctionError::NotLeafField);
|
||||
|
||||
case STI_ACCOUNT: {
|
||||
auto const* account(static_cast<STAccount const*>(obj)); // NOLINT
|
||||
auto const& data = account->value();
|
||||
return Bytes{data.begin(), data.end()};
|
||||
}
|
||||
|
||||
case STI_ISSUE: {
|
||||
auto const* issue(static_cast<STIssue const*>(obj)); // NOLINT
|
||||
Asset const& asset(issue->value());
|
||||
// XRP and IOU will be processed by serializer
|
||||
if (asset.holds<MPTIssue>())
|
||||
{
|
||||
auto const& mptIssue = asset.get<MPTIssue>();
|
||||
auto const& mptID = mptIssue.getMptID();
|
||||
return Bytes{mptID.cbegin(), mptID.cend()};
|
||||
}
|
||||
break; // Use serializer
|
||||
}
|
||||
|
||||
case STI_VL: {
|
||||
auto const* vl(static_cast<STBlob const*>(obj)); // NOLINT
|
||||
auto const& data = vl->value();
|
||||
return Bytes{data.begin(), data.end()};
|
||||
}
|
||||
|
||||
case STI_UINT16:
|
||||
return getIntBytes<std::uint16_t>(obj);
|
||||
|
||||
case STI_UINT32:
|
||||
return getIntBytes<std::uint32_t>(obj);
|
||||
|
||||
// LCOV_EXCL_START
|
||||
case STI_UINT64:
|
||||
return getIntBytes<std::uint64_t>(obj);
|
||||
|
||||
case STI_INT32:
|
||||
return getIntBytes<std::int32_t>(obj);
|
||||
|
||||
case STI_INT64:
|
||||
return getIntBytes<std::int64_t>(obj);
|
||||
// LCOV_EXCL_STOP
|
||||
|
||||
case STI_UINT256: {
|
||||
auto const* uint256Obj(static_cast<STUInt256 const*>(obj)); // NOLINT
|
||||
auto const& data = uint256Obj->value();
|
||||
return Bytes{data.begin(), data.end()};
|
||||
}
|
||||
|
||||
case STI_AMOUNT:
|
||||
case STI_NUMBER:
|
||||
default:
|
||||
break; // Use serializer
|
||||
}
|
||||
|
||||
Serializer msg;
|
||||
obj->add(msg);
|
||||
return msg.getData();
|
||||
}
|
||||
|
||||
static std::expected<Bytes, HostFunctionError>
|
||||
getAnyFieldData(FieldValue const& variantObj)
|
||||
{
|
||||
if (STBase const* const* obj = std::get_if<STBase const*>(&variantObj))
|
||||
return getAnyFieldData(*obj);
|
||||
|
||||
if (uint256 const* const* u = std::get_if<uint256 const*>(&variantObj))
|
||||
return Bytes((*u)->begin(), (*u)->end());
|
||||
|
||||
// Unreachable: the variant only holds the two alternatives above. If not, it is an
|
||||
// xrpld bug, and `guarded` turns the throw into `InternalFatal`, which stops the run ->
|
||||
// tecINTERNAL.
|
||||
Throw<std::runtime_error>("field value variant holds neither alternative"); // LCOV_EXCL_LINE
|
||||
}
|
||||
|
||||
static inline bool
|
||||
noField(STBase const* field)
|
||||
{
|
||||
return (field == nullptr) || (STI_NOTPRESENT == field->getSType()) ||
|
||||
(STI_UNKNOWN == field->getSType());
|
||||
}
|
||||
|
||||
static std::expected<FieldValue, HostFunctionError>
|
||||
locateField(STObject const& obj, FieldLocator const& locator)
|
||||
{
|
||||
STBase const* field = nullptr;
|
||||
auto const& knownSFields = SField::getKnownCodeToField();
|
||||
|
||||
{
|
||||
int32_t const sfieldCode = adjustWasmEndianess(locator[0]);
|
||||
auto const it = knownSFields.find(sfieldCode);
|
||||
if (it == knownSFields.end())
|
||||
return std::unexpected(HostFunctionError::InvalidField);
|
||||
|
||||
auto const& fname(*it->second);
|
||||
field = obj.peekAtPField(fname);
|
||||
if (noField(field))
|
||||
return std::unexpected(HostFunctionError::FieldNotFound);
|
||||
}
|
||||
|
||||
for (unsigned i = 1; i < locator.size(); ++i)
|
||||
{
|
||||
int32_t const sfieldCode = adjustWasmEndianess(locator[i]);
|
||||
|
||||
if (STI_ARRAY == field->getSType())
|
||||
{
|
||||
auto const* arr = static_cast<STArray const*>(field); // NOLINT
|
||||
if (sfieldCode < 0 || std::cmp_greater_equal(sfieldCode, arr->size()))
|
||||
return std::unexpected(HostFunctionError::IndexOutOfBounds);
|
||||
field = &(arr->operator[](sfieldCode));
|
||||
}
|
||||
else if (STI_OBJECT == field->getSType())
|
||||
{
|
||||
auto const* o = static_cast<STObject const*>(field); // NOLINT
|
||||
|
||||
auto const it = knownSFields.find(sfieldCode);
|
||||
if (it == knownSFields.end())
|
||||
return std::unexpected(HostFunctionError::InvalidField);
|
||||
|
||||
auto const& fname(*it->second);
|
||||
field = o->peekAtPField(fname);
|
||||
}
|
||||
else if (STI_VECTOR256 == field->getSType())
|
||||
{
|
||||
auto const* v = static_cast<STVector256 const*>(field); // NOLINT
|
||||
if (sfieldCode < 0 || std::cmp_greater_equal(sfieldCode, v->size()))
|
||||
return std::unexpected(HostFunctionError::IndexOutOfBounds);
|
||||
return FieldValue(&(v->operator[](sfieldCode)));
|
||||
}
|
||||
else // simple field must be the last one
|
||||
{
|
||||
return std::unexpected(HostFunctionError::LocatorMalformed);
|
||||
}
|
||||
|
||||
if (noField(field))
|
||||
return std::unexpected(HostFunctionError::FieldNotFound);
|
||||
}
|
||||
|
||||
return FieldValue(field);
|
||||
}
|
||||
|
||||
static inline std::expected<int32_t, HostFunctionError>
|
||||
getArrayLen(FieldValue const& variantField)
|
||||
{
|
||||
if (STBase const* const* field = std::get_if<STBase const*>(&variantField))
|
||||
{
|
||||
if ((*field)->getSType() == STI_VECTOR256)
|
||||
return static_cast<STVector256 const*>(*field)->size(); // NOLINT
|
||||
if ((*field)->getSType() == STI_ARRAY)
|
||||
return static_cast<STArray const*>(*field)->size(); // NOLINT
|
||||
}
|
||||
// uint256 is not an array so that variant should still return NO_ARRAY
|
||||
|
||||
return std::unexpected(HostFunctionError::NoArray); // LCOV_EXCL_LINE
|
||||
}
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
WasmHostFunctionsImpl::cacheLedgerObj(uint256 const& objId, int32_t cacheIdx)
|
||||
{
|
||||
auto const& keylet = keylet::unchecked(objId);
|
||||
if (cacheIdx < 0 || cacheIdx > maxCache)
|
||||
return std::unexpected(HostFunctionError::SlotOutRange);
|
||||
|
||||
if (cacheIdx == 0)
|
||||
{
|
||||
for (cacheIdx = 0; cacheIdx < maxCache; ++cacheIdx)
|
||||
{
|
||||
if (!cache_[cacheIdx])
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
cacheIdx--; // convert to 0-based index
|
||||
}
|
||||
|
||||
if (cacheIdx >= maxCache)
|
||||
return std::unexpected(HostFunctionError::SlotsFull);
|
||||
|
||||
cache_[cacheIdx] = ctx_.view().read(keylet);
|
||||
if (!cache_[cacheIdx])
|
||||
return std::unexpected(HostFunctionError::LedgerObjNotFound);
|
||||
return cacheIdx + 1; // return 1-based index
|
||||
}
|
||||
|
||||
// Subsection: top level getters
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::getTxField(SField const& fname) const
|
||||
{
|
||||
return getAnyFieldData(ctx_.tx.peekAtPField(fname));
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::getCurrentLedgerObjField(SField const& fname) const
|
||||
{
|
||||
auto const sle = getCurrentLedgerObj();
|
||||
if (!sle.has_value())
|
||||
return std::unexpected(sle.error());
|
||||
return getAnyFieldData(sle.value()->peekAtPField(fname));
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::getLedgerObjField(int32_t cacheIdx, SField const& fname) const
|
||||
{
|
||||
auto const normalizedIdx = normalizeCacheIndex(cacheIdx);
|
||||
if (!normalizedIdx.has_value())
|
||||
return std::unexpected(normalizedIdx.error());
|
||||
return getAnyFieldData(cache_[normalizedIdx.value()]->peekAtPField(fname));
|
||||
}
|
||||
|
||||
// Subsection: nested getters
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::getTxNestedField(FieldLocator const& locator) const
|
||||
{
|
||||
auto const r = locateField(ctx_.tx, locator);
|
||||
if (!r)
|
||||
return std::unexpected(r.error());
|
||||
|
||||
return getAnyFieldData(r.value());
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::getCurrentLedgerObjNestedField(FieldLocator const& locator) const
|
||||
{
|
||||
auto const sle = getCurrentLedgerObj();
|
||||
if (!sle.has_value())
|
||||
return std::unexpected(sle.error());
|
||||
|
||||
auto const r = locateField(*sle.value(), locator);
|
||||
if (!r)
|
||||
return std::unexpected(r.error());
|
||||
|
||||
return getAnyFieldData(r.value());
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::getLedgerObjNestedField(int32_t cacheIdx, FieldLocator const& locator) const
|
||||
{
|
||||
auto const normalizedIdx = normalizeCacheIndex(cacheIdx);
|
||||
if (!normalizedIdx.has_value())
|
||||
return std::unexpected(normalizedIdx.error());
|
||||
|
||||
auto const r = locateField(*cache_[normalizedIdx.value()], locator);
|
||||
if (!r)
|
||||
return std::unexpected(r.error());
|
||||
|
||||
return getAnyFieldData(r.value());
|
||||
}
|
||||
|
||||
// Subsection: array length getters
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
WasmHostFunctionsImpl::getTxArrayLen(SField const& fname) const
|
||||
{
|
||||
if (fname.fieldType != STI_ARRAY && fname.fieldType != STI_VECTOR256)
|
||||
return std::unexpected(HostFunctionError::NoArray);
|
||||
|
||||
auto const* field = ctx_.tx.peekAtPField(fname);
|
||||
if (noField(field))
|
||||
return std::unexpected(HostFunctionError::FieldNotFound);
|
||||
|
||||
return getArrayLen(field);
|
||||
}
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
WasmHostFunctionsImpl::getCurrentLedgerObjArrayLen(SField const& fname) const
|
||||
{
|
||||
if (fname.fieldType != STI_ARRAY && fname.fieldType != STI_VECTOR256)
|
||||
return std::unexpected(HostFunctionError::NoArray);
|
||||
|
||||
auto const sle = getCurrentLedgerObj();
|
||||
if (!sle.has_value())
|
||||
return std::unexpected(sle.error());
|
||||
|
||||
auto const* field = sle.value()->peekAtPField(fname);
|
||||
if (noField(field))
|
||||
return std::unexpected(HostFunctionError::FieldNotFound);
|
||||
|
||||
return getArrayLen(field);
|
||||
}
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
WasmHostFunctionsImpl::getLedgerObjArrayLen(int32_t cacheIdx, SField const& fname) const
|
||||
{
|
||||
if (fname.fieldType != STI_ARRAY && fname.fieldType != STI_VECTOR256)
|
||||
return std::unexpected(HostFunctionError::NoArray);
|
||||
|
||||
auto const normalizedIdx = normalizeCacheIndex(cacheIdx);
|
||||
if (!normalizedIdx.has_value())
|
||||
return std::unexpected(normalizedIdx.error());
|
||||
|
||||
auto const* field = cache_[normalizedIdx.value()]->peekAtPField(fname);
|
||||
if (noField(field))
|
||||
return std::unexpected(HostFunctionError::FieldNotFound);
|
||||
|
||||
return getArrayLen(field);
|
||||
}
|
||||
|
||||
// Subsection: nested array length getters
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
WasmHostFunctionsImpl::getTxNestedArrayLen(FieldLocator const& locator) const
|
||||
{
|
||||
auto const r = locateField(ctx_.tx, locator);
|
||||
if (!r)
|
||||
return std::unexpected(r.error());
|
||||
|
||||
auto const& field = r.value();
|
||||
return getArrayLen(field);
|
||||
}
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
WasmHostFunctionsImpl::getCurrentLedgerObjNestedArrayLen(FieldLocator const& locator) const
|
||||
{
|
||||
auto const sle = getCurrentLedgerObj();
|
||||
if (!sle.has_value())
|
||||
return std::unexpected(sle.error());
|
||||
auto const r = locateField(*sle.value(), locator);
|
||||
if (!r)
|
||||
return std::unexpected(r.error());
|
||||
|
||||
auto const& field = r.value();
|
||||
return getArrayLen(field);
|
||||
}
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
WasmHostFunctionsImpl::getLedgerObjNestedArrayLen(int32_t cacheIdx, FieldLocator const& locator)
|
||||
const
|
||||
{
|
||||
auto const normalizedIdx = normalizeCacheIndex(cacheIdx);
|
||||
if (!normalizedIdx.has_value())
|
||||
return std::unexpected(normalizedIdx.error());
|
||||
|
||||
auto const r = locateField(*cache_[normalizedIdx.value()], locator);
|
||||
if (!r)
|
||||
return std::unexpected(r.error());
|
||||
|
||||
auto const& field = r.value();
|
||||
return getArrayLen(field);
|
||||
}
|
||||
|
||||
} // namespace xrpl
|
||||
223
src/libxrpl/tx/wasm/HostFuncImplKeylet.cpp
Normal file
223
src/libxrpl/tx/wasm/HostFuncImplKeylet.cpp
Normal file
@@ -0,0 +1,223 @@
|
||||
#include <xrpl/basics/Slice.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/MPTIssue.h>
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
#include <xrpl/protocol/SeqProxy.h>
|
||||
#include <xrpl/protocol/UintTypes.h>
|
||||
#include <xrpl/tx/wasm/HostFuncImpl.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::accountKeylet(AccountID const& account) const
|
||||
{
|
||||
if (!account)
|
||||
return std::unexpected(HostFunctionError::InvalidAccount);
|
||||
auto const keylet = keylet::account(account);
|
||||
return Bytes{keylet.key.begin(), keylet.key.end()};
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::ammKeylet(Asset const& issue1, Asset const& issue2) const
|
||||
{
|
||||
if (issue1 == issue2)
|
||||
return std::unexpected(HostFunctionError::InvalidParams);
|
||||
|
||||
// note: this should be removed with the MPT DEX amendment
|
||||
if (issue1.holds<MPTIssue>() || issue2.holds<MPTIssue>())
|
||||
return std::unexpected(HostFunctionError::InvalidParams);
|
||||
|
||||
auto const keylet = keylet::amm(issue1, issue2);
|
||||
return Bytes{keylet.key.begin(), keylet.key.end()};
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::checkKeylet(AccountID const& account, std::uint32_t seq) const
|
||||
{
|
||||
if (!account)
|
||||
return std::unexpected(HostFunctionError::InvalidAccount);
|
||||
auto const keylet = keylet::check(account, SeqProxy::rawSequence(seq));
|
||||
return Bytes{keylet.key.begin(), keylet.key.end()};
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::credentialKeylet(
|
||||
AccountID const& subject,
|
||||
AccountID const& issuer,
|
||||
Slice const& credentialType) const
|
||||
{
|
||||
if (!subject || !issuer)
|
||||
return std::unexpected(HostFunctionError::InvalidAccount);
|
||||
|
||||
if (credentialType.empty() || credentialType.size() > kMaxCredentialTypeLength)
|
||||
return std::unexpected(HostFunctionError::InvalidParams);
|
||||
|
||||
auto const keylet = keylet::credential(subject, issuer, credentialType);
|
||||
|
||||
return Bytes{keylet.key.begin(), keylet.key.end()};
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::didKeylet(AccountID const& account) const
|
||||
{
|
||||
if (!account)
|
||||
return std::unexpected(HostFunctionError::InvalidAccount);
|
||||
auto const keylet = keylet::did(account);
|
||||
return Bytes{keylet.key.begin(), keylet.key.end()};
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::delegateKeylet(AccountID const& account, AccountID const& authorize) const
|
||||
{
|
||||
if (!account || !authorize)
|
||||
return std::unexpected(HostFunctionError::InvalidAccount);
|
||||
if (account == authorize)
|
||||
return std::unexpected(HostFunctionError::InvalidParams);
|
||||
auto const keylet = keylet::delegate(account, authorize);
|
||||
return Bytes{keylet.key.begin(), keylet.key.end()};
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::depositPreauthKeylet(AccountID const& account, AccountID const& authorize)
|
||||
const
|
||||
{
|
||||
if (!account || !authorize)
|
||||
return std::unexpected(HostFunctionError::InvalidAccount);
|
||||
if (account == authorize)
|
||||
return std::unexpected(HostFunctionError::InvalidParams);
|
||||
auto const keylet = keylet::depositPreauth(account, authorize);
|
||||
return Bytes{keylet.key.begin(), keylet.key.end()};
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::escrowKeylet(AccountID const& account, std::uint32_t seq) const
|
||||
{
|
||||
if (!account)
|
||||
return std::unexpected(HostFunctionError::InvalidAccount);
|
||||
auto const keylet = keylet::escrow(account, SeqProxy::rawSequence(seq));
|
||||
return Bytes{keylet.key.begin(), keylet.key.end()};
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::trustLineKeylet(
|
||||
AccountID const& account1,
|
||||
AccountID const& account2,
|
||||
Currency const& currency) const
|
||||
{
|
||||
if (!account1 || !account2)
|
||||
return std::unexpected(HostFunctionError::InvalidAccount);
|
||||
if (account1 == account2)
|
||||
return std::unexpected(HostFunctionError::InvalidParams);
|
||||
if (currency.isZero())
|
||||
return std::unexpected(HostFunctionError::InvalidParams);
|
||||
|
||||
auto const keylet = keylet::trustLine(account1, account2, currency);
|
||||
return Bytes{keylet.key.begin(), keylet.key.end()};
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::mptokenIssuanceKeylet(AccountID const& issuer, std::uint32_t seq) const
|
||||
{
|
||||
if (!issuer)
|
||||
return std::unexpected(HostFunctionError::InvalidAccount);
|
||||
|
||||
auto const keylet = keylet::mptokenIssuance(makeMptID(seq, issuer));
|
||||
return Bytes{keylet.key.begin(), keylet.key.end()};
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::mptokenKeylet(MPTID const& mptid, AccountID const& holder) const
|
||||
{
|
||||
if (!mptid)
|
||||
return std::unexpected(HostFunctionError::InvalidParams);
|
||||
if (!holder)
|
||||
return std::unexpected(HostFunctionError::InvalidAccount);
|
||||
|
||||
auto const keylet = keylet::mptoken(mptid, holder);
|
||||
return Bytes{keylet.key.begin(), keylet.key.end()};
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::nftokenOfferKeylet(AccountID const& account, std::uint32_t seq) const
|
||||
{
|
||||
if (!account)
|
||||
return std::unexpected(HostFunctionError::InvalidAccount);
|
||||
auto const keylet = keylet::nftokenOffer(account, SeqProxy::rawSequence(seq));
|
||||
return Bytes{keylet.key.begin(), keylet.key.end()};
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::offerKeylet(AccountID const& account, std::uint32_t seq) const
|
||||
{
|
||||
if (!account)
|
||||
return std::unexpected(HostFunctionError::InvalidAccount);
|
||||
auto const keylet = keylet::offer(account, SeqProxy::rawSequence(seq));
|
||||
return Bytes{keylet.key.begin(), keylet.key.end()};
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::oracleKeylet(AccountID const& account, std::uint32_t documentId) const
|
||||
{
|
||||
if (!account)
|
||||
return std::unexpected(HostFunctionError::InvalidAccount);
|
||||
auto const keylet = keylet::oracle(account, documentId);
|
||||
return Bytes{keylet.key.begin(), keylet.key.end()};
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::paychannelKeylet(
|
||||
AccountID const& account,
|
||||
AccountID const& destination,
|
||||
std::uint32_t seq) const
|
||||
{
|
||||
if (!account || !destination)
|
||||
return std::unexpected(HostFunctionError::InvalidAccount);
|
||||
if (account == destination)
|
||||
return std::unexpected(HostFunctionError::InvalidParams);
|
||||
auto const keylet = keylet::payChannel(account, destination, SeqProxy::rawSequence(seq));
|
||||
return Bytes{keylet.key.begin(), keylet.key.end()};
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::permissionedDomainKeylet(AccountID const& account, std::uint32_t seq) const
|
||||
{
|
||||
if (!account)
|
||||
return std::unexpected(HostFunctionError::InvalidAccount);
|
||||
auto const keylet = keylet::permissionedDomain(account, SeqProxy::rawSequence(seq));
|
||||
return Bytes{keylet.key.begin(), keylet.key.end()};
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::signerListKeylet(AccountID const& account) const
|
||||
{
|
||||
if (!account)
|
||||
return std::unexpected(HostFunctionError::InvalidAccount);
|
||||
auto const keylet = keylet::signerList(account);
|
||||
return Bytes{keylet.key.begin(), keylet.key.end()};
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::ticketKeylet(AccountID const& account, std::uint32_t seq) const
|
||||
{
|
||||
if (!account)
|
||||
return std::unexpected(HostFunctionError::InvalidAccount);
|
||||
auto const keylet = keylet::ticket(account, SeqProxy::rawTicket(seq));
|
||||
return Bytes{keylet.key.begin(), keylet.key.end()};
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::vaultKeylet(AccountID const& account, std::uint32_t seq) const
|
||||
{
|
||||
if (!account)
|
||||
return std::unexpected(HostFunctionError::InvalidAccount);
|
||||
auto const keylet = keylet::vault(account, SeqProxy::rawSequence(seq));
|
||||
return Bytes{keylet.key.begin(), keylet.key.end()};
|
||||
}
|
||||
|
||||
} // namespace xrpl
|
||||
55
src/libxrpl/tx/wasm/HostFuncImplLedgerHeader.cpp
Normal file
55
src/libxrpl/tx/wasm/HostFuncImplLedgerHeader.cpp
Normal file
@@ -0,0 +1,55 @@
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/ledger/AmendmentTable.h>
|
||||
#include <xrpl/tx/wasm/HostFuncImpl.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
// =========================================================
|
||||
// SECTION: LEDGER HEADER FUNCTIONS
|
||||
// =========================================================
|
||||
|
||||
std::expected<std::uint32_t, HostFunctionError>
|
||||
WasmHostFunctionsImpl::getLedgerSqn() const
|
||||
{
|
||||
return ctx_.view().seq();
|
||||
}
|
||||
|
||||
std::expected<std::uint32_t, HostFunctionError>
|
||||
WasmHostFunctionsImpl::getParentLedgerTime() const
|
||||
{
|
||||
return ctx_.view().parentCloseTime().time_since_epoch().count();
|
||||
}
|
||||
|
||||
std::expected<Hash, HostFunctionError>
|
||||
WasmHostFunctionsImpl::getParentLedgerHash() const
|
||||
{
|
||||
return ctx_.view().header().parentHash;
|
||||
}
|
||||
|
||||
std::expected<std::uint32_t, HostFunctionError>
|
||||
WasmHostFunctionsImpl::getBaseFee() const
|
||||
{
|
||||
return ctx_.view().fees().base.drops();
|
||||
}
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
WasmHostFunctionsImpl::isAmendmentEnabled(uint256 const& amendmentId) const
|
||||
{
|
||||
return ctx_.view().rules().enabled(amendmentId);
|
||||
}
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
WasmHostFunctionsImpl::isAmendmentEnabled(std::string_view const& amendmentName) const
|
||||
{
|
||||
auto const& table = ctx_.registry.get().getAmendmentTable();
|
||||
auto const amendment = table.find(std::string(amendmentName));
|
||||
return ctx_.view().rules().enabled(amendment);
|
||||
}
|
||||
|
||||
} // namespace xrpl
|
||||
74
src/libxrpl/tx/wasm/HostFuncImplNFT.cpp
Normal file
74
src/libxrpl/tx/wasm/HostFuncImplNFT.cpp
Normal file
@@ -0,0 +1,74 @@
|
||||
#include <xrpl/basics/Slice.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/ledger/helpers/NFTokenHelpers.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/nft.h>
|
||||
#include <xrpl/tx/wasm/HostFuncImpl.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
// =========================================================
|
||||
// SECTION: NFT UTILS
|
||||
// =========================================================
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::getNFT(AccountID const& account, uint256 const& nftId) const
|
||||
{
|
||||
if (!account)
|
||||
return std::unexpected(HostFunctionError::InvalidAccount);
|
||||
|
||||
if (!nftId)
|
||||
return std::unexpected(HostFunctionError::InvalidParams);
|
||||
|
||||
auto obj = nft::findToken(ctx_.view(), account, nftId);
|
||||
if (!obj)
|
||||
return std::unexpected(HostFunctionError::LedgerObjNotFound);
|
||||
|
||||
auto objUri = obj->at(~sfURI);
|
||||
if (!objUri)
|
||||
return std::unexpected(HostFunctionError::FieldNotFound);
|
||||
|
||||
Slice const s = objUri->value();
|
||||
return Bytes(s.begin(), s.end());
|
||||
}
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
WasmHostFunctionsImpl::getNFTIssuer(uint256 const& nftId) const
|
||||
{
|
||||
auto const issuer = nft::getIssuer(nftId);
|
||||
if (!issuer)
|
||||
return std::unexpected(HostFunctionError::InvalidParams);
|
||||
|
||||
return Bytes{issuer.begin(), issuer.end()};
|
||||
}
|
||||
|
||||
std::expected<std::uint32_t, HostFunctionError>
|
||||
WasmHostFunctionsImpl::getNFTTaxon(uint256 const& nftId) const
|
||||
{
|
||||
return nft::toUInt32(nft::getTaxon(nftId));
|
||||
}
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
WasmHostFunctionsImpl::getNFTFlags(uint256 const& nftId) const
|
||||
{
|
||||
return nft::getFlags(nftId);
|
||||
}
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
WasmHostFunctionsImpl::getNFTTransferFee(uint256 const& nftId) const
|
||||
{
|
||||
return nft::getTransferFee(nftId);
|
||||
}
|
||||
|
||||
std::expected<std::uint32_t, HostFunctionError>
|
||||
WasmHostFunctionsImpl::getNFTSequence(uint256 const& nftId) const
|
||||
{
|
||||
return nft::getSequence(nftId);
|
||||
}
|
||||
|
||||
} // namespace xrpl
|
||||
186
src/libxrpl/tx/wasm/WasmVM.cpp
Normal file
186
src/libxrpl/tx/wasm/WasmVM.cpp
Normal file
@@ -0,0 +1,186 @@
|
||||
#include <xrpl/tx/wasm/WasmVM.h>
|
||||
|
||||
#include <xrpl/basics/Log.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/beast/utility/instrumentation.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/tx/wasm/HostContext.h>
|
||||
#include <xrpl/tx/wasm/HostFunc.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <rust/cxx.h>
|
||||
#include <xrpl_wasm_vm_ffi_cxxbridge/lib.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <optional>
|
||||
#include <stdexcept>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
namespace {
|
||||
|
||||
using RunStatus = rs::wasm_vm::RunStatus;
|
||||
using CheckStatus = rs::wasm_vm::CheckStatus;
|
||||
|
||||
// The engine's outcome as the caller's: a value with its gas cost, or a TER with the gas cost
|
||||
// to record beside it.
|
||||
//
|
||||
// A `tecINTERNAL` reports no cost. It says the fault is the node's, and charging a
|
||||
// transaction for a node's defect would write that defect into the ledger.
|
||||
//
|
||||
// Exhaustive over the status enum, with no `default`: the enum is generated from the
|
||||
// engine's `RunError`, so an outcome added there fails this switch under -Wswitch -Werror
|
||||
// rather than quietly picking up a neighbour's TER. The return past the switch is for the
|
||||
// compilers that will not call an exhaustive switch exhaustive; it sits after the switch,
|
||||
// not in a `default`, so the coverage check above still holds.
|
||||
std::expected<EscrowResult, WasmTER>
|
||||
outcome(rs::wasm_vm::RunResult const& run)
|
||||
{
|
||||
auto const cost = static_cast<std::int64_t>(run.gas_used);
|
||||
|
||||
switch (run.status)
|
||||
{
|
||||
case RunStatus::Ok:
|
||||
return EscrowResult{.result = run.result, .cost = cost};
|
||||
|
||||
// The cost is the whole limit: XLS-0102 halts the guest the instant the meter runs
|
||||
// out, and the run is charged for all of it.
|
||||
case RunStatus::OutOfGas:
|
||||
return std::unexpected{WasmTER{.ter = tecOUT_OF_GAS, .cost = cost}};
|
||||
|
||||
// The contract's own fault - it trapped, or it never exported the linear memory
|
||||
// its host calls need - so it is charged for what it burned reaching that point.
|
||||
case RunStatus::Trap:
|
||||
case RunStatus::NoMemory:
|
||||
// A module that will not instantiate is the contract's fault too. Screening
|
||||
// cannot see every way this happens - a linear memory the module keeps to itself
|
||||
// is absent from its exports - so a module can pass preflight and still be
|
||||
// refused here. It is a deterministic property of the code either way, and one
|
||||
// this node's own conduct had no part in.
|
||||
case RunStatus::Instantiate:
|
||||
return std::unexpected{WasmTER{.ter = tecFAILED_PROCESSING, .cost = cost}};
|
||||
|
||||
// A module that will not compile, or does not expose the entry point, should have
|
||||
// been refused at preflight with `temBAD_WASM`: screening decides both from the
|
||||
// same bytes and the same engine, so agreeing here is not a matter of degree.
|
||||
// Reaching apply means the screening did not happen, which is a node-side fault
|
||||
// rather than the transaction's.
|
||||
case RunStatus::Compile:
|
||||
case RunStatus::EntryPoint:
|
||||
// The host could not serve a call, or it threw and `HostContext` caught it.
|
||||
case RunStatus::Internal:
|
||||
// The engine panicked: a defect in the engine, reported rather than fatal to the
|
||||
// node.
|
||||
case RunStatus::Panic:
|
||||
return std::unexpected{WasmTER{.ter = tecINTERNAL, .cost = std::nullopt}};
|
||||
}
|
||||
UNREACHABLE("xrpl::outcome : unknown RunStatus");
|
||||
return std::unexpected{WasmTER{.ter = tecINTERNAL, .cost = std::nullopt}};
|
||||
}
|
||||
|
||||
// A screening verdict as a TER.
|
||||
//
|
||||
// `temBAD_WASM` says the transaction carries something this engine cannot run: a
|
||||
// malformed transaction, refused before it can reach the ledger. A panic inside the
|
||||
// engine is different in kind - nothing was learned about the module - so the answer is
|
||||
// node-local rather than a claim about the transaction.
|
||||
//
|
||||
// Exhaustive over the status enum, with no `default`, for the same reason `outcome` is.
|
||||
NotTEC
|
||||
verdict(CheckStatus status)
|
||||
{
|
||||
switch (status)
|
||||
{
|
||||
case CheckStatus::Ok:
|
||||
return tesSUCCESS;
|
||||
|
||||
// The module will not compile, imports what no engine of this ABI serves, does
|
||||
// not export the entry point as `() -> i32`, or asks for more linear memory or
|
||||
// table than it may have.
|
||||
case CheckStatus::Compile:
|
||||
case CheckStatus::Import:
|
||||
case CheckStatus::EntryPoint:
|
||||
case CheckStatus::Memory:
|
||||
case CheckStatus::Table:
|
||||
return temBAD_WASM;
|
||||
|
||||
// The engine panicked: a defect in the engine, reported rather than fatal to
|
||||
// the node, and not the transaction's fault.
|
||||
case CheckStatus::Panic:
|
||||
return telFAILED_PROCESSING;
|
||||
}
|
||||
UNREACHABLE("xrpl::verdict : unknown CheckStatus");
|
||||
return telFAILED_PROCESSING;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::expected<EscrowResult, WasmTER>
|
||||
runEscrowWasm(
|
||||
Bytes const& wasmCode,
|
||||
HostFunctions& hfs,
|
||||
std::int64_t gasLimit,
|
||||
std::string_view funcName) noexcept
|
||||
{
|
||||
XRPL_ASSERT(
|
||||
gasLimit > 0,
|
||||
"::xrpl::runEscrowWasm : gas limit is positive (should be checked in preflight)");
|
||||
// A run needs a budget to spend. Refused here rather than in the engine because what a
|
||||
// non-positive limit means is a transaction-validity rule; the engine's own budget is
|
||||
// therefore an unsigned quantity with no invalid value to represent.
|
||||
if (gasLimit <= 0)
|
||||
return std::unexpected{WasmTER{.ter = temBAD_AMOUNT, .cost = std::nullopt}};
|
||||
|
||||
auto const nodeSideFault = std::unexpected{WasmTER{.ter = tecINTERNAL, .cost = std::nullopt}};
|
||||
|
||||
return guarded(hfs.getJournal(), nodeSideFault, [&]() -> std::expected<EscrowResult, WasmTER> {
|
||||
// The host caches the current ledger object, the slot table and the
|
||||
// contract's data for the length of one run, so a reused one would answer a
|
||||
// later contract out of an earlier contract's state.
|
||||
XRPL_ASSERT(
|
||||
hfs.checkSelf(), "::xrpl::runEscrowWasm : host functions not clean before the run");
|
||||
if (!hfs.checkSelf())
|
||||
{
|
||||
throw std::runtime_error("host functions not clean before the run");
|
||||
}
|
||||
|
||||
HostContext const ctx{hfs};
|
||||
auto const run = rs::wasm_vm::run_escrow(
|
||||
ctx,
|
||||
rust::Slice<std::uint8_t const>{wasmCode.data(), wasmCode.size()},
|
||||
static_cast<std::uint64_t>(gasLimit),
|
||||
rust::Str{funcName.data(), funcName.size()});
|
||||
|
||||
auto const result = outcome(run);
|
||||
if (!result)
|
||||
{
|
||||
JLOG(hfs.getJournal().warn())
|
||||
<< "wasm: " << std::string_view{run.detail.data(), run.detail.size()}
|
||||
<< ", ter: " << transToken(result.error().ter);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
NotTEC
|
||||
preflightEscrowWasm(Bytes const& wasmCode, beast::Journal j, std::string_view funcName) noexcept
|
||||
{
|
||||
return guarded(j, NotTEC{telFAILED_PROCESSING}, [&]() {
|
||||
auto const checked = rs::wasm_vm::check_escrow(
|
||||
rust::Slice<std::uint8_t const>{wasmCode.data(), wasmCode.size()},
|
||||
rust::Str{funcName.data(), funcName.size()});
|
||||
|
||||
auto const ter = verdict(checked.status);
|
||||
if (!isTesSuccess(ter))
|
||||
{
|
||||
JLOG(j.warn()) << "wasm: "
|
||||
<< std::string_view{checked.detail.data(), checked.detail.size()}
|
||||
<< ", ter: " << transToken(ter);
|
||||
}
|
||||
return ter;
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace xrpl
|
||||
@@ -23,6 +23,9 @@ set_target_properties(
|
||||
target_include_directories(xrpl_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
target_link_libraries(xrpl_tests PRIVATE GTest::gtest GTest::gmock xrpl.libxrpl)
|
||||
|
||||
target_link_libraries(xrpl_tests PRIVATE xrpl_wasm_testkit_cxxbridge)
|
||||
add_dependencies(xrpl_tests xrpl_crates)
|
||||
|
||||
# One source subdirectory per module. Network unit tests are currently not
|
||||
# supported on Windows.
|
||||
set(test_modules
|
||||
@@ -43,9 +46,6 @@ set(test_modules
|
||||
if(NOT WIN32)
|
||||
list(APPEND test_modules net)
|
||||
endif()
|
||||
if(rust)
|
||||
target_link_libraries(xrpl_tests PRIVATE rs_hello_world_cxxbridge)
|
||||
endif()
|
||||
|
||||
foreach(module IN LISTS test_modules)
|
||||
# Append the module's sources (${module}/*.cpp and ${module}.cpp, if any).
|
||||
@@ -55,12 +55,12 @@ foreach(module IN LISTS test_modules)
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/${module}/*.cpp"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/${module}.cpp"
|
||||
)
|
||||
if(NOT rust)
|
||||
# Tests of the Rust interop include generated cxxbridge headers, which
|
||||
# do not exist without the crates, so keep them out of the build tree
|
||||
# entirely. They are named `Rust<something>.cpp`.
|
||||
list(FILTER sources EXCLUDE REGEX "/Rust[^/]*\\.cpp$")
|
||||
endif()
|
||||
# Remove Benchmark tests from this target.
|
||||
list(
|
||||
FILTER sources
|
||||
EXCLUDE
|
||||
REGEX "\\.bench\\.cpp$|/(WasmBench|BenchFixtures)\\.cpp$"
|
||||
)
|
||||
target_sources(xrpl_tests PRIVATE ${sources})
|
||||
|
||||
# Expose the module's private headers under their canonical include path.
|
||||
@@ -82,6 +82,14 @@ file(
|
||||
)
|
||||
target_sources(xrpl_tests PRIVATE ${csf_sources})
|
||||
|
||||
if(benchmark AND TARGET benchmark::benchmark)
|
||||
target_include_directories(
|
||||
xrpl_tests
|
||||
PRIVATE
|
||||
$<TARGET_PROPERTY:benchmark::benchmark,INTERFACE_INCLUDE_DIRECTORIES>
|
||||
)
|
||||
endif()
|
||||
|
||||
# The test helpers and per-module test headers are not built with add_module,
|
||||
# so verify them against the test binary's own compile environment.
|
||||
if(verify_headers)
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <rs_hello_world_cxxbridge/lib.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
TEST(RustInteropTest, hello_world)
|
||||
{
|
||||
EXPECT_EQ(std::string(rs::hello_world::hello_world()), "hello_world");
|
||||
}
|
||||
@@ -1,13 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/basics/Log.h>
|
||||
#include <xrpl/basics/UnorderedContainers.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/basics/chrono.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/core/HashRouter.h>
|
||||
#include <xrpl/core/NetworkIDService.h>
|
||||
#include <xrpl/core/ServiceRegistry.h>
|
||||
#include <xrpl/json/json_value.h>
|
||||
#include <xrpl/ledger/AmendmentTable.h>
|
||||
#include <xrpl/ledger/PendingSaves.h>
|
||||
#include <xrpl/ledger/View.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
#include <xrpl/protocol/PublicKey.h>
|
||||
#include <xrpl/protocol/Rules.h>
|
||||
#include <xrpl/protocol/STValidation.h>
|
||||
#include <xrpl/server/LoadFeeTrack.h>
|
||||
|
||||
#include <boost/asio/io_context.hpp>
|
||||
@@ -15,11 +24,15 @@
|
||||
#include <helpers/TestFamily.h>
|
||||
#include <helpers/TestSink.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
@@ -40,6 +53,100 @@ public:
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimal AmendmentTable for tests.
|
||||
*/
|
||||
class TestAmendmentTable final : public AmendmentTable
|
||||
{
|
||||
public:
|
||||
[[nodiscard]] uint256
|
||||
find(std::string const& name) const override
|
||||
{
|
||||
return getRegisteredFeature(name).value_or(uint256{});
|
||||
}
|
||||
|
||||
bool
|
||||
veto(uint256 const&) override
|
||||
{
|
||||
throw std::logic_error("TestAmendmentTable::veto not implemented");
|
||||
}
|
||||
bool
|
||||
unVeto(uint256 const&) override
|
||||
{
|
||||
throw std::logic_error("TestAmendmentTable::unVeto not implemented");
|
||||
}
|
||||
bool
|
||||
enable(uint256 const&) override
|
||||
{
|
||||
throw std::logic_error("TestAmendmentTable::enable not implemented");
|
||||
}
|
||||
[[nodiscard]] bool
|
||||
isEnabled(uint256 const&) const override
|
||||
{
|
||||
throw std::logic_error("TestAmendmentTable::isEnabled not implemented");
|
||||
}
|
||||
[[nodiscard]] bool
|
||||
isSupported(uint256 const&) const override
|
||||
{
|
||||
throw std::logic_error("TestAmendmentTable::isSupported not implemented");
|
||||
}
|
||||
[[nodiscard]] bool
|
||||
hasUnsupportedEnabled() const override
|
||||
{
|
||||
throw std::logic_error("TestAmendmentTable::hasUnsupportedEnabled not implemented");
|
||||
}
|
||||
[[nodiscard]] std::optional<NetClock::time_point>
|
||||
firstUnsupportedExpected() const override
|
||||
{
|
||||
throw std::logic_error("TestAmendmentTable::firstUnsupportedExpected not implemented");
|
||||
}
|
||||
[[nodiscard]] json::Value
|
||||
getJson(bool) const override
|
||||
{
|
||||
throw std::logic_error("TestAmendmentTable::getJson not implemented");
|
||||
}
|
||||
[[nodiscard]] json::Value
|
||||
getJson(uint256 const&, bool) const override
|
||||
{
|
||||
throw std::logic_error("TestAmendmentTable::getJson(amendment) not implemented");
|
||||
}
|
||||
[[nodiscard]] bool
|
||||
needValidatedLedger(LedgerIndex) const override
|
||||
{
|
||||
throw std::logic_error("TestAmendmentTable::needValidatedLedger not implemented");
|
||||
}
|
||||
void
|
||||
doValidatedLedger(LedgerIndex, std::set<uint256> const&, majorityAmendments_t const&) override
|
||||
{
|
||||
throw std::logic_error("TestAmendmentTable::doValidatedLedger not implemented");
|
||||
}
|
||||
void
|
||||
trustChanged(hash_set<PublicKey> const&) override
|
||||
{
|
||||
throw std::logic_error("TestAmendmentTable::trustChanged not implemented");
|
||||
}
|
||||
std::map<uint256, std::uint32_t>
|
||||
doVoting(
|
||||
Rules const&,
|
||||
NetClock::time_point,
|
||||
std::set<uint256> const&,
|
||||
majorityAmendments_t const&,
|
||||
std::vector<std::shared_ptr<STValidation>> const&) override
|
||||
{
|
||||
throw std::logic_error("TestAmendmentTable::doVoting not implemented");
|
||||
}
|
||||
[[nodiscard]] std::vector<uint256>
|
||||
doValidation(std::set<uint256> const&) const override
|
||||
{
|
||||
throw std::logic_error("TestAmendmentTable::doValidation not implemented");
|
||||
}
|
||||
[[nodiscard]] std::vector<uint256>
|
||||
getDesired() const override
|
||||
{
|
||||
throw std::logic_error("TestAmendmentTable::getDesired not implemented");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple NetworkIDService implementation for tests.
|
||||
*/
|
||||
@@ -91,6 +198,7 @@ class TestServiceRegistry : public ServiceRegistry
|
||||
logs_.journal("TaggedCache")};
|
||||
PendingSaves pendingSaves_;
|
||||
std::optional<uint256> trapTxID_;
|
||||
TestAmendmentTable amendmentTable_;
|
||||
|
||||
public:
|
||||
TestServiceRegistry() = default;
|
||||
@@ -143,7 +251,7 @@ public:
|
||||
AmendmentTable&
|
||||
getAmendmentTable() override
|
||||
{
|
||||
throw std::logic_error("TestServiceRegistry::getAmendmentTable() not implemented");
|
||||
return amendmentTable_;
|
||||
}
|
||||
|
||||
HashRouter&
|
||||
|
||||
185
src/tests/libxrpl/tx/wasm/BenchFixtures.cpp
Normal file
185
src/tests/libxrpl/tx/wasm/BenchFixtures.cpp
Normal file
@@ -0,0 +1,185 @@
|
||||
#include <tx/wasm/BenchFixtures.h>
|
||||
|
||||
#include <xrpl/basics/Slice.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/Keylet.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STArray.h>
|
||||
#include <xrpl/protocol/STObject.h>
|
||||
#include <xrpl/protocol/SeqProxy.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/protocol_autogen/transactions/EscrowCreate.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <helpers/Account.h>
|
||||
#include <helpers/TxTest.h>
|
||||
#include <tx/wasm/FloatFixture.h>
|
||||
#include <tx/wasm/NFTFixture.h>
|
||||
#include <tx/wasm/RealHostFixture.h>
|
||||
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
|
||||
namespace xrpl::test::bench {
|
||||
namespace {
|
||||
|
||||
[[noreturn]] void
|
||||
setupFailed(std::string_view what)
|
||||
{
|
||||
throw std::runtime_error{"benchmark fixture setup failed: " + std::string{what}};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Fixtures::Fixtures()
|
||||
: alice_{ledger_.fund("benchAlice")}
|
||||
, bob_{ledger_.fund("benchBob")}
|
||||
, signerListOwner_{ledger_.fund("benchSigners")}
|
||||
, escrow_{keylet::account(AccountID{})}
|
||||
, signedMessage_{signMessage("the quick brown fox jumps over the lazy dog")}
|
||||
, nftId_{NFTTest::makeNftId(alice_.id())}
|
||||
{
|
||||
ledger_.makeSignerList(signerListOwner_, 2, {{alice_, 1}, {bob_, 1}});
|
||||
|
||||
// The escrow has to be submitted after the accounts exist, which is why it is built here
|
||||
// rather than in the initializer list: its keylet depends on the owner's sequence number at
|
||||
// submission time.
|
||||
auto const ownerSeq = ledger_.ledger.getAccountRoot(alice_.id()).getSequence();
|
||||
auto const created = ledger_.ledger.submit(
|
||||
transactions::EscrowCreateBuilder{alice_.id(), bob_.id(), XRP(100)}.setFinishAfter(
|
||||
900'000'000),
|
||||
alice_);
|
||||
if (created.ter != tesSUCCESS)
|
||||
{
|
||||
setupFailed(std::string{"creating the escrow: "} + transToken(created.ter));
|
||||
}
|
||||
ledger_.ledger.close();
|
||||
escrow_ = keylet::escrow(alice_.id(), SeqProxy::rawSequence(ownerSeq));
|
||||
}
|
||||
|
||||
Account const&
|
||||
Fixtures::alice() const
|
||||
{
|
||||
return alice_;
|
||||
}
|
||||
|
||||
Account const&
|
||||
Fixtures::bob() const
|
||||
{
|
||||
return bob_;
|
||||
}
|
||||
|
||||
TxAssembler
|
||||
Fixtures::memoTx()
|
||||
{
|
||||
auto assembler = escrowFinishTx(ledger_.ledger, alice_);
|
||||
assembler.build = [inner = std::move(assembler.build)](STObject& obj) {
|
||||
inner(obj);
|
||||
auto memos = STArray{};
|
||||
memos.push_back(makeMemo(RealHostFixture::toBytes("hello")));
|
||||
memos.push_back(makeMemo(RealHostFixture::toBytes("world")));
|
||||
obj.setFieldArray(sfMemos, memos);
|
||||
};
|
||||
return assembler;
|
||||
}
|
||||
|
||||
FieldLocator
|
||||
Fixtures::memoLocator()
|
||||
{
|
||||
return FieldLocator{{sfMemos.getCode(), 0, sfMemoData.getCode()}};
|
||||
}
|
||||
|
||||
WasmHost
|
||||
Fixtures::host()
|
||||
{
|
||||
auto assembler = memoTx();
|
||||
return ledger_.makeHost(
|
||||
keylet::account(alice_.id()), assembler.type, std::move(assembler.build));
|
||||
}
|
||||
|
||||
WasmHost
|
||||
Fixtures::cachedHost()
|
||||
{
|
||||
auto wasmHost = host();
|
||||
if (!wasmHost->cacheLedgerObj(keylet::account(alice_.id()).key, 1).has_value())
|
||||
{
|
||||
setupFailed("caching the account root into slot 1");
|
||||
}
|
||||
return wasmHost;
|
||||
}
|
||||
|
||||
WasmHost
|
||||
Fixtures::signerListHost()
|
||||
{
|
||||
auto assembler = bareTx();
|
||||
return ledger_.makeHost(
|
||||
keylet::signerList(signerListOwner_.id()), assembler.type, std::move(assembler.build));
|
||||
}
|
||||
|
||||
WasmHost
|
||||
Fixtures::cachedSignerListHost()
|
||||
{
|
||||
auto assembler = bareTx();
|
||||
auto wasmHost =
|
||||
ledger_.makeHost(keylet::account(AccountID{}), assembler.type, std::move(assembler.build));
|
||||
if (!wasmHost->cacheLedgerObj(keylet::signerList(signerListOwner_.id()).key, 1).has_value())
|
||||
{
|
||||
setupFailed("caching the signer list into slot 1");
|
||||
}
|
||||
return wasmHost;
|
||||
}
|
||||
|
||||
WasmHost
|
||||
Fixtures::tracingHost()
|
||||
{
|
||||
return ledger_.makeTracingHost();
|
||||
}
|
||||
|
||||
Keylet const&
|
||||
Fixtures::escrow() const
|
||||
{
|
||||
return escrow_;
|
||||
}
|
||||
|
||||
WasmHost
|
||||
Fixtures::escrowHost()
|
||||
{
|
||||
return ledger_.makeHost(escrow_);
|
||||
}
|
||||
|
||||
Slice
|
||||
Fixtures::floatX()
|
||||
{
|
||||
return FloatTest::slice(FloatTest::kPi);
|
||||
}
|
||||
|
||||
Slice
|
||||
Fixtures::floatY()
|
||||
{
|
||||
return FloatTest::slice(FloatTest::kTwo);
|
||||
}
|
||||
|
||||
SignedMessage const&
|
||||
Fixtures::signedMessage() const
|
||||
{
|
||||
return signedMessage_;
|
||||
}
|
||||
|
||||
uint256 const&
|
||||
Fixtures::nftId() const
|
||||
{
|
||||
return nftId_;
|
||||
}
|
||||
|
||||
Fixtures&
|
||||
Fixtures::instance()
|
||||
{
|
||||
static Fixtures kValue;
|
||||
return kValue;
|
||||
}
|
||||
|
||||
} // namespace xrpl::test::bench
|
||||
112
src/tests/libxrpl/tx/wasm/BenchFixtures.h
Normal file
112
src/tests/libxrpl/tx/wasm/BenchFixtures.h
Normal file
@@ -0,0 +1,112 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/basics/Slice.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/protocol/Keylet.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <helpers/Account.h>
|
||||
#include <tx/wasm/RealHostFixture.h>
|
||||
#include <tx/wasm/WasmBench.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace xrpl::test::bench {
|
||||
|
||||
// The ledger and canned inputs every `*.bench.cpp` measures against.
|
||||
class Fixtures
|
||||
{
|
||||
public:
|
||||
// The one set of fixtures every benchmark shares, built on first use.
|
||||
static Fixtures&
|
||||
instance();
|
||||
|
||||
// A sequence number for the keylets that take one. Arbitrary — a keylet hashes whatever it
|
||||
// is given, so the value cannot change the cost.
|
||||
static constexpr std::uint32_t kSeq = 42;
|
||||
|
||||
// Rounding mode 0 throughout the float family: modes select a tie-breaking rule, not a
|
||||
// different algorithm, so they do not move the cost, and pinning one keeps the fourteen
|
||||
// comparable.
|
||||
static constexpr std::int32_t kRoundingMode = 0;
|
||||
|
||||
// Two funded accounts, enough for every keylet shape and every object below.
|
||||
[[nodiscard]] Account const&
|
||||
alice() const;
|
||||
[[nodiscard]] Account const&
|
||||
bob() const;
|
||||
|
||||
// The default host: its transaction carries a two-element memo array (something for the
|
||||
// nested getters to walk to and the array-length getters to count) and its current object is
|
||||
// Alice's account root.
|
||||
[[nodiscard]] WasmHost
|
||||
host();
|
||||
|
||||
// The same, with Alice's account root pinned to slot 1, for the `le_*` getters that read
|
||||
// through a cache slot rather than the current object.
|
||||
[[nodiscard]] WasmHost
|
||||
cachedHost();
|
||||
|
||||
// An account root has no arrays, so the array-length getters that read a *ledger object*
|
||||
// need a different one. A signer list has `sfSignerEntries`; without it those calls would
|
||||
// answer `FieldNotFound` and the benchmark would time the rejection instead of the work.
|
||||
[[nodiscard]] WasmHost
|
||||
signerListHost();
|
||||
[[nodiscard]] WasmHost
|
||||
cachedSignerListHost();
|
||||
|
||||
// A host whose `trace` output is captured rather than dropped, so the log-enabled path can
|
||||
// be measured against the log-disabled one that `host()` gives.
|
||||
[[nodiscard]] WasmHost
|
||||
tracingHost();
|
||||
|
||||
// A real escrow, created through the real transactor — the current object for
|
||||
// `home_le_field`, the one getter whose cost depends on the object it reads rather than on
|
||||
// its arguments.
|
||||
[[nodiscard]] Keylet const&
|
||||
escrow() const;
|
||||
[[nodiscard]] WasmHost
|
||||
escrowHost();
|
||||
|
||||
// `sfMemos[0].sfMemoData` — a two-step locator path, the shape the nested getters are priced
|
||||
// for.
|
||||
[[nodiscard]] static FieldLocator
|
||||
memoLocator();
|
||||
|
||||
// Canonical float operands. Zeroed bytes decode as a non-canonical float and would be
|
||||
// refused before any arithmetic ran, so the whole family shares these two known-good values.
|
||||
[[nodiscard]] static Slice
|
||||
floatX();
|
||||
[[nodiscard]] static Slice
|
||||
floatY();
|
||||
|
||||
// A signed message for `check_sig`. Signing is far more expensive than the verification
|
||||
// being measured, so it happens once here rather than inside a timed loop.
|
||||
[[nodiscard]] SignedMessage const&
|
||||
signedMessage() const;
|
||||
|
||||
// A well-formed NFToken id with the fixture's known taxon, flags, fee and sequence baked in,
|
||||
// so the id-extractor getters have real fields to pull out rather than zeros.
|
||||
[[nodiscard]] uint256 const&
|
||||
nftId() const;
|
||||
|
||||
private:
|
||||
// Order matters, and that is the reason this is a constructor rather than a pile of lazy
|
||||
// statics: the accounts have to be funded before the signer list and the escrow can be built
|
||||
// on them.
|
||||
Fixtures();
|
||||
|
||||
// The transaction the default host runs, carrying the memo array.
|
||||
[[nodiscard]] TxAssembler
|
||||
memoTx();
|
||||
|
||||
BenchFixture ledger_;
|
||||
Account alice_;
|
||||
Account bob_;
|
||||
Account signerListOwner_;
|
||||
Keylet escrow_;
|
||||
SignedMessage signedMessage_;
|
||||
uint256 nftId_;
|
||||
};
|
||||
|
||||
} // namespace xrpl::test::bench
|
||||
44
src/tests/libxrpl/tx/wasm/Crossing.bench.cpp
Normal file
44
src/tests/libxrpl/tx/wasm/Crossing.bench.cpp
Normal file
@@ -0,0 +1,44 @@
|
||||
#include <benchmark/benchmark.h>
|
||||
#include <tx/wasm/BenchFixtures.h>
|
||||
#include <tx/wasm/WasmBench.h>
|
||||
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test::bench {
|
||||
namespace {
|
||||
|
||||
// The harness checking itself.
|
||||
//
|
||||
// This file holds no host function — it belongs to the wasm directory rather than
|
||||
// `host_functions/` because it measures the two reference points every per-function number is read
|
||||
// against, and neither is a host call.
|
||||
//
|
||||
// `GuestInstruction` runs a contract whose "host call" is a couple of guest instructions. Its
|
||||
// `implied_gas` and `charged_gas` are then two independent measurements of the same quantity — one
|
||||
// from wall time via `secondsPerGas`, one from the engine's own fuel meter — and they should agree
|
||||
// closely. When they diverge, `secondsPerGas` has measured something other than a guest
|
||||
// instruction and no other number in the run is trustworthy. Read this first.
|
||||
//
|
||||
// The crossing floor is the other reference point, and it lives in `host_functions/LedgerSqn`:
|
||||
// `ldgr_index` takes no input and answers from a header already in hand, so its impl is as close
|
||||
// to nothing as a host function gets, and whatever its `ThroughVm` case costs above its `Impl`
|
||||
// case is the price of leaving the guest — paid by every one of the 61 functions before any of
|
||||
// them does any work.
|
||||
//
|
||||
// So the reading order across the suite is: this file, then `LedgerSqn`'s pair for the floor, then
|
||||
// a function's own `Impl` number. Those three should account for its `ThroughVm` number; where
|
||||
// they do not, the gap is size-dependent copying, which the swept cases (`Sha512Half`,
|
||||
// `UpdateData`) expose.
|
||||
|
||||
void
|
||||
guestInstruction(benchmark::State& state)
|
||||
{
|
||||
static constexpr std::string_view kBody = "(i32.add (local.get $r) (i32.const 1))";
|
||||
// Empty import name: this case prices no host function, so there is nothing to look a
|
||||
// declaration up for and it reports no `suggested_gas`.
|
||||
benchmarkThroughVm(state, "", "", "", kBody, [] { return Fixtures::instance().host(); });
|
||||
}
|
||||
BENCHMARK(guestInstruction)->UseManualTime()->Iterations(kBenchIterations);
|
||||
|
||||
} // namespace
|
||||
} // namespace xrpl::test::bench
|
||||
50
src/tests/libxrpl/tx/wasm/FloatFixture.h
Normal file
50
src/tests/libxrpl/tx/wasm/FloatFixture.h
Normal file
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/basics/Slice.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <tx/wasm/RealHostFixture.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
struct FloatTest : RealHostFixture
|
||||
{
|
||||
static constexpr std::int64_t kMin64 = std::numeric_limits<std::int64_t>::min();
|
||||
static constexpr std::int64_t kMax64 = std::numeric_limits<std::int64_t>::max();
|
||||
static constexpr std::int32_t kNormalExp = 18;
|
||||
static constexpr std::string_view const kInvalidData = "invalid_data";
|
||||
|
||||
// clang-format off
|
||||
static inline Bytes const kIntMin = {0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00}; // -2^63 (rounds to -(2^63-1))
|
||||
static inline Bytes const kIntZero = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00}; // 0
|
||||
static inline Bytes const kIntMax = {0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00}; // 2^63-1
|
||||
static inline Bytes const kUintMax = {0x19, 0x99, 0x99, 0x99, 0x99, 0x99, 0x99, 0x9A, 0x00, 0x00, 0x00, 0x01}; // 2^64-1
|
||||
static inline Bytes const kMaxExp = {0x0D, 0xE0, 0xB6, 0xB3, 0xA7, 0x64, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00}; // 1e(kMaxExponent + kNormalExp)
|
||||
static inline Bytes const kPreMaxExp = {0x0D, 0xE0, 0xB6, 0xB3, 0xA7, 0x64, 0x00, 0x00, 0x00, 0x00, 0x7F, 0xFF}; // 1e(kMaxExponent + kNormalExp - 1)
|
||||
static inline Bytes const kMinusMaxExp = {0xF2, 0x1F, 0x49, 0x4C, 0x58, 0x9C, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00}; // -1e(kMaxExponent + kNormalExp)
|
||||
static inline Bytes const kMinExp = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00}; // 1e(kMinExponent - kNormalExp)
|
||||
static inline Bytes const kMax = {0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x80, 0x00}; // kMaxRep e(kMaxExponent - kNormalExp)
|
||||
static inline Bytes const kMaxIOU = {0x0D, 0xE0, 0xB6, 0xB3, 0xA7, 0x63, 0xFF, 0x9C, 0x00, 0x00, 0x00, 0x4E}; // 9999999999999999e(96)
|
||||
static inline Bytes const kMinIOU = {0x0D, 0xE0, 0xB6, 0xB3, 0xA7, 0x64, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x9D}; // 1e(-81)
|
||||
static inline Bytes const kOne = {0x0D, 0xE0, 0xB6, 0xB3, 0xA7, 0x64, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xEE}; // 1
|
||||
static inline Bytes const kMinusOne = {0xF2, 0x1F, 0x49, 0x4C, 0x58, 0x9C, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xEE}; // -1
|
||||
static inline Bytes const kOneMore = {0x0D, 0xE0, 0xB6, 0xB3, 0xA7, 0x64, 0x03, 0xE8, 0xFF, 0xFF, 0xFF, 0xEE}; // 1.000000000000001
|
||||
static inline Bytes const kTwo = {0x1B, 0xC1, 0x6D, 0x67, 0x4E, 0xC8, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xEE}; // 2
|
||||
static inline Bytes const kTen = {0x0D, 0xE0, 0xB6, 0xB3, 0xA7, 0x64, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xEF}; // 10
|
||||
static inline Bytes const kPi = {0x2B, 0x99, 0x2D, 0xDF, 0xA2, 0x32, 0x48, 0xE8, 0xFF, 0xFF, 0xFF, 0xEE}; // 3.141592653589793
|
||||
static inline Bytes const kInvalidZero = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x81, 0x00, 0x00, 0x00}; // non-canonical zero
|
||||
static inline Bytes const kMinusThree = {0xD6, 0x5D, 0xDB, 0xE5, 0x09, 0xD4, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xEE}; // -3
|
||||
// clang-format on
|
||||
|
||||
static Slice
|
||||
slice(Bytes const& b)
|
||||
{
|
||||
return Slice{b.data(), b.size()};
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace xrpl::test
|
||||
69
src/tests/libxrpl/tx/wasm/HostContextFixture.cpp
Normal file
69
src/tests/libxrpl/tx/wasm/HostContextFixture.cpp
Normal file
@@ -0,0 +1,69 @@
|
||||
#include <tx/wasm/HostContextFixture.h>
|
||||
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <rust/cxx.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
rust::Slice<std::uint8_t const>
|
||||
HostContextTest::bytesOf(Bytes const& bytes)
|
||||
{
|
||||
return rust::Slice<std::uint8_t const>{bytes.data(), bytes.size()};
|
||||
}
|
||||
|
||||
Bytes
|
||||
HostContextTest::bytesOfSteps(std::vector<std::int32_t> const& steps)
|
||||
{
|
||||
Bytes bytes;
|
||||
bytes.reserve(steps.size() * sizeof(std::int32_t));
|
||||
for (auto const step : steps)
|
||||
{
|
||||
auto const wire = bytesOfScalar(step);
|
||||
bytes.insert(bytes.end(), wire.begin(), wire.end());
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
HostContextTest::OutRegion::OutRegion(std::size_t capacity) : bytes(capacity, kSentinel)
|
||||
{
|
||||
}
|
||||
|
||||
rust::Slice<std::uint8_t>
|
||||
HostContextTest::OutRegion::slice()
|
||||
{
|
||||
return rust::Slice<std::uint8_t>{bytes.data(), bytes.size()};
|
||||
}
|
||||
|
||||
bool
|
||||
HostContextTest::OutRegion::wasWritten() const
|
||||
{
|
||||
return std::ranges::any_of(bytes, [](std::uint8_t b) { return b != kSentinel; });
|
||||
}
|
||||
|
||||
bool
|
||||
HostContextTest::OutRegion::holds(rust::Slice<std::uint8_t const> expected) const
|
||||
{
|
||||
if (expected.size() > bytes.size())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
auto want = std::vector<std::uint8_t>(bytes.size(), kSentinel);
|
||||
std::ranges::copy(expected, want.begin());
|
||||
return bytes == want;
|
||||
}
|
||||
|
||||
std::string
|
||||
HostContextTest::logged() const
|
||||
{
|
||||
return sink.messages();
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
104
src/tests/libxrpl/tx/wasm/HostContextFixture.h
Normal file
104
src/tests/libxrpl/tx/wasm/HostContextFixture.h
Normal file
@@ -0,0 +1,104 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/tx/wasm/HostContext.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <gmock/gmock.h>
|
||||
#include <gtest/gtest.h>
|
||||
#include <helpers/CaptureSink.h>
|
||||
#include <rust/cxx.h>
|
||||
#include <tx/wasm/MockHostFunctions.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
#include <vector>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
// Base for the tests that construct `HostContext` directly, rather than reaching it through
|
||||
// an assembled module.
|
||||
struct HostContextTest : testing::Test
|
||||
{
|
||||
static rust::Slice<std::uint8_t const>
|
||||
bytesOf(Bytes const& bytes);
|
||||
|
||||
// A scalar's wire form: its bytes little-endian, the way a wasm guest lays them out in
|
||||
// memory.
|
||||
//
|
||||
// Spelled out with shifts rather than a `memcpy` of the value, which would mirror what
|
||||
// `answerScalar` does and so assert nothing about the byte order. That is the whole reason
|
||||
// this exists, so keep it a shift.
|
||||
template <class T>
|
||||
static Bytes
|
||||
bytesOfScalar(T value)
|
||||
{
|
||||
static_assert(std::is_integral_v<T>, "Only integral types");
|
||||
|
||||
auto const bits = static_cast<std::make_unsigned_t<T>>(value);
|
||||
Bytes bytes(sizeof(bits));
|
||||
for (std::size_t i = 0; i < sizeof(bits); ++i)
|
||||
{
|
||||
bytes[i] = static_cast<std::uint8_t>(bits >> (i * 8));
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// A locator's wire form: each step as four little-endian bytes.
|
||||
static Bytes
|
||||
bytesOfSteps(std::vector<std::int32_t> const& steps);
|
||||
|
||||
// Filled with a sentinel rather than left at zero: an answer can itself be all zero, so
|
||||
// only a byte no answer produces tells "wrote nothing" apart from "wrote zeros".
|
||||
struct OutRegion
|
||||
{
|
||||
static constexpr std::uint8_t kSentinel = 0xcd;
|
||||
|
||||
std::vector<std::uint8_t> bytes;
|
||||
|
||||
explicit OutRegion(std::size_t capacity);
|
||||
|
||||
rust::Slice<std::uint8_t>
|
||||
slice();
|
||||
|
||||
[[nodiscard]] bool
|
||||
wasWritten() const;
|
||||
|
||||
// Means "this value and nothing past it".
|
||||
[[nodiscard]] bool
|
||||
holds(rust::Slice<std::uint8_t const> expected) const;
|
||||
};
|
||||
|
||||
CaptureSink sink{beast::Severity::Warning};
|
||||
testing::StrictMock<MockHostFunctions> host{beast::Journal{sink}};
|
||||
HostContext hostContext{host};
|
||||
|
||||
[[nodiscard]] std::string
|
||||
logged() const;
|
||||
};
|
||||
|
||||
// `FieldLocator` has no `operator==` and is move-only, so an `EXPECT_CALL` needs a matcher
|
||||
// rather than `testing::Ref`/`testing::Eq`. `invokeWithLocator` builds it as a local that is
|
||||
// gone once the call returns, so the check has to happen inside the matcher.
|
||||
//
|
||||
// `MATCHER_P` emits a function of this name, and gmock matchers are CamelCase by convention.
|
||||
// NOLINTNEXTLINE(readability-identifier-naming)
|
||||
MATCHER_P(LocatorEquals, steps, "")
|
||||
{
|
||||
if (arg.size() != static_cast<std::uint32_t>(steps.size()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
for (std::uint32_t i = 0; i < arg.size(); ++i)
|
||||
{
|
||||
if (arg[i] != steps[i])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
425
src/tests/libxrpl/tx/wasm/MockHostFunctions.h
Normal file
425
src/tests/libxrpl/tx/wasm/MockHostFunctions.h
Normal file
@@ -0,0 +1,425 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/basics/Slice.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STAmount.h>
|
||||
#include <xrpl/protocol/STNumber.h>
|
||||
#include <xrpl/protocol/UintTypes.h>
|
||||
#include <xrpl/tx/wasm/HostFunc.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <gmock/gmock.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
// A mock of the host the wasm engine calls back into.
|
||||
//
|
||||
// One `MOCK_METHOD` per `HostFunctions` entry, in that header's order, each signature taken
|
||||
// verbatim from it. The extra parentheses around a return type are what keeps the comma in
|
||||
// `std::expected<T, HostFunctionError>` from splitting the macro's arguments.
|
||||
//
|
||||
// No `ON_CALL` defaults, deliberately: this is always used through `StrictMock`, which fails
|
||||
// a call to a method carrying no `EXPECT_CALL`.
|
||||
struct MockHostFunctions : HostFunctions
|
||||
{
|
||||
explicit MockHostFunctions(beast::Journal journal) : HostFunctions(journal)
|
||||
{
|
||||
}
|
||||
|
||||
MOCK_METHOD(bool, checkSelf, (), (const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<std::uint32_t, HostFunctionError>),
|
||||
getLedgerSqn,
|
||||
(),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<std::uint32_t, HostFunctionError>),
|
||||
getParentLedgerTime,
|
||||
(),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Hash, HostFunctionError>),
|
||||
getParentLedgerHash,
|
||||
(),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<std::uint32_t, HostFunctionError>),
|
||||
getBaseFee,
|
||||
(),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<std::int32_t, HostFunctionError>),
|
||||
isAmendmentEnabled,
|
||||
(uint256 const& amendmentId),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<std::int32_t, HostFunctionError>),
|
||||
isAmendmentEnabled,
|
||||
(std::string_view const& amendmentName),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<std::int32_t, HostFunctionError>),
|
||||
cacheLedgerObj,
|
||||
(uint256 const& objId, std::int32_t cacheIdx),
|
||||
(override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
getTxField,
|
||||
(SField const& fname),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
getCurrentLedgerObjField,
|
||||
(SField const& fname),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
getLedgerObjField,
|
||||
(std::int32_t cacheIdx, SField const& fname),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
getTxNestedField,
|
||||
(FieldLocator const& locator),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
getCurrentLedgerObjNestedField,
|
||||
(FieldLocator const& locator),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
getLedgerObjNestedField,
|
||||
(std::int32_t cacheIdx, FieldLocator const& locator),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<std::int32_t, HostFunctionError>),
|
||||
getTxArrayLen,
|
||||
(SField const& fname),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<std::int32_t, HostFunctionError>),
|
||||
getCurrentLedgerObjArrayLen,
|
||||
(SField const& fname),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<std::int32_t, HostFunctionError>),
|
||||
getLedgerObjArrayLen,
|
||||
(std::int32_t cacheIdx, SField const& fname),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<std::int32_t, HostFunctionError>),
|
||||
getTxNestedArrayLen,
|
||||
(FieldLocator const& locator),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<std::int32_t, HostFunctionError>),
|
||||
getCurrentLedgerObjNestedArrayLen,
|
||||
(FieldLocator const& locator),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<std::int32_t, HostFunctionError>),
|
||||
getLedgerObjNestedArrayLen,
|
||||
(std::int32_t cacheIdx, FieldLocator const& locator),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<std::int32_t, HostFunctionError>),
|
||||
updateData,
|
||||
(Slice const& data),
|
||||
(override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<std::int32_t, HostFunctionError>),
|
||||
checkSignature,
|
||||
(Slice const& message, Slice const& signature, Slice const& pubkey),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Hash, HostFunctionError>),
|
||||
computeSha512HalfHash,
|
||||
(Slice const& data),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
accountKeylet,
|
||||
(AccountID const& account),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
ammKeylet,
|
||||
(Asset const& issue1, Asset const& issue2),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
checkKeylet,
|
||||
(AccountID const& account, std::uint32_t seq),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
credentialKeylet,
|
||||
(AccountID const& subject, AccountID const& issuer, Slice const& credentialType),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
didKeylet,
|
||||
(AccountID const& account),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
delegateKeylet,
|
||||
(AccountID const& account, AccountID const& authorize),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
depositPreauthKeylet,
|
||||
(AccountID const& account, AccountID const& authorize),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
escrowKeylet,
|
||||
(AccountID const& account, std::uint32_t seq),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
trustLineKeylet,
|
||||
(AccountID const& account1, AccountID const& account2, Currency const& currency),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
mptokenIssuanceKeylet,
|
||||
(AccountID const& issuer, std::uint32_t seq),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
mptokenKeylet,
|
||||
(MPTID const& mptid, AccountID const& holder),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
nftokenOfferKeylet,
|
||||
(AccountID const& account, std::uint32_t seq),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
offerKeylet,
|
||||
(AccountID const& account, std::uint32_t seq),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
oracleKeylet,
|
||||
(AccountID const& account, std::uint32_t docId),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
paychannelKeylet,
|
||||
(AccountID const& account, AccountID const& destination, std::uint32_t seq),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
permissionedDomainKeylet,
|
||||
(AccountID const& account, std::uint32_t seq),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
signerListKeylet,
|
||||
(AccountID const& account),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
ticketKeylet,
|
||||
(AccountID const& account, std::uint32_t seq),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
vaultKeylet,
|
||||
(AccountID const& account, std::uint32_t seq),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
getNFT,
|
||||
(AccountID const& account, uint256 const& nftId),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
getNFTIssuer,
|
||||
(uint256 const& nftId),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<std::uint32_t, HostFunctionError>),
|
||||
getNFTTaxon,
|
||||
(uint256 const& nftId),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<std::int32_t, HostFunctionError>),
|
||||
getNFTFlags,
|
||||
(uint256 const& nftId),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<std::int32_t, HostFunctionError>),
|
||||
getNFTTransferFee,
|
||||
(uint256 const& nftId),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<std::uint32_t, HostFunctionError>),
|
||||
getNFTSequence,
|
||||
(uint256 const& nftId),
|
||||
(const, override));
|
||||
|
||||
// Takes the rendered text, not the guest's buffer: rendering is `HostContext`'s, so what
|
||||
// a test asserts here is the log line a node would write.
|
||||
MOCK_METHOD(
|
||||
void,
|
||||
trace,
|
||||
(std::string_view const& msg, std::string_view const& data),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
floatFromInt,
|
||||
(std::int64_t x, std::int32_t mode),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
floatFromUint,
|
||||
(std::uint64_t x, std::int32_t mode),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
floatFromSTAmount,
|
||||
(STAmount const& x, std::int32_t mode),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
floatFromSTNumber,
|
||||
(STNumber const& x, std::int32_t mode),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<std::int64_t, HostFunctionError>),
|
||||
floatToInt,
|
||||
(Slice const& x, std::int32_t mode),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<FloatPair, HostFunctionError>),
|
||||
floatToMantExp,
|
||||
(Slice const& x),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
floatFromMantExp,
|
||||
(std::int64_t mantissa, std::int32_t exponent, std::int32_t mode),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<std::int32_t, HostFunctionError>),
|
||||
floatCompare,
|
||||
(Slice const& x, Slice const& y),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
floatAdd,
|
||||
(Slice const& x, Slice const& y, std::int32_t mode),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
floatSubtract,
|
||||
(Slice const& x, Slice const& y, std::int32_t mode),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
floatMultiply,
|
||||
(Slice const& x, Slice const& y, std::int32_t mode),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
floatDivide,
|
||||
(Slice const& x, Slice const& y, std::int32_t mode),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
floatRoot,
|
||||
(Slice const& x, std::int32_t n, std::int32_t mode),
|
||||
(const, override));
|
||||
|
||||
MOCK_METHOD(
|
||||
(std::expected<Bytes, HostFunctionError>),
|
||||
floatPower,
|
||||
(Slice const& x, std::int32_t n, std::int32_t mode),
|
||||
(const, override));
|
||||
};
|
||||
|
||||
// Matches a `Slice` (or anything with `data()`/`size()`) against the bytes of a string, so
|
||||
// an expectation can say *what* the guest asked the host to work on.
|
||||
//
|
||||
// `MATCHER_P` emits a function of this name, and gmock matchers are CamelCase by convention.
|
||||
// NOLINTNEXTLINE(readability-identifier-naming)
|
||||
MATCHER_P(BytesAre, expected, "")
|
||||
{
|
||||
return std::string_view{reinterpret_cast<char const*>(arg.data()), arg.size()} ==
|
||||
std::string_view{expected};
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
52
src/tests/libxrpl/tx/wasm/NFTFixture.cpp
Normal file
52
src/tests/libxrpl/tx/wasm/NFTFixture.cpp
Normal file
@@ -0,0 +1,52 @@
|
||||
#include <tx/wasm/NFTFixture.h>
|
||||
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/LedgerFormats.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/protocol/nft.h>
|
||||
#include <xrpl/protocol_autogen/transactions/NFTokenMint.h> // IWYU pragma: keep
|
||||
#include <xrpl/tx/transactors/nft/NFTokenMint.h>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <helpers/Account.h>
|
||||
|
||||
#include <optional>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
uint256
|
||||
NFTTest::makeNftId(AccountID const& issuer)
|
||||
{
|
||||
return NFTokenMint::createNFTokenID(kFlags, kFee, issuer, nft::toTaxon(kTaxon), kSequence);
|
||||
}
|
||||
|
||||
uint256
|
||||
NFTTest::mintNFT(Account const& issuer, std::optional<std::string_view> uri)
|
||||
{
|
||||
auto builder = transactions::NFTokenMintBuilder{issuer.id(), 0u};
|
||||
if (uri)
|
||||
builder.setURI(Slice{uri->data(), uri->size()});
|
||||
auto const r = ledger.submit(builder, issuer);
|
||||
EXPECT_EQ(r.ter, tesSUCCESS) << transToken(r.ter);
|
||||
ledger.close();
|
||||
|
||||
// The single minted token lives in the owner's first NFTokenPage.
|
||||
auto const& view = ledger.getOpenLedger();
|
||||
auto const first = keylet::nftokenPageMin(issuer.id()).key;
|
||||
auto const last = keylet::nftokenPageMax(issuer.id()).key;
|
||||
auto const pageKey = view.succ(first, last.next());
|
||||
EXPECT_TRUE(pageKey.has_value());
|
||||
auto const page = pageKey ? view.read(Keylet{ltNFTOKEN_PAGE, *pageKey}) : nullptr;
|
||||
EXPECT_NE(page, nullptr);
|
||||
if (!page)
|
||||
return uint256{};
|
||||
auto const& tokens = page->getFieldArray(sfNFTokens);
|
||||
EXPECT_FALSE(tokens.empty());
|
||||
return tokens.empty() ? uint256{} : tokens[0].getFieldH256(sfNFTokenID);
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
33
src/tests/libxrpl/tx/wasm/NFTFixture.h
Normal file
33
src/tests/libxrpl/tx/wasm/NFTFixture.h
Normal file
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/nft.h>
|
||||
|
||||
#include <helpers/Account.h>
|
||||
#include <tx/wasm/RealHostFixture.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
struct NFTTest : RealHostFixture
|
||||
{
|
||||
static constexpr std::uint16_t kFlags = nft::kFlagTransferable | nft::kFlagBurnable;
|
||||
static constexpr std::uint16_t kFee = 314;
|
||||
static constexpr std::uint32_t kTaxon = 12345;
|
||||
static constexpr std::uint32_t kSequence = 7;
|
||||
|
||||
static uint256
|
||||
makeNftId(AccountID const& issuer);
|
||||
|
||||
// Mint a real NFToken owned by `issuer` (taxon 0) and return its id, read back from the
|
||||
// owner's NFTokenPage. TxTest applies to the open ledger, which produces no metadata, so
|
||||
// the id is recovered from ledger state rather than from the mint's metadata.
|
||||
uint256
|
||||
mintNFT(Account const& issuer, std::optional<std::string_view> uri = std::nullopt);
|
||||
};
|
||||
|
||||
} // namespace xrpl::test
|
||||
248
src/tests/libxrpl/tx/wasm/Preflight.cpp
Normal file
248
src/tests/libxrpl/tx/wasm/Preflight.cpp
Normal file
@@ -0,0 +1,248 @@
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
#include <xrpl/tx/wasm/WasmVM.h>
|
||||
|
||||
#include <gmock/gmock.h>
|
||||
#include <gtest/gtest.h>
|
||||
#include <helpers/CaptureSink.h>
|
||||
#include <tx/wasm/MockHostFunctions.h>
|
||||
#include <tx/wasm/WasmRun.h>
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
namespace {
|
||||
|
||||
// A contract the engine can run: it compiles, imports only a declared host function, and
|
||||
// exports the entry point as `() -> i32`.
|
||||
constexpr std::string_view kRunnableWat = R"wat(
|
||||
(module
|
||||
(import "host_lib" "ldgr_index" (func $ldgr_index (param i32 i32) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(func (export "escrow_finish") (result i32)
|
||||
(call $ldgr_index (i32.const 0) (i32.const 4))))
|
||||
)wat";
|
||||
|
||||
} // namespace
|
||||
|
||||
// `preflightEscrowWasm` takes no host, so this fixture holds none - which is the point of
|
||||
// the signature, and what deriving from `MockVmTest` would hide. Only a journal, to read the
|
||||
// refusal out of.
|
||||
struct PreflightTest : testing::Test
|
||||
{
|
||||
CaptureSink sink{beast::Severity::Warning};
|
||||
|
||||
NotTEC
|
||||
preflight(std::string_view wat, std::string_view funcName = escrowFunctionName)
|
||||
{
|
||||
return preflightEscrowWasm(assembleWat(wat), beast::Journal{sink}, funcName);
|
||||
}
|
||||
|
||||
NotTEC
|
||||
preflightBytes(Bytes const& wasm, std::string_view funcName = escrowFunctionName)
|
||||
{
|
||||
return preflightEscrowWasm(wasm, beast::Journal{sink}, funcName);
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string
|
||||
logged() const
|
||||
{
|
||||
return sink.messages();
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(PreflightTest, RunnableContractPasses)
|
||||
{
|
||||
EXPECT_EQ(preflight(kRunnableWat), tesSUCCESS);
|
||||
EXPECT_TRUE(logged().empty()) << logged();
|
||||
}
|
||||
|
||||
TEST_F(PreflightTest, GarbageIsRefused)
|
||||
{
|
||||
EXPECT_EQ(preflightBytes(Bytes{}), temBAD_WASM);
|
||||
EXPECT_EQ(preflightBytes(Bytes{0x00, 0x61, 0x73, 0x6d}), temBAD_WASM);
|
||||
}
|
||||
|
||||
// The engine takes wasm binaries, and text is not one. The suite writes its modules as text
|
||||
// and assembles them, so this feeds the engine the very text the other tests assemble: a
|
||||
// transaction's validity must not depend on whether an assembler was linked in.
|
||||
TEST_F(PreflightTest, TextFormatModuleIsRefused)
|
||||
{
|
||||
Bytes const text{kRunnableWat.begin(), kRunnableWat.end()};
|
||||
|
||||
EXPECT_EQ(preflightBytes(text), temBAD_WASM);
|
||||
EXPECT_EQ(preflight(kRunnableWat), tesSUCCESS) << "the same module, assembled first";
|
||||
}
|
||||
|
||||
TEST_F(PreflightTest, ImportOfAnUnknownHostFunctionIsRefused)
|
||||
{
|
||||
constexpr std::string_view wat = R"wat(
|
||||
(module
|
||||
(import "host_lib" "no_such_function" (func $f (param i32) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(func (export "escrow_finish") (result i32) (call $f (i32.const 0))))
|
||||
)wat";
|
||||
|
||||
EXPECT_EQ(preflight(wat), temBAD_WASM);
|
||||
EXPECT_THAT(logged(), testing::HasSubstr("no host function 'no_such_function'"));
|
||||
}
|
||||
|
||||
// Host functions are registered under one module name. `env` is what plain clang emits, so a
|
||||
// contract built without the SDK's import attributes lands here.
|
||||
TEST_F(PreflightTest, ImportFromAnotherModuleIsRefused)
|
||||
{
|
||||
constexpr std::string_view wat = R"wat(
|
||||
(module
|
||||
(import "env" "ldgr_index" (func $f (param i32 i32) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(func (export "escrow_finish") (result i32) (i32.const 0)))
|
||||
)wat";
|
||||
|
||||
EXPECT_EQ(preflight(wat), temBAD_WASM);
|
||||
EXPECT_THAT(logged(), testing::HasSubstr("is not from 'host_lib'"));
|
||||
}
|
||||
|
||||
// A contract asking for more linear memory than the engine grants can never run, so it is
|
||||
// refused before it can be escrowed. The cap itself is granted.
|
||||
TEST_F(PreflightTest, MemoryPastTheCapIsRefused)
|
||||
{
|
||||
constexpr std::string_view tooMuch = R"wat(
|
||||
(module
|
||||
(memory (export "memory") 129)
|
||||
(func (export "escrow_finish") (result i32) (i32.const 0)))
|
||||
)wat";
|
||||
|
||||
EXPECT_EQ(preflight(tooMuch), temBAD_WASM);
|
||||
EXPECT_THAT(logged(), testing::HasSubstr("memory: initial memory of 129 pages"));
|
||||
|
||||
constexpr std::string_view atTheCap = R"wat(
|
||||
(module
|
||||
(memory (export "memory") 128)
|
||||
(func (export "escrow_finish") (result i32) (i32.const 0)))
|
||||
)wat";
|
||||
|
||||
EXPECT_EQ(preflight(atTheCap), tesSUCCESS);
|
||||
}
|
||||
|
||||
// A table is allocated in full at instantiation, before any gas is charged, so an oversized
|
||||
// one is refused before it can be escrowed. Screening sees only an *exported* table; the
|
||||
// store's limiter is what refuses the table a contract keeps to itself.
|
||||
TEST_F(PreflightTest, TablePastTheCapIsRefused)
|
||||
{
|
||||
constexpr std::string_view tooMuch = R"wat(
|
||||
(module
|
||||
(memory (export "memory") 1)
|
||||
(table (export "t") 1025 funcref)
|
||||
(func (export "escrow_finish") (result i32) (i32.const 0)))
|
||||
)wat";
|
||||
|
||||
EXPECT_EQ(preflight(tooMuch), temBAD_WASM);
|
||||
EXPECT_THAT(logged(), testing::HasSubstr("table: initial table of 1025 elements"));
|
||||
|
||||
constexpr std::string_view atTheCap = R"wat(
|
||||
(module
|
||||
(memory (export "memory") 1)
|
||||
(table (export "t") 1024 funcref)
|
||||
(func (export "escrow_finish") (result i32) (i32.const 0)))
|
||||
)wat";
|
||||
|
||||
EXPECT_EQ(preflight(atTheCap), tesSUCCESS);
|
||||
}
|
||||
|
||||
TEST_F(PreflightTest, MissingEntryPointIsRefused)
|
||||
{
|
||||
constexpr std::string_view wat = R"wat(
|
||||
(module
|
||||
(memory (export "memory") 1)
|
||||
(func (export "other") (result i32) (i32.const 0)))
|
||||
)wat";
|
||||
|
||||
EXPECT_EQ(preflight(wat), temBAD_WASM);
|
||||
EXPECT_THAT(logged(), testing::HasSubstr("no entry point 'escrow_finish'"));
|
||||
}
|
||||
|
||||
TEST_F(PreflightTest, EntryPointOfTheWrongTypeIsRefused)
|
||||
{
|
||||
constexpr std::string_view wat = R"wat(
|
||||
(module
|
||||
(memory (export "memory") 1)
|
||||
(func (export "escrow_finish") (result i64) (i64.const 0)))
|
||||
)wat";
|
||||
|
||||
EXPECT_EQ(preflight(wat), temBAD_WASM);
|
||||
EXPECT_THAT(logged(), testing::HasSubstr("has the wrong signature"));
|
||||
}
|
||||
|
||||
// Screening is for the entry point the caller names, as a run is: a contract screened for one
|
||||
// export says nothing about another.
|
||||
TEST_F(PreflightTest, EntryPointIsTheNameTheCallerGives)
|
||||
{
|
||||
constexpr std::string_view wat = R"wat(
|
||||
(module
|
||||
(memory (export "memory") 1)
|
||||
(func (export "other") (result i32) (i32.const 0)))
|
||||
)wat";
|
||||
|
||||
EXPECT_EQ(preflight(wat, "other"), tesSUCCESS);
|
||||
EXPECT_EQ(preflight(wat), temBAD_WASM);
|
||||
}
|
||||
|
||||
// Every refusal is logged with the engine's own description and the TER: without it a node
|
||||
// operator has a `temBAD_WASM` and no way to tell a contract author which of the three
|
||||
// stages refused the module.
|
||||
TEST_F(PreflightTest, RefusalNamesTheReasonAndTheTer)
|
||||
{
|
||||
EXPECT_EQ(preflightBytes(Bytes{0x00, 0x61, 0x73, 0x6d}), temBAD_WASM);
|
||||
|
||||
EXPECT_THAT(logged(), testing::HasSubstr("compile: "));
|
||||
EXPECT_THAT(logged(), testing::HasSubstr(transToken(temBAD_WASM)));
|
||||
}
|
||||
|
||||
// A module that passes screening still has to pass the run's own stages, and one that fails
|
||||
// screening would have failed the run. Same modules through both entry points, so the two do
|
||||
// not have to be trusted to agree.
|
||||
TEST_F(PreflightTest, ScreeningAgreesWithARun)
|
||||
{
|
||||
struct Case
|
||||
{
|
||||
std::string_view label;
|
||||
std::string_view wat;
|
||||
bool passes;
|
||||
};
|
||||
|
||||
// clang-format off
|
||||
constexpr Case cases[]{
|
||||
{.label = "a runnable contract", .wat = kRunnableWat, .passes = true},
|
||||
{.label = "an unknown host function",
|
||||
.wat = R"wat((module (import "host_lib" "nope" (func $f (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(func (export "escrow_finish") (result i32) (call $f))))wat",
|
||||
.passes = false},
|
||||
{.label = "no entry point",
|
||||
.wat = R"wat((module (memory (export "memory") 1)
|
||||
(func (export "other") (result i32) (i32.const 0))))wat",
|
||||
.passes = false},
|
||||
};
|
||||
// clang-format on
|
||||
|
||||
for (auto const& [label, wat, passes] : cases)
|
||||
{
|
||||
auto const screened = preflight(wat);
|
||||
EXPECT_EQ(isTesSuccess(screened), passes) << label;
|
||||
|
||||
// The run's own verdict on the same bytes. A refused module must not reach the
|
||||
// contract's first instruction; an accepted one must get past the entry-point
|
||||
// lookup, whatever it then does.
|
||||
testing::StrictMock<MockHostFunctions> host{beast::Journal{sink}};
|
||||
EXPECT_CALL(host, checkSelf()).WillRepeatedly(testing::Return(true));
|
||||
EXPECT_CALL(host, getLedgerSqn()).WillRepeatedly(testing::Return(7u));
|
||||
|
||||
auto const ran = runEscrowWasm(assembleWat(wat), host, 100'000);
|
||||
EXPECT_EQ(ran.has_value(), passes) << label;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
347
src/tests/libxrpl/tx/wasm/README.md
Normal file
347
src/tests/libxrpl/tx/wasm/README.md
Normal file
@@ -0,0 +1,347 @@
|
||||
# WASM host-function tests — layering
|
||||
|
||||
These tests are deliberately **layered**: each layer isolates one thing, so a failure points at
|
||||
one place instead of "somewhere in the stack." If a folder looks thin, that is usually because
|
||||
the breadth it might seem to be missing lives in a sibling layer. This file is the map.
|
||||
|
||||
## The layers
|
||||
|
||||
| Layer | Location | host | VM | ledger | Answers |
|
||||
| ------------------------------------- | ------------------------------------------------------------------------------------------- | ---- | --- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Engine / gas / limits / ABI | `crates/xrpl-wasm-vm/tests`, `crates/xrpl-host-functions/tests` (Rust, `.wat` + `FakeHost`) | mock | ✓ | ✗ | gas, transfer budget, memory/field limits, preflight screening, VM limits, the generated ABI + error codes |
|
||||
| `host_context/` (`HostContextTest`) | `src/tests/libxrpl/tx/wasm/host_context` | mock | ✗ | ✗ | the `HostContext` marshalling shim in isolation (byte order, buffer sizing, `SField` translation) |
|
||||
| `host_calls/` (`HostCallTest`) | `.../host_calls` | mock | ✓ | ✗ | per-host-function **wire contract** — what the host was asked, what came back — a real guest through the real VM + `HostContext`, mock host |
|
||||
| `host_functions/` (`RealHostFixture`) | `.../host_functions` | real | ✗ | real | each host function's **actual answer** vs. real `TxTest` ledger state (impl called directly in C++) |
|
||||
| `e2e/` (`RealVmTest`) | `.../e2e` | real | ✓ | real | **full-stack integration** — VM + `HostContext` + real impl + real ledger, driven by a WAT contract |
|
||||
|
||||
`MockVmTest` / `RealVmTest` are the mock-host and real-host counterparts of the same VM harness;
|
||||
both forward to the shared `runWat(HostFunctions&, ...)` in `WasmRun.h`, differing only in the
|
||||
host they inject.
|
||||
|
||||
## `*.bench.cpp` — gas calibration
|
||||
|
||||
Interleaved with the tests are `*.bench.cpp` files. They are **not tests**: nothing asserts,
|
||||
and a number moving is not a build failure. They answer the pricing question the tests cannot —
|
||||
whether each `#[gas = N]` in `crates/xrpl-host-functions/src/lib.rs` matches what the function
|
||||
actually costs.
|
||||
|
||||
**One `.bench.cpp` per host function, named after its test** — `EscrowKeylet.cpp` and
|
||||
`EscrowKeylet.bench.cpp` sit next to each other, 61 of each. That is a checklist rather than a
|
||||
judgment call: adding a host function means adding two files, and nobody has to decide where a
|
||||
benchmark belongs. Shared ledger setup lives in the `Fixtures` type (`BenchFixtures.h`) — one
|
||||
ledger, funded once, for the whole binary — so each file holds only the call it measures.
|
||||
|
||||
They build into a **separate executable** (`xrpl.bench.wasm`), and `xrpl_tests` filters
|
||||
`*.bench.cpp` out of its source globs, so benchmark runtime never lands on the `ctest` path.
|
||||
|
||||
### Running them
|
||||
|
||||
The executable lands in the **build root**, beside `xrpl_tests`:
|
||||
|
||||
```bash
|
||||
# configure with -o benchmark=True (the conan default), then:
|
||||
cmake --build build --target xrpl.bench.wasm
|
||||
./build/xrpl.bench.wasm # everything (~2 min)
|
||||
./build/xrpl.bench.wasm --benchmark_filter=sha512Half
|
||||
./build/xrpl.bench.wasm --benchmark_format=json >gas.json
|
||||
```
|
||||
|
||||
**Build Release first.** A Debug build inflates the crossing (templates and `std::expected`,
|
||||
none of it inlined) far more than it inflates the impls, so Debug overstates what leaving the
|
||||
guest costs. Google Benchmark prints a warning when it detects this. Ratios between `Impl`
|
||||
cases survive Debug reasonably; absolute `implied_gas` does not.
|
||||
|
||||
> **On hardware performance counters.** Google Benchmark can read instruction and cycle counts
|
||||
> through libpfm on Linux, and that was wired up here at one point, but it has been removed: its
|
||||
> counters start and stop around the whole `for (auto _ : state)` body, which for these cases
|
||||
> covers the loaded run _and_ the baseline run _and_ two module compilations. They would not
|
||||
> reflect the subtraction that makes these numbers mean anything. Getting useful instruction
|
||||
> counts needs a custom `perf_event_open` around the same regions `timeRun` brackets — worth
|
||||
> doing, but the dependency buys nothing until then.
|
||||
|
||||
### Reading the output
|
||||
|
||||
Gas _is_ wasmi fuel — `set_fuel(gas)` meters guest instructions and host charges from one pool —
|
||||
so a host call's price is answerable as a **ratio**: how many guest instructions' worth of work
|
||||
is it? That is machine-independent, which matters because a consensus rule cannot be derived
|
||||
from one laptop's nanoseconds.
|
||||
|
||||
| Counter | Meaning |
|
||||
| --------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `suggested_gas` | **the answer** — what this function should be priced at |
|
||||
| `declared_gas` | what `lib.rs` says today |
|
||||
| `price_ratio` | `declared / suggested`. **1.0 is correct; below 1 is underpriced** |
|
||||
| `implied_gas` | the raw measurement, before the crossing is added back |
|
||||
| `charged_gas` | what the engine actually billed (`EscrowResult::cost`); confirms the right call was measured |
|
||||
| `ns_per_call` | raw wall time, for debugging a suspicious ratio |
|
||||
|
||||
`declared_gas` is read from the declaration through the `wasm_testkit` bridge
|
||||
(`declared_gas(wasm_name)`), not transcribed into C++ — 61 copied constants would drift from
|
||||
`lib.rs` the first time a price changed, and drift _silently_, because a benchmark has nothing
|
||||
to fail.
|
||||
|
||||
`price_ratio` is what you sort by. **Below 1 is the direction that matters**: an underpriced call
|
||||
is one a contract can buy too cheaply, which is a denial-of-service vector rather than a rounding
|
||||
error. Above 1 the table merely overcharges. The mispriced functions come to the top with:
|
||||
|
||||
```bash
|
||||
./build/xrpl.bench.wasm --benchmark_format=json |
|
||||
jq -r '.benchmarks[] | select(.price_ratio) | [.price_ratio, .name] | @tsv' | sort -n
|
||||
```
|
||||
|
||||
### How `suggested_gas` is measured
|
||||
|
||||
Gas is not a unit of time, so a wall-clock number cannot be a gas number. The bridge between them
|
||||
is that **gas is wasmi fuel** — `set_fuel(gas)` meters guest instructions and host charges from
|
||||
one pool — so one unit of gas is, by construction, about one guest instruction. That turns the
|
||||
question into a ratio: _how many guest instructions' worth of work is this host call?_ Everything
|
||||
below exists to answer that without any hard-coded constant.
|
||||
|
||||
Four steps, each a subtraction, all in `WasmBench.h` / `WasmBench.cpp`.
|
||||
|
||||
**1. `Calibration::secondsPerGas()` — what one unit of gas costs on this machine.**
|
||||
Assemble two modules that differ only in a loop bound: one runs a trivial `i32.add` body
|
||||
`kCallsPerRun` times, the other zero times. Run both, and take
|
||||
|
||||
```
|
||||
secondsPerGas = (time_busy − time_idle) / (fuel_busy − fuel_idle)
|
||||
```
|
||||
|
||||
Both numerator terms include module compilation, instantiation and process noise; both
|
||||
denominator terms include the engine's fixed overhead. Subtracting cancels all of it, leaving
|
||||
seconds per unit of fuel. Taken as the **minimum over 32 pairs** (after 8 warm-up pairs), because
|
||||
the fastest run is the one least disturbed by the scheduler. Cached — it describes the machine,
|
||||
not the case.
|
||||
|
||||
**2. `secondsPerCall` — the isolated cost of one host call.**
|
||||
The same subtraction, one level up. A `ThroughVm` case runs a contract making N host calls and a
|
||||
**byte-identical** one making none, then reports `(t_loaded − t_baseline) / N` _per iteration_, so
|
||||
Google Benchmark's variance statistics describe the host call rather than the run containing it.
|
||||
An `Impl` case times `kCallsPerRun` direct calls and divides, spreading the clock read over
|
||||
enough work that it does not distort a cheap call.
|
||||
|
||||
**3. `implied_gas` — the measurement, in gas.**
|
||||
|
||||
```
|
||||
implied_gas = secondsPerCall / secondsPerGas
|
||||
```
|
||||
|
||||
Machine-independent: both terms scale with the box, so the ratio does not.
|
||||
|
||||
**4. `Calibration::crossingFloorGas()` — the toll every call pays.**
|
||||
Measured once, from `ldgr_index` — the cheapest host function there is, taking no input and
|
||||
answering from a header already in hand, so almost nothing remains after subtracting it away:
|
||||
|
||||
```
|
||||
crossing_floor = (secondsPerCall_ThroughVm − secondsPerCall_Impl) / secondsPerGas
|
||||
```
|
||||
|
||||
That is region decode, bounds checks and the cxx hop, and nothing else.
|
||||
|
||||
**Putting it together:**
|
||||
|
||||
```
|
||||
suggested_gas = implied_gas # ThroughVm — the guest already paid the crossing
|
||||
suggested_gas = implied_gas + crossing_floor # Impl — a guest cannot call without paying it
|
||||
price_ratio = declared_gas / suggested_gas
|
||||
```
|
||||
|
||||
**Why you can trust it measured the right thing.** `charged_gas` comes from the engine's own fuel
|
||||
meter (`EscrowResult::cost`), independently of every timing above. On a `ThroughVm` case it should
|
||||
equal the declared gas plus the fuel the guest itself burns — the loop body (13, which
|
||||
`GuestInstruction` reports on its own) plus the `i32.const`s pushing the call's arguments. So
|
||||
`escrow_id` reports `charged_gas ≈ 367` against a declared 350: 13 for the loop, 4 for its six
|
||||
argument constants. When that arithmetic does not line up, the case is measuring something other
|
||||
than the call it names.
|
||||
|
||||
**`guestInstruction` is the harness's self-test, and it has a number.** It runs the same loop body
|
||||
`Calibration::secondsPerGas()` calibrates against, so its `implied_gas` (from wall time) and
|
||||
`charged_gas` (from the engine's fuel meter) are two measurements of one quantity and must agree.
|
||||
On a quiet machine, Release, that is currently **`implied_gas` ≈ 13.6 against `charged_gas` 13.007
|
||||
— about 4% high, with roughly 4% run-to-run spread.** Treat a persistent gap much beyond that as a
|
||||
harness bug rather than a property of the machine, and do not trust any other number in the run
|
||||
until it is closed.
|
||||
|
||||
That check is worth running because it has already caught a real defect. Calibration originally
|
||||
took a _best-of-N_ while the cases report a _mean_, and since `implied_gas =
|
||||
secondsPerCall / secondsPerGas`, a minimum in the divisor against a mean in the dividend biased
|
||||
every reported number one way — `guestInstruction` read 18.2 against 13.007, +40%, and every
|
||||
`suggested_gas` in the report was inflated by that factor. Both estimators are now means. **If you
|
||||
change how either side is estimated, change both.**
|
||||
|
||||
**Two limitations, both real.**
|
||||
|
||||
- `suggested_gas` for an `Impl`-only case is a **lower bound**. The crossing floor is measured on a
|
||||
call with no input, so a function that moves bytes pays more than the floor. The swept cases
|
||||
(`Sha512Half`, `UpdateData`) measure that per-byte term where it matters.
|
||||
- A Debug build inflates the crossing far more than the impls, so absolute values are not usable
|
||||
there. **Ratios between `Impl` cases survive Debug; `suggested_gas` does not.**
|
||||
- Run-to-run spread is a few percent, which is immaterial next to the pricing errors this suite finds
|
||||
(6x-20x). If you need it tighter, `--benchmark_repetitions=N` averages the noise down; it will
|
||||
not touch a systematic bias, which is what the `guestInstruction` check above is for.
|
||||
|
||||
### The two case kinds, and why the subtraction is the point
|
||||
|
||||
Every function has an `Impl` case; the distinct crossing shapes also have a `ThroughVm` case.
|
||||
|
||||
- **`Impl`** — the host method called directly. No guest, no VM, no marshalling: the computation alone.
|
||||
- **`ThroughVm`** — the same call made by a real WAT contract through the real VM, against a real
|
||||
ledger. Measured as the difference between a contract making N host calls and a **byte-identical**
|
||||
one making none, so compilation, instantiation and the guest's own loop cancel out.
|
||||
|
||||
`ThroughVm − Impl` is the **crossing**: region decode, bounds checks, memory copies, the cxx hop.
|
||||
`Crossing.bench.cpp` brackets its floor with the cheapest possible host call, and the size-swept
|
||||
cases (`Sha512Half`, `UpdateData`) expose its per-byte term.
|
||||
|
||||
`ThroughVm` cases are deliberately **one per crossing shape, not one per function** — the same
|
||||
argument as the e2e rule above. What the crossing costs depends on a call's shape, not on which
|
||||
function makes it, so a `float_sub` ThroughVm would only re-measure `float_add`'s.
|
||||
|
||||
### Gotchas, all of which have already cost someone an afternoon
|
||||
|
||||
- **The wasm ABI is not the trait's argument order.** `float_add(x, y, mode, out)` in Rust is
|
||||
`(x_ptr, x_len, y_ptr, y_len, out_ptr, out_len, mode)` on the wire — scalars move _after_ the
|
||||
output region. Check `register.rs`, not `lib.rs`, when writing WAT.
|
||||
- **A soft host error still "succeeds".** The run completes and gas is charged _before_ the body,
|
||||
so a wrong-argument case reports a plausible, confidently wrong number — it measures the
|
||||
rejection path. The harness guards this by requiring the contract's result to be `>= 0`. The
|
||||
tell is a `ThroughVm` case coming out _faster_ than its `Impl` pair.
|
||||
- **A host serves exactly one run** (`checkSelf` assert in `WasmVM.cpp`), so a benchmark builds a
|
||||
fresh host per run and cannot pre-cache a slot.
|
||||
- **`MAX_FIELD_BYTES` is 1024** — no value crosses the boundary in either direction above 1 KiB,
|
||||
so size sweeps stop there.
|
||||
- Cases pin `->Iterations(...)`: with `UseManualTime`, Google Benchmark's automatic sizing reads
|
||||
only the tiny reported residue and would ask for millions of iterations.
|
||||
|
||||
## What `e2e/` covers — the rule
|
||||
|
||||
**`e2e/` covers every marshalling shape and every cross-call convention exactly once. It does
|
||||
not cover every function.** That is a completeness claim on the axis e2e can uniquely test, not
|
||||
a sample.
|
||||
|
||||
The reasoning: `host_calls` pins what the bridge _asks_ a host and what it does with a _canned_
|
||||
answer; `host_functions` pins what the real impl _answers_. The C++ type system guarantees the
|
||||
two agree on signatures — the real impl implements the same interface the mock does. What
|
||||
nothing guarantees is that they agree on **conventions**: units, endianness, buffer layout, the
|
||||
meaning of a wire format. A mocked bridge test and a direct impl test can both pass while
|
||||
meaning different things by "a four-byte sequence number", because in neither test does a real
|
||||
guest write bytes that a real host reads. That is precisely the `seq`-as-little-endian-region
|
||||
bug: every internal test passed, and it was caught by cross-checking the guest SDK.
|
||||
|
||||
Convention mismatch is a property of the **shape** of a call, not of the function making it. All
|
||||
19 keylet functions share one shape; a 19th keylet e2e proves nothing the 1st did not. So the
|
||||
inventory below is indexed by shape, and it is meant to be exhaustive:
|
||||
|
||||
| Shape / convention | Covered by | Why it is its own row |
|
||||
| ----------------------------------------- | -------------------------- | -------------------------------------------------------- |
|
||||
| no-input scalar getter | `LedgerSqnE2e` | header read; the minimal call |
|
||||
| field code in, bytes out (ledger object) | `CurrentLedgerObjFieldE2e` | `SField` translation over a real object |
|
||||
| field code in, bytes out (transaction) | `TxFieldE2e` | a different source than a ledger object |
|
||||
| region in, bytes out + `u32` region | `CacheLedgerObjE2e` | the 4-byte little-endian region convention |
|
||||
| slot in, bytes out — **cross-call state** | `CacheLedgerObjE2e` | the slot table is the only host state outliving one call |
|
||||
| locator (path of i32 steps) | `TxNestedFieldE2e` | a wire format the guest writes and the host walks |
|
||||
| **two** output regions | `FloatToMantExpE2e` | two bounds checks, two writes, an ordering between them |
|
||||
| write / mutation | `SetDataE2e` | the one thing a contract changes |
|
||||
| **error** path from a real impl | `HostErrorE2e` | soft code produced by a real failure, not a staged one |
|
||||
| realistic multi-call contract | `HostFunctionTourE2e` | the old `all_host_functions` tour shape, as one test |
|
||||
|
||||
Adding a function does not require a new e2e case — unless it introduces a shape or a convention
|
||||
not in that table, in which case it does. Per-function breadth (does fn X return the right
|
||||
value, does it marshal correctly) lives in `host_functions/` and `host_calls/`, one case each,
|
||||
and re-driving that shared machinery 61 times e2e would cost heavy per-test ledger setup for no
|
||||
added signal.
|
||||
|
||||
The **guest SDK** (`xrpl-std` / `xrpl-escrow`, from the external `xrpl-wasm-stdlib` repo) is
|
||||
intentionally **not** exercised here: that is the SDK repo's own test suite. WAT tests the host
|
||||
side (this repo's code); a compiled guest would couple this suite to that repo and a Rust→wasm
|
||||
toolchain.
|
||||
|
||||
## SDK ↔ host agreement — what the retired fixtures tested, and why it lives elsewhere
|
||||
|
||||
The old `wasm_fixtures/` guests (`all_host_functions`, `all_keylets`, `codecov_tests`) were
|
||||
compiled from the real `xrpl-std` / `xrpl-escrow` SDK, so beyond exercising host functions they
|
||||
implicitly tested the **SDK's side of the ABI contract** — that the SDK and the host agree on the
|
||||
wire format:
|
||||
|
||||
- **host bindings** — import module/name and parameter order/types actually reach the host functions
|
||||
- **field-code & locator encoding** — `sfield` constants and nested-field `Locator` serialization
|
||||
- **type serialization** — `Issue` / `Currency` / `MptId` / `XrpIssue` encode to the byte layouts the host decodes
|
||||
- **error-code enum** — the SDK's `error_codes` match the host's wire numbers
|
||||
- **size constants** — `DEFAULT_BLOB_SIZE` / `XRPL_CONTRACT_DATA_SIZE` match the host's caps
|
||||
- **typed accessors** — `get_current_escrow`, `ledger_object::get_field`, `keylets::*` build requests and decode responses
|
||||
|
||||
None of that is host code — it is the SDK's, and it is the `xrpl-wasm-stdlib` repo's job to test.
|
||||
The tests here hand-write the ABI in WAT (raw imports, literal field codes, hand-built byte
|
||||
layouts), which **deliberately bypasses all SDK code**. So SDK correctness is out of scope here by
|
||||
design.
|
||||
|
||||
**The one residual gap** is the _direct_ SDK↔host cross-check a compiled guest gave for free. The
|
||||
new split verifies agreement **transitively**: the SDK repo tests the SDK against the ABI spec, and
|
||||
this repo tests the host against the same spec (`host_calls`, the `generated_abi.rs` spec table,
|
||||
`host_errors.rs`). That is sound as long as both conform to the spec; it would not catch a drift
|
||||
where the SDK and host diverge on an ambiguous point. Closing that gap is **not** an xrpld unit
|
||||
test — it is a **cross-repo integration test** (compiled `xrpl-wasm-stdlib` guests run against a
|
||||
real xrpld host) belonging in CI where the Rust→wasm toolchain exists.
|
||||
|
||||
## Out of scope — transactor-level (L5) tests deferred until the transactor is wired
|
||||
|
||||
This migration ported `Wasm_test.cpp` + the `wasm_fixtures/` guests, which drive the VM directly
|
||||
via `runEscrowWasm`. In the upstream `ripple/smart-escrow` branch the **same fixtures** are also
|
||||
consumed by two **transactor-level** suites that are **not** part of this port and have **no
|
||||
equivalent here yet**, because the redesign branch does not yet wire `runEscrowWasm` into the
|
||||
`EscrowFinish` transactor (it has no caller under `src/xrpld`):
|
||||
|
||||
- **`EscrowSmart_test.cpp`** — full `Env → EscrowFinish → ledger`. Its cases test things none of
|
||||
the layers above cover, because they only exist once a transactor runs the contract:
|
||||
- **`set_data` persistence** — "Update escrow data on failure" asserts the contract's data field
|
||||
is written to the escrow ledger object **even on `tecBYTECODE_REJECTED`**. (Note: in _this_
|
||||
branch `set_data` is _not_ persisted — there is no transactor caller yet — so this is a real
|
||||
gap, not a redundancy.)
|
||||
- **gas → fee / meta** — `sfGasUsed` and `sfVMReturnCode` surfaced in transaction metadata.
|
||||
- **owner reserve / owner count** accounting for the bytecode-bearing escrow.
|
||||
- **transactor-driven tours** — "Test all host functions", "Test all keylet host functions",
|
||||
"Test large wasm modules".
|
||||
- **`PayChan_test.cpp`** — also consumes `wasm_fixtures` symbols at the transactor level.
|
||||
|
||||
These belong to the **L5 transactor layer**. When `EscrowFinish` is wired to `runEscrowWasm` in the
|
||||
redesign, those cases need a home (as C++ transactor tests over a real `Env`), and the persistence /
|
||||
gas-in-meta / reserve behaviors should be pinned there — the WAT layers here deliberately stop at
|
||||
the VM boundary and do not exercise the transactor.
|
||||
|
||||
## Old → new test map
|
||||
|
||||
`Wasm_test.cpp` (retired) + `wasm_fixtures/` guests → the new design. ★ = authored during the
|
||||
migration.
|
||||
|
||||
| Old test / fixture | New home | Status |
|
||||
| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| `wasm lib test` (inline addTwo) | Rust `vm_limits::WasmVMTest::ContractReturnValueReachesCaller` | superseded |
|
||||
| `bad wasm test` | Rust `preflight::garbage_does_not_pass` | superseded |
|
||||
| `Wasm get ledger sequence` | `host_calls/LedgerSqn` + ★`e2e/LedgerSqn` | superseded + e2e |
|
||||
| `import/export functions` | Rust `preflight` (imports) / `vm_limits` (imports at instantiation) | superseded |
|
||||
| `import/export section corruption` | Rust `preflight::garbage_does_not_pass` + ★`structurally_malformed_modules_are_refused` | superseded + ported |
|
||||
| `Wasm fibo` | Rust `budgets` (gas baseline) | superseded |
|
||||
| `wasm test host functions cost` | Rust `budgets` (`a_host_call_costs_its_gas...`) | superseded |
|
||||
| `escrow wasm devnet` (26-fn tour) | `host_functions/*` + `host_calls/*` + ★`e2e/*` (incl. `HostFunctionTourE2e`) | decomposed |
|
||||
| `Codecov wasm test` | Rust `memory_policy` + `host_calls` error paths | superseded |
|
||||
| `float point` (was commented) | `host_functions/Float*` + Rust `host_calls` float (incl. ★8 gaps) | superseded + ported |
|
||||
| `disabled float` | Rust `vm_limits::disabled_features` (floats) | superseded |
|
||||
| `memory limit tests` (×11) | Rust `memory_policy` + `vm_limits` | superseded |
|
||||
| `table limit tests` (×5) | Rust `vm_limits` | superseded |
|
||||
| `disabled proposal tests` (×13) | Rust `vm_limits::disabled_features` | superseded |
|
||||
| `trap tests` (×5) | Rust `vm_limits`: `unreachable` + ★div0 / overflow / null-indirect / sig-mismatch | all ported |
|
||||
| `Wasm Wasi tests` (×2) | Rust `preflight`/`vm_limits` (unknown import) | superseded |
|
||||
| `Section Corruption` (×10) | Rust ★`structurally_malformed_modules_are_refused` (6) + ★`parser_abuse_shapes_are_refused` (4 DoS) | all ported |
|
||||
| `start function loop` | Rust `vm_limits` (start section) / `preflight` | superseded |
|
||||
| `Wasm Bad Align` | Rust ★`host_calls::an_unaligned_input_is_read_intact` | ported |
|
||||
| `invalid return type` / `invalid params` | Rust `preflight`/`vm_limits` | superseded |
|
||||
| `Wasm swap bytes` | plain C++ endian util test (not a VM test) | keep as-is |
|
||||
| `Many params` — params / locals / functions | Rust ★`vm_limits` (`too_many_params`, `too_many_locals`, `past_the_register_frame`, `many_functions_currently_run_unbounded` [`#[ignore]` marker]) | ported (functions enforcement deferred to preflight) |
|
||||
| `deep recursion` | Rust ★`vm_limits::unbounded_recursion_is_stopped_by_the_call_stack_limit` | ported |
|
||||
| `infinite loop` | Rust `budgets::an_endless_loop_is_stopped_by_gas` | superseded |
|
||||
| `reserved opcodes` | Rust `vm_limits::disabled_features` (representative) | superseded |
|
||||
| float sub/mult/div/pow, from_stamount/stnumber, to_int, from_mant_exp | Rust ★`host_calls` (8 tests) | ported |
|
||||
| **Fixture** `all_host_functions` | ★`e2e` (incl. tour) + `host_functions/*` + `host_calls/*`; SDK → external repo | decomposed |
|
||||
| **Fixture** `all_keylets` (was orphaned) | `host_functions/*Keylet`; SDK → external repo | decomposed |
|
||||
| **Fixture** `codecov_tests` | Rust `memory_policy` + `host_calls` error paths + ★`e2e` | decomposed |
|
||||
| `getData helper functions` | — (tested a deleted engine API) | removed |
|
||||
297
src/tests/libxrpl/tx/wasm/RealHostFixture.cpp
Normal file
297
src/tests/libxrpl/tx/wasm/RealHostFixture.cpp
Normal file
@@ -0,0 +1,297 @@
|
||||
#include <tx/wasm/RealHostFixture.h>
|
||||
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/ledger/ApplyView.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/Issue.h>
|
||||
#include <xrpl/protocol/KeyType.h>
|
||||
#include <xrpl/protocol/Keylet.h>
|
||||
#include <xrpl/protocol/MPTIssue.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STAmount.h>
|
||||
#include <xrpl/protocol/STNumber.h>
|
||||
#include <xrpl/protocol/STObject.h>
|
||||
#include <xrpl/protocol/STTx.h>
|
||||
#include <xrpl/protocol/SecretKey.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/protocol/TxFormats.h>
|
||||
#include <xrpl/protocol/UintTypes.h>
|
||||
#include <xrpl/protocol/XRPAmount.h>
|
||||
#include <xrpl/protocol_autogen/transactions/SignerListSet.h> // IWYU pragma: keep
|
||||
#include <xrpl/tx/ApplyContext.h>
|
||||
#include <xrpl/tx/wasm/HostFuncImpl.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <helpers/Account.h>
|
||||
#include <helpers/TxTest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <functional>
|
||||
#include <iterator>
|
||||
#include <memory>
|
||||
#include <span>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
Bytes
|
||||
RealHostFixture::toBytes(std::uint8_t value)
|
||||
{
|
||||
return {value};
|
||||
}
|
||||
|
||||
Bytes
|
||||
RealHostFixture::toBytes(std::uint16_t value)
|
||||
{
|
||||
return {static_cast<std::uint8_t>(value), static_cast<std::uint8_t>(value >> 8)};
|
||||
}
|
||||
|
||||
Bytes
|
||||
RealHostFixture::toBytes(std::uint32_t value)
|
||||
{
|
||||
return {
|
||||
static_cast<std::uint8_t>(value),
|
||||
static_cast<std::uint8_t>(value >> 8),
|
||||
static_cast<std::uint8_t>(value >> 16),
|
||||
static_cast<std::uint8_t>(value >> 24)};
|
||||
}
|
||||
|
||||
Bytes
|
||||
RealHostFixture::toBytes(uint256 const& value)
|
||||
{
|
||||
return Bytes{std::begin(value), std::end(value)};
|
||||
}
|
||||
|
||||
Bytes
|
||||
RealHostFixture::toBytes(std::string_view value)
|
||||
{
|
||||
return Bytes{std::begin(value), std::end(value)};
|
||||
}
|
||||
|
||||
Bytes
|
||||
RealHostFixture::toBytes(std::span<std::uint8_t const> value)
|
||||
{
|
||||
return Bytes{std::begin(value), std::end(value)};
|
||||
}
|
||||
|
||||
Bytes
|
||||
RealHostFixture::toBytes(AccountID const& account)
|
||||
{
|
||||
return Bytes{std::begin(account), std::end(account)};
|
||||
}
|
||||
|
||||
Bytes
|
||||
RealHostFixture::toBytes(Issue const& issue)
|
||||
{
|
||||
auto s = Serializer{};
|
||||
s.addBitString(issue.currency);
|
||||
if (!isXRP(issue.currency))
|
||||
s.addBitString(issue.account);
|
||||
return s.getData();
|
||||
}
|
||||
|
||||
Bytes
|
||||
RealHostFixture::toBytes(Asset const& asset)
|
||||
{
|
||||
if (asset.holds<Issue>())
|
||||
return toBytes(asset.get<Issue>());
|
||||
|
||||
auto const& mptIssue = asset.get<MPTIssue>();
|
||||
auto const& mptID = mptIssue.getMptID();
|
||||
return Bytes{mptID.cbegin(), mptID.cend()};
|
||||
}
|
||||
|
||||
Bytes
|
||||
RealHostFixture::toBytes(STAmount const& amount)
|
||||
{
|
||||
auto msg = Serializer{};
|
||||
amount.add(msg);
|
||||
return msg.getData();
|
||||
}
|
||||
|
||||
Bytes
|
||||
RealHostFixture::toBytes(STNumber const& number)
|
||||
{
|
||||
auto msg = Serializer{};
|
||||
number.add(msg);
|
||||
return msg.getData();
|
||||
}
|
||||
|
||||
void
|
||||
expectKeyletMatches(std::expected<Bytes, HostFunctionError> const& result, Keylet const& expected)
|
||||
{
|
||||
expectValue(result, RealHostFixture::toBytes(expected.key));
|
||||
}
|
||||
|
||||
SignedMessage
|
||||
signMessage(std::string_view message, KeyType keyType)
|
||||
{
|
||||
auto const [pk, sk] = randomKeyPair(keyType);
|
||||
auto const msg = Bytes{std::begin(message), std::end(message)};
|
||||
auto const sig = sign(pk, sk, Slice{msg.data(), msg.size()});
|
||||
return {
|
||||
.message = msg,
|
||||
.signature = Bytes{sig.data(), sig.data() + sig.size()},
|
||||
.publicKey = Bytes{pk.data(), pk.data() + pk.size()}};
|
||||
}
|
||||
|
||||
uint256
|
||||
credentialId(std::string_view hex)
|
||||
{
|
||||
auto id = uint256{};
|
||||
EXPECT_TRUE(id.parseHex(std::string{hex}));
|
||||
return id;
|
||||
}
|
||||
|
||||
STObject
|
||||
makeMemo(Bytes const& data)
|
||||
{
|
||||
auto memo = STObject::makeInnerObject(sfMemo);
|
||||
memo.setFieldVL(sfMemoData, data);
|
||||
return memo;
|
||||
}
|
||||
|
||||
TxAssembler
|
||||
bareTx(TxType type)
|
||||
{
|
||||
return {.type = type, .build = [](STObject&) {}};
|
||||
}
|
||||
|
||||
TxAssembler
|
||||
escrowFinishTx(TxTest& ledger, Account const& acct)
|
||||
{
|
||||
return {.type = ttESCROW_FINISH, .build = [&ledger, acct](STObject& obj) {
|
||||
auto credId = uint256{};
|
||||
EXPECT_TRUE(credId.parseHex(
|
||||
"0011223344556677889900112233445566778899001122334455667788990011"));
|
||||
|
||||
obj.setAccountID(sfAccount, acct.id());
|
||||
obj.setAccountID(sfOwner, acct.id());
|
||||
obj.setFieldU32(sfOfferSequence, ledger.getAccountRoot(acct.id()).getSequence());
|
||||
obj.setFieldArray(sfMemos, STArray{});
|
||||
auto credIds = STVector256{};
|
||||
credIds.pushBack(credId);
|
||||
obj.setFieldV256(sfCredentialIDs, credIds);
|
||||
}};
|
||||
}
|
||||
|
||||
TxAssembler
|
||||
ammDepositTx(Account const& acct, Asset const& asset1, Asset const& asset2)
|
||||
{
|
||||
return {.type = ttAMM_DEPOSIT, .build = [acct, asset1, asset2](STObject& obj) {
|
||||
obj.setAccountID(sfAccount, acct.id());
|
||||
obj.setFieldIssue(sfAsset, STIssue{sfAsset, asset1});
|
||||
obj.setFieldIssue(sfAsset2, STIssue{sfAsset2, asset2});
|
||||
}};
|
||||
}
|
||||
|
||||
TxAssembler
|
||||
mptIssuanceCreateTx(Account const& acct, std::uint8_t scale)
|
||||
{
|
||||
return {.type = ttMPTOKEN_ISSUANCE_CREATE, .build = [acct, scale](STObject& obj) {
|
||||
obj.setAccountID(sfAccount, acct.id());
|
||||
obj.setFieldU8(sfAssetScale, scale);
|
||||
}};
|
||||
}
|
||||
|
||||
WasmHost::WasmHost(
|
||||
std::shared_ptr<STTx const> tx,
|
||||
std::unique_ptr<ApplyContext> context,
|
||||
std::unique_ptr<WasmHostFunctionsImpl> host)
|
||||
: tx_{std::move(tx)}, context_{std::move(context)}, host_{std::move(host)}
|
||||
{
|
||||
}
|
||||
|
||||
WasmHostFunctionsImpl*
|
||||
WasmHost::operator->() const
|
||||
{
|
||||
return host_.get();
|
||||
}
|
||||
|
||||
WasmHostFunctionsImpl&
|
||||
WasmHost::operator*() const
|
||||
{
|
||||
return *host_;
|
||||
}
|
||||
|
||||
Account
|
||||
RealHostFixture::fund(char const* name, XRPAmount amount)
|
||||
{
|
||||
auto const account = Account{name};
|
||||
ledger.createAccount(account, amount);
|
||||
return account;
|
||||
}
|
||||
|
||||
WasmHost
|
||||
RealHostFixture::makeHost(
|
||||
beast::Journal journal,
|
||||
Keylet const& leKey,
|
||||
TxType txType,
|
||||
std::function<void(STObject&)> assembler)
|
||||
{
|
||||
auto tx = std::make_shared<STTx>(
|
||||
txType, [assembler = std::move(assembler)](STObject& obj) { assembler(obj); });
|
||||
auto context = std::make_unique<ApplyContext>(
|
||||
ledger.getServiceRegistry(),
|
||||
ledger.getOpenLedger(),
|
||||
*tx,
|
||||
tesSUCCESS,
|
||||
ledger.getOpenLedger().fees().base,
|
||||
TapNone,
|
||||
journal);
|
||||
auto host = std::make_unique<WasmHostFunctionsImpl>(*context, leKey);
|
||||
return WasmHost{std::move(tx), std::move(context), std::move(host)};
|
||||
}
|
||||
|
||||
WasmHost
|
||||
RealHostFixture::makeHost(
|
||||
Keylet const& leKey,
|
||||
TxType txType,
|
||||
std::function<void(STObject&)> assembler)
|
||||
{
|
||||
return makeHost(
|
||||
beast::Journal{beast::Journal::getNullSink()}, leKey, txType, std::move(assembler));
|
||||
}
|
||||
|
||||
WasmHost
|
||||
RealHostFixture::makeTracingHost(
|
||||
Keylet const& leKey,
|
||||
TxType txType,
|
||||
std::function<void(STObject&)> assembler)
|
||||
{
|
||||
return makeHost(beast::Journal{traceSink_}, leKey, txType, std::move(assembler));
|
||||
}
|
||||
|
||||
std::string
|
||||
RealHostFixture::logged() const
|
||||
{
|
||||
return traceSink_.messages();
|
||||
}
|
||||
|
||||
void
|
||||
RealHostFixture::makeSignerList(
|
||||
Account const& owner,
|
||||
std::uint32_t quorum,
|
||||
std::vector<std::pair<Account, std::uint16_t>> const& signers)
|
||||
{
|
||||
auto entries = STArray{};
|
||||
for (auto const& [signer, weight] : signers)
|
||||
{
|
||||
auto entry = STObject::makeInnerObject(sfSignerEntry);
|
||||
entry.setAccountID(sfAccount, signer.id());
|
||||
entry.setFieldU16(sfSignerWeight, weight);
|
||||
entries.push_back(std::move(entry));
|
||||
}
|
||||
auto const r = ledger.submit(
|
||||
transactions::SignerListSetBuilder{owner.id(), quorum}.setSignerEntries(entries), owner);
|
||||
EXPECT_EQ(r.ter, tesSUCCESS) << transToken(r.ter);
|
||||
ledger.close();
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
189
src/tests/libxrpl/tx/wasm/RealHostFixture.h
Normal file
189
src/tests/libxrpl/tx/wasm/RealHostFixture.h
Normal file
@@ -0,0 +1,189 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/Indexes.h> // keylet::account
|
||||
#include <xrpl/protocol/Issue.h>
|
||||
#include <xrpl/protocol/KeyType.h>
|
||||
#include <xrpl/protocol/Keylet.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STAmount.h>
|
||||
#include <xrpl/protocol/STNumber.h>
|
||||
#include <xrpl/protocol/STObject.h>
|
||||
#include <xrpl/protocol/STTx.h>
|
||||
#include <xrpl/protocol/TxFormats.h>
|
||||
#include <xrpl/protocol/XRPAmount.h>
|
||||
#include <xrpl/tx/ApplyContext.h>
|
||||
#include <xrpl/tx/wasm/HostFuncImpl.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <helpers/Account.h>
|
||||
#include <helpers/CaptureSink.h>
|
||||
#include <helpers/TxTest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <source_location>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
template <typename T, typename U>
|
||||
void
|
||||
expectValue(
|
||||
std::expected<T, HostFunctionError> const& result,
|
||||
U const& expected,
|
||||
std::source_location loc = std::source_location::current())
|
||||
{
|
||||
auto trace = testing::ScopedTrace{loc.file_name(), static_cast<int>(loc.line()), ""};
|
||||
ASSERT_TRUE(result.has_value())
|
||||
<< "expected a value, got error " << static_cast<int>(result.error());
|
||||
EXPECT_EQ(*result, expected);
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
void
|
||||
expectError(
|
||||
std::expected<T, HostFunctionError> const& result,
|
||||
HostFunctionError expected,
|
||||
std::source_location loc = std::source_location::current())
|
||||
{
|
||||
auto trace = testing::ScopedTrace{loc.file_name(), static_cast<int>(loc.line()), ""};
|
||||
ASSERT_FALSE(result.has_value()) << "expected error, got a value";
|
||||
EXPECT_EQ(result.error(), expected);
|
||||
}
|
||||
|
||||
void
|
||||
expectKeyletMatches(std::expected<Bytes, HostFunctionError> const& result, Keylet const& expected);
|
||||
|
||||
struct SignedMessage
|
||||
{
|
||||
Bytes message;
|
||||
Bytes signature;
|
||||
Bytes publicKey;
|
||||
};
|
||||
|
||||
SignedMessage
|
||||
signMessage(std::string_view message, KeyType keyType = KeyType::Secp256k1);
|
||||
|
||||
uint256
|
||||
credentialId(
|
||||
std::string_view hex = "0011223344556677889900112233445566778899001122334455667788990011");
|
||||
|
||||
STObject
|
||||
makeMemo(Bytes const& data);
|
||||
|
||||
struct TxAssembler
|
||||
{
|
||||
TxType type;
|
||||
std::function<void(STObject&)> build;
|
||||
};
|
||||
|
||||
TxAssembler
|
||||
bareTx(TxType type = ttESCROW_FINISH);
|
||||
TxAssembler
|
||||
escrowFinishTx(TxTest& ledger, Account const& acct);
|
||||
TxAssembler
|
||||
ammDepositTx(Account const& acct, Asset const& asset1, Asset const& asset2);
|
||||
TxAssembler
|
||||
mptIssuanceCreateTx(Account const& acct, std::uint8_t scale);
|
||||
|
||||
class WasmHost
|
||||
{
|
||||
public:
|
||||
WasmHost(
|
||||
std::shared_ptr<STTx const> tx,
|
||||
std::unique_ptr<ApplyContext> context,
|
||||
std::unique_ptr<WasmHostFunctionsImpl> host);
|
||||
|
||||
WasmHostFunctionsImpl*
|
||||
operator->() const;
|
||||
WasmHostFunctionsImpl&
|
||||
operator*() const;
|
||||
|
||||
private:
|
||||
std::shared_ptr<STTx const> tx_;
|
||||
std::unique_ptr<ApplyContext> context_;
|
||||
std::unique_ptr<WasmHostFunctionsImpl> host_;
|
||||
};
|
||||
|
||||
class RealHostFixture : public testing::Test
|
||||
{
|
||||
public:
|
||||
TxTest ledger;
|
||||
|
||||
Account
|
||||
fund(char const* name, XRPAmount amount = XRP(1000));
|
||||
|
||||
WasmHost
|
||||
makeHost(
|
||||
beast::Journal journal,
|
||||
Keylet const& leKey = keylet::account(AccountID{}),
|
||||
TxType txType = ttESCROW_FINISH,
|
||||
std::function<void(STObject&)> assembler = [](STObject&) {});
|
||||
|
||||
// The common case: a host that discards its log output.
|
||||
WasmHost
|
||||
makeHost(
|
||||
Keylet const& leKey = keylet::account(AccountID{}),
|
||||
TxType txType = ttESCROW_FINISH,
|
||||
std::function<void(STObject&)> assembler = [](STObject&) {});
|
||||
|
||||
// A host whose `trace` output is captured, so a test can read it back with `logged()`.
|
||||
// The sink is a fixture member, so it outlives the host and accumulates across a test.
|
||||
WasmHost
|
||||
makeTracingHost(
|
||||
Keylet const& leKey = keylet::account(AccountID{}),
|
||||
TxType txType = ttESCROW_FINISH,
|
||||
std::function<void(STObject&)> assembler = [](STObject&) {});
|
||||
|
||||
// Everything `trace` has written to the tracing host so far.
|
||||
[[nodiscard]] std::string
|
||||
logged() const;
|
||||
|
||||
// Submit a real SignerListSet so `keylet::signerList(owner)` exists — the object the
|
||||
// signer-list nested-field / array-length getters read. `signers` pairs each signer
|
||||
// account with its weight.
|
||||
void
|
||||
makeSignerList(
|
||||
Account const& owner,
|
||||
std::uint32_t quorum,
|
||||
std::vector<std::pair<Account, std::uint16_t>> const& signers);
|
||||
|
||||
static Bytes
|
||||
toBytes(std::uint8_t value);
|
||||
static Bytes
|
||||
toBytes(std::uint16_t value);
|
||||
static Bytes
|
||||
toBytes(std::uint32_t value);
|
||||
static Bytes
|
||||
toBytes(uint256 const& value);
|
||||
static Bytes
|
||||
toBytes(std::string_view value);
|
||||
static Bytes
|
||||
toBytes(std::span<std::uint8_t const> value);
|
||||
static Bytes
|
||||
toBytes(AccountID const& account);
|
||||
static Bytes
|
||||
toBytes(Issue const& issue);
|
||||
static Bytes
|
||||
toBytes(Asset const& asset);
|
||||
static Bytes
|
||||
toBytes(STAmount const& amount);
|
||||
static Bytes
|
||||
toBytes(STNumber const& number);
|
||||
|
||||
private:
|
||||
CaptureSink traceSink_{beast::Severity::Trace};
|
||||
};
|
||||
|
||||
} // namespace xrpl::test
|
||||
42
src/tests/libxrpl/tx/wasm/RealVmTest.h
Normal file
42
src/tests/libxrpl/tx/wasm/RealVmTest.h
Normal file
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/Keylet.h>
|
||||
#include <xrpl/protocol/STObject.h>
|
||||
#include <xrpl/protocol/TxFormats.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
#include <xrpl/tx/wasm/WasmVM.h>
|
||||
|
||||
#include <tx/wasm/RealHostFixture.h>
|
||||
#include <tx/wasm/WasmRun.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <functional>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
// End-to-end: a WAT contract run through the REAL VM against the REAL host
|
||||
// over a REAL `TxTest` ledger.
|
||||
struct RealVmTest : RealHostFixture
|
||||
{
|
||||
// Assemble `wat` and run its `entryPoint` through the real VM against a real host built
|
||||
// over the current open ledger. `leKey`/`txType`/`assembler` configure the ledger object
|
||||
// the contract runs against and the transaction it reads.
|
||||
std::expected<EscrowResult, WasmTER>
|
||||
run(
|
||||
std::string_view wat,
|
||||
Keylet const& leKey = keylet::account(AccountID{}),
|
||||
TxType txType = ttESCROW_FINISH,
|
||||
std::function<void(STObject&)> assembler = [](STObject&) {},
|
||||
std::int64_t gas = kAmpleGas,
|
||||
std::string_view entryPoint = escrowFunctionName)
|
||||
{
|
||||
auto host = makeHost(leKey, txType, std::move(assembler));
|
||||
return runWat(*host, wat, gas, entryPoint);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace xrpl::test
|
||||
233
src/tests/libxrpl/tx/wasm/WasmBench.cpp
Normal file
233
src/tests/libxrpl/tx/wasm/WasmBench.cpp
Normal file
@@ -0,0 +1,233 @@
|
||||
#include <tx/wasm/WasmBench.h>
|
||||
|
||||
#include <xrpl/tx/wasm/HostFunc.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
#include <xrpl/tx/wasm/WasmVM.h>
|
||||
|
||||
#include <benchmark/benchmark.h>
|
||||
#include <rust/cxx.h>
|
||||
#include <tx/wasm/WasmRun.h>
|
||||
#include <xrpl_wasm_testkit_cxxbridge/lib.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <format>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test::bench {
|
||||
|
||||
int
|
||||
callsWithinTransferBudget(std::int64_t bytesPerCall)
|
||||
{
|
||||
if (bytesPerCall <= 0)
|
||||
{
|
||||
return kCallsPerRun;
|
||||
}
|
||||
auto const affordable = (kTransferLimitBytes / 2) / bytesPerCall;
|
||||
return static_cast<int>(std::clamp<std::int64_t>(affordable, 16, kCallsPerRun));
|
||||
}
|
||||
|
||||
std::string
|
||||
dataSegment(int offset, std::span<std::uint8_t const> bytes)
|
||||
{
|
||||
return std::format(" (data (i32.const {}) \"{}\")\n", offset, watEscaped(bytes));
|
||||
}
|
||||
|
||||
std::string
|
||||
dataSegment(int offset, Bytes const& bytes)
|
||||
{
|
||||
return dataSegment(offset, std::span<std::uint8_t const>{bytes.data(), bytes.size()});
|
||||
}
|
||||
|
||||
std::string
|
||||
makeLoopWat(std::string_view imports, std::string_view data, std::string_view body, int count)
|
||||
{
|
||||
static constexpr auto kTemplate = R"wat((module
|
||||
{}
|
||||
(memory (export "memory") 1)
|
||||
{}
|
||||
(func (export "escrow_finish") (result i32)
|
||||
(local $i i32)
|
||||
(local $r i32)
|
||||
(local.set $i (i32.const {}))
|
||||
(block $done
|
||||
(loop $again
|
||||
(br_if $done (i32.eqz (local.get $i)))
|
||||
(local.set $r {})
|
||||
(local.set $i (i32.sub (local.get $i) (i32.const 1)))
|
||||
(br $again)))
|
||||
(local.get $r)))
|
||||
)wat";
|
||||
|
||||
return std::format(kTemplate, imports, data, count, body);
|
||||
}
|
||||
|
||||
Timing
|
||||
timeRun(HostFunctions& host, Bytes const& wasm)
|
||||
{
|
||||
auto const start = std::chrono::steady_clock::now();
|
||||
auto outcome = runEscrowWasm(wasm, host, kBenchGas);
|
||||
auto const elapsed = std::chrono::steady_clock::now() - start;
|
||||
|
||||
benchmark::DoNotOptimize(outcome);
|
||||
return {
|
||||
.seconds = std::chrono::duration<double>(elapsed).count(),
|
||||
.gas = outcome.has_value() ? outcome->cost : std::int64_t{0}};
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Seconds of wall time one unit of gas buys on this machine. See `Calibration` for why this is
|
||||
// a difference rather than a single measurement.
|
||||
//
|
||||
// The estimator has to be the *same* one the cases use — a mean over `kBenchIterations` pairs,
|
||||
// with the same clamp at zero as `benchmarkThroughVm`. This is not a stylistic point. Since
|
||||
// `implied_gas = secondsPerCall / secondsPerGas`, any systematic difference between how the
|
||||
// divisor and the dividend are estimated lands directly in every reported number. A minimum
|
||||
// sits below a mean, so calibrating with a best-of while measuring cases with a mean biases
|
||||
// `secondsPerGas` low and every `implied_gas` and `suggested_gas` correspondingly high.
|
||||
//
|
||||
// `guestInstruction` in Crossing.bench.cpp is the check that this holds: it runs this exact loop
|
||||
// body, so its `implied_gas` and `charged_gas` are two measurements of one quantity and must
|
||||
// agree within a few percent.
|
||||
double
|
||||
measureSecondsPerGas()
|
||||
{
|
||||
// A couple of guest instructions per iteration, no memory traffic, nothing the engine can
|
||||
// fold away.
|
||||
static constexpr auto kBody = std::string_view{"(i32.add (local.get $r) (i32.const 1))"};
|
||||
auto const busy = assembleWat(makeLoopWat("", "", kBody, kCallsPerRun));
|
||||
auto const idle = assembleWat(makeLoopWat("", "", kBody, 0));
|
||||
|
||||
auto fixture = BenchFixture{};
|
||||
|
||||
// Warm the instruction cache and the allocator before the pairs that count, so the first-run
|
||||
// penalty does not land on one side of the subtraction.
|
||||
for (auto i = 0U; i < 8; ++i)
|
||||
{
|
||||
timeRun(*fixture.makeHost(), busy);
|
||||
timeRun(*fixture.makeHost(), idle);
|
||||
}
|
||||
|
||||
auto total = 0.0;
|
||||
// Fuel is exact and deterministic, so any pair gives the same delta.
|
||||
auto gasDelta = std::int64_t{1};
|
||||
for (auto i = 0; i < kCalibrationPairs; ++i)
|
||||
{
|
||||
auto hotHost = fixture.makeHost();
|
||||
auto const hot = timeRun(*hotHost, busy);
|
||||
auto coldHost = fixture.makeHost();
|
||||
auto const cold = timeRun(*coldHost, idle);
|
||||
|
||||
total += std::max(0.0, hot.seconds - cold.seconds);
|
||||
gasDelta = std::max(std::int64_t{1}, hot.gas - cold.gas);
|
||||
}
|
||||
|
||||
return (total / kCalibrationPairs) / static_cast<double>(gasDelta);
|
||||
}
|
||||
|
||||
// The crossing, in gas: `ldgr_index` through the VM minus `ldgr_index` called directly.
|
||||
// `secondsPerGas` has to be the value from the same snapshot, so it is passed in rather than
|
||||
// re-measured.
|
||||
//
|
||||
// Both halves are means for the same reason `measureSecondsPerGas` is: the VM half has to match
|
||||
// `benchmarkThroughVm`'s estimator and the impl half `benchmarkImpl`'s, or the crossing is the
|
||||
// difference of two numbers computed differently.
|
||||
double
|
||||
measureCrossingFloorGas(double secondsPerGas)
|
||||
{
|
||||
static constexpr std::string_view kImport =
|
||||
R"( (import "host_lib" "ldgr_index" (func $ldgr_index (param i32 i32) (result i32)))
|
||||
)";
|
||||
static constexpr std::string_view kBody = "(call $ldgr_index (i32.const 0) (i32.const 4))";
|
||||
|
||||
auto const loaded = assembleWat(makeLoopWat(kImport, "", kBody, kCallsPerRun));
|
||||
auto const baseline = assembleWat(makeLoopWat(kImport, "", kBody, 0));
|
||||
|
||||
auto fixture = BenchFixture{};
|
||||
|
||||
auto vmTotal = 0.0;
|
||||
for (auto i = 0; i < kBenchIterations; ++i)
|
||||
{
|
||||
auto hotHost = fixture.makeHost();
|
||||
auto const hot = timeRun(*hotHost, loaded);
|
||||
auto coldHost = fixture.makeHost();
|
||||
auto const cold = timeRun(*coldHost, baseline);
|
||||
|
||||
vmTotal += std::max(0.0, hot.seconds - cold.seconds) / kCallsPerRun;
|
||||
}
|
||||
auto const vmSeconds = vmTotal / kBenchIterations;
|
||||
|
||||
// The impl side is the same call without the VM. Subtracting it leaves the crossing.
|
||||
auto implTotal = 0.0;
|
||||
auto host = fixture.makeHost();
|
||||
for (auto i = 0; i < kBenchIterations; ++i)
|
||||
{
|
||||
auto const start = std::chrono::steady_clock::now();
|
||||
for (auto c = 0U; c < kCallsPerRun; ++c)
|
||||
{
|
||||
auto result = host->getLedgerSqn();
|
||||
benchmark::DoNotOptimize(result);
|
||||
}
|
||||
auto const elapsed = std::chrono::steady_clock::now() - start;
|
||||
implTotal += std::chrono::duration<double>(elapsed).count() / kCallsPerRun;
|
||||
}
|
||||
auto const implSeconds = implTotal / kBenchIterations;
|
||||
|
||||
return secondsPerGas > 0.0 ? std::max(0.0, vmSeconds - implSeconds) / secondsPerGas : 0.0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Calibration::Calibration()
|
||||
: secondsPerGas_{measureSecondsPerGas()}
|
||||
, crossingFloorGas_{measureCrossingFloorGas(secondsPerGas_)}
|
||||
{
|
||||
}
|
||||
|
||||
Calibration const&
|
||||
Calibration::instance()
|
||||
{
|
||||
static Calibration const kValue;
|
||||
return kValue;
|
||||
}
|
||||
|
||||
double
|
||||
declaredGas(std::string_view wasmName)
|
||||
{
|
||||
return static_cast<double>(
|
||||
rs::wasm_testkit::declared_gas(rust::Str{wasmName.data(), wasmName.size()}));
|
||||
}
|
||||
|
||||
void
|
||||
report(
|
||||
benchmark::State& state,
|
||||
double secondsPerCall,
|
||||
double chargedGas,
|
||||
std::string_view wasmName,
|
||||
bool throughVm)
|
||||
{
|
||||
auto const perGas = Calibration::instance().secondsPerGas();
|
||||
auto const implied = perGas > 0.0 ? secondsPerCall / perGas : 0.0;
|
||||
auto const suggested =
|
||||
throughVm ? implied : implied + Calibration::instance().crossingFloorGas();
|
||||
|
||||
state.counters["implied_gas"] = implied;
|
||||
state.counters["ns_per_call"] = secondsPerCall * 1e9;
|
||||
state.counters["charged_gas"] = chargedGas;
|
||||
|
||||
if (wasmName.empty())
|
||||
return;
|
||||
|
||||
auto const declared = declaredGas(wasmName);
|
||||
state.counters["declared_gas"] = declared;
|
||||
state.counters["suggested_gas"] = suggested;
|
||||
// Above 1: the table charges more than the work costs. Below 1: underpriced, which is the
|
||||
// direction that matters — an underpriced call is one a contract can buy too cheaply.
|
||||
state.counters["price_ratio"] = suggested > 0.0 ? declared / suggested : 0.0;
|
||||
}
|
||||
|
||||
} // namespace xrpl::test::bench
|
||||
262
src/tests/libxrpl/tx/wasm/WasmBench.h
Normal file
262
src/tests/libxrpl/tx/wasm/WasmBench.h
Normal file
@@ -0,0 +1,262 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/tx/wasm/HostFunc.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
#include <xrpl/tx/wasm/WasmVM.h>
|
||||
|
||||
#include <benchmark/benchmark.h>
|
||||
#include <tx/wasm/RealHostFixture.h>
|
||||
#include <tx/wasm/WasmRun.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <type_traits>
|
||||
|
||||
// The gas-calibration harness: how a `*.bench.cpp` measures a host call, and how that
|
||||
// measurement becomes a suggested price. What the numbers mean and how to read a report are in
|
||||
// ../README.md.
|
||||
|
||||
namespace xrpl::test::bench {
|
||||
|
||||
// Enough gas that a thousand-call benchmark loop never ends early; a benchmark measures
|
||||
// work, so running out of budget would silently measure something shorter instead.
|
||||
inline constexpr std::int64_t kBenchGas = 2'000'000'000;
|
||||
|
||||
// How many host calls a benchmarked contract makes per run. Large enough that the
|
||||
// per-call cost dominates the residue left by the baseline subtraction, small enough that
|
||||
// one run stays in the microsecond range.
|
||||
inline constexpr std::int32_t kCallsPerRun = 1000;
|
||||
|
||||
// How many timed iterations each case runs. Pinned rather than left to Google Benchmark's
|
||||
// automatic sizing, which cannot work here: a case reports the subtraction's residue — tens
|
||||
// of nanoseconds — while the iteration that produced it ran two whole contracts, module
|
||||
// compilation included, and cost milliseconds. Automatic sizing sees only the reported time,
|
||||
// so it would ask for millions of iterations to accumulate its default `min_time` and the
|
||||
// case would never finish. Every registration therefore ends
|
||||
// `->UseManualTime()->Iterations(kBenchIterations)`.
|
||||
inline constexpr std::int32_t kBenchIterations = 50;
|
||||
|
||||
// How many pairs the one-off calibration averages. Higher than `kBenchIterations` because
|
||||
// `secondsPerGas` is the divisor for *every* reported number, so its noise is common-mode across
|
||||
// the whole report, and because calibration runs a bare wasm loop with no ledger and no host
|
||||
// calls — a few hundred extra pairs cost milliseconds. More samples of a mean is only more
|
||||
// precision, not a different estimator, so this does not reintroduce the bias that mixing a
|
||||
// best-of with a mean did.
|
||||
inline constexpr std::int32_t kCalibrationPairs = 400;
|
||||
|
||||
// Every run gets this much guest<->host copying before `charge_transfer` starts refusing
|
||||
// calls. It is a per-run budget, so it resets between the runs a benchmark makes —
|
||||
// but a single run of `kCallsPerRun` calls moving a kilobyte each would exhaust it partway
|
||||
// through and spend the rest of the loop measuring the refusal path instead of the host function.
|
||||
inline constexpr std::int64_t kTransferLimitBytes = 1 << 20;
|
||||
|
||||
// How many calls a run can afford at `bytesPerCall`, staying clear of the transfer budget.
|
||||
// Halved because most functions move bytes in *both* directions.
|
||||
int
|
||||
callsWithinTransferBudget(std::int64_t bytesPerCall);
|
||||
|
||||
// A benchmark wants a fixture's ledger, host and setup helpers.
|
||||
// Templated so a case can reuse whichever fixture its test uses — `Bench<NFTTest>` for the
|
||||
// NFT benchmarks, `Bench<FloatTest>` for the float ones — instead of duplicating that setup.
|
||||
template <class Fixture>
|
||||
struct Bench : Fixture
|
||||
{
|
||||
void
|
||||
TestBody() override
|
||||
{
|
||||
}
|
||||
};
|
||||
|
||||
using BenchFixture = Bench<RealHostFixture>;
|
||||
|
||||
// One run of a contract: how long it took, and what the engine charged it.
|
||||
struct Timing
|
||||
{
|
||||
double seconds{};
|
||||
std::int64_t gas{};
|
||||
};
|
||||
|
||||
// A `(data ...)` segment placing `bytes` at `offset` in the guest's memory, so a case's
|
||||
// input is in place before the timed loop starts and the loop measures the host call rather
|
||||
// than the guest arranging its arguments. See `watEscaped` in WasmRun.h for why zeroed
|
||||
// memory will not do.
|
||||
std::string
|
||||
dataSegment(int offset, std::span<std::uint8_t const> bytes);
|
||||
|
||||
std::string
|
||||
dataSegment(int offset, Bytes const& bytes);
|
||||
|
||||
// A contract that runs `body` `count` times and returns the last result.
|
||||
std::string
|
||||
makeLoopWat(std::string_view imports, std::string_view data, std::string_view body, int count);
|
||||
|
||||
// Run pre-assembled `wasm` once through the real VM, reporting wall time and gas.
|
||||
Timing
|
||||
timeRun(HostFunctions& host, Bytes const& wasm);
|
||||
|
||||
// What this machine costs, measured once and shared by every case.
|
||||
class Calibration
|
||||
{
|
||||
public:
|
||||
// The machine's calibration, measured on first use. Measuring runs a few hundred short
|
||||
// contracts, so the first case to ask pays for it and every later case reads this answer.
|
||||
static Calibration const&
|
||||
instance();
|
||||
|
||||
Calibration();
|
||||
|
||||
// Seconds of wall time one unit of gas buys here.
|
||||
[[nodiscard]] double
|
||||
secondsPerGas() const
|
||||
{
|
||||
return secondsPerGas_;
|
||||
}
|
||||
|
||||
// The gas a host call costs before it does anything: region decode, bounds checks, the cxx
|
||||
// hop.
|
||||
[[nodiscard]] double
|
||||
crossingFloorGas() const
|
||||
{
|
||||
return crossingFloorGas_;
|
||||
}
|
||||
|
||||
private:
|
||||
double secondsPerGas_{};
|
||||
double crossingFloorGas_{};
|
||||
};
|
||||
|
||||
// What the gas table says a host function costs, by its guest import name.
|
||||
// Read from the declaration through the `wasm_testkit` bridge.
|
||||
double
|
||||
declaredGas(std::string_view wasmName);
|
||||
|
||||
// Attach the calibration counters to a finished case.
|
||||
void
|
||||
report(
|
||||
benchmark::State& state,
|
||||
double secondsPerCall,
|
||||
double chargedGas,
|
||||
std::string_view wasmName,
|
||||
bool throughVm);
|
||||
|
||||
// Measure a host function *through the whole stack* — guest, VM, marshalling, real impl,
|
||||
// real ledger — with everything but the host calls subtracted away.
|
||||
template <class SetUp>
|
||||
void
|
||||
benchmarkThroughVm(
|
||||
benchmark::State& state,
|
||||
std::string_view wasmName,
|
||||
std::string_view imports,
|
||||
std::string_view data,
|
||||
std::string_view body,
|
||||
SetUp&& setUp,
|
||||
int calls = kCallsPerRun)
|
||||
{
|
||||
auto const loaded = assembleWat(makeLoopWat(imports, data, body, calls));
|
||||
auto const baseline = assembleWat(makeLoopWat(imports, data, body, 0));
|
||||
|
||||
// A host serves exactly one run: it caches the current ledger object, the slot table and
|
||||
// the contract's data for that run's length, and `runEscrowWasm` asserts it was handed a
|
||||
// clean one (see `checkSelf` in WasmVM.cpp). So every run below builds its own. That
|
||||
// costs the measurement nothing — `timeRun` starts its clock after the host exists —
|
||||
// and it is why `setUp` is a factory rather than a host.
|
||||
auto probe = setUp();
|
||||
|
||||
// Confirm the contract actually succeeds before measuring it — and note that "the run
|
||||
// succeeded" is not enough to establish that.
|
||||
// So require both: the run completed, and the contract's last host call returned a
|
||||
// non-negative result. Every body here leaves that result in `$r`, which the module
|
||||
// returns.
|
||||
auto const check = runEscrowWasm(loaded, *probe, kBenchGas);
|
||||
if (!check.has_value())
|
||||
{
|
||||
state.SkipWithError("the benchmarked contract did not run to completion");
|
||||
return;
|
||||
}
|
||||
if (check->result < 0)
|
||||
{
|
||||
state.SkipWithError(
|
||||
"the benchmarked host call returned error code " + std::to_string(check->result) +
|
||||
"; the case would be measuring the rejection path, not the work");
|
||||
return;
|
||||
}
|
||||
|
||||
auto totalSeconds = 0.0;
|
||||
auto totalGas = 0.0;
|
||||
auto rounds = std::int64_t{0};
|
||||
for (auto _ : state)
|
||||
{
|
||||
auto hotHost = setUp();
|
||||
auto const hot = timeRun(*hotHost, loaded);
|
||||
auto coldHost = setUp();
|
||||
auto const cold = timeRun(*coldHost, baseline);
|
||||
|
||||
// Clamped at zero: on a noisy machine a single pair can invert, and a negative
|
||||
// iteration time would make Google Benchmark's statistics meaningless.
|
||||
auto const perCall = std::max(0.0, hot.seconds - cold.seconds) / calls;
|
||||
state.SetIterationTime(perCall);
|
||||
|
||||
totalSeconds += perCall;
|
||||
totalGas += static_cast<double>(hot.gas - cold.gas) / calls;
|
||||
++rounds;
|
||||
}
|
||||
|
||||
if (rounds > 0)
|
||||
{
|
||||
report(state, totalSeconds / rounds, totalGas / rounds, wasmName, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Measure a host function's *impl alone* — the computation, with no guest, no VM and no
|
||||
// marshalling. Paired with the `ThroughVm` case for the same function, the difference is
|
||||
// what crossing the guest/host boundary costs.
|
||||
// Register with `->UseManualTime()`.
|
||||
template <class SetUp, class Call>
|
||||
void
|
||||
benchmarkImpl(benchmark::State& state, std::string_view wasmName, SetUp&& setUp, Call&& call)
|
||||
{
|
||||
auto host = setUp();
|
||||
|
||||
auto totalSeconds = 0.0;
|
||||
auto rounds = std::int64_t{0};
|
||||
for (auto _ : state)
|
||||
{
|
||||
auto const start = std::chrono::steady_clock::now();
|
||||
for (int i = 0; i < kCallsPerRun; ++i)
|
||||
{
|
||||
// `trace` is the one host function that answers nothing, so there is no result
|
||||
// to hold onto; `ClobberMemory` stands in for `DoNotOptimize` to keep the call
|
||||
// from being elided.
|
||||
if constexpr (std::is_void_v<decltype(call(*host))>)
|
||||
{
|
||||
call(*host);
|
||||
benchmark::ClobberMemory();
|
||||
}
|
||||
else
|
||||
{
|
||||
auto result = call(*host);
|
||||
benchmark::DoNotOptimize(result);
|
||||
}
|
||||
}
|
||||
auto const elapsed = std::chrono::steady_clock::now() - start;
|
||||
|
||||
auto const perCall = std::chrono::duration<double>(elapsed).count() / kCallsPerRun;
|
||||
state.SetIterationTime(perCall);
|
||||
|
||||
totalSeconds += perCall;
|
||||
++rounds;
|
||||
}
|
||||
|
||||
// No VM ran, so nothing was charged — and the crossing this case leaves out is added back
|
||||
// into `suggested_gas`, because a guest cannot make the call without paying it.
|
||||
if (rounds > 0)
|
||||
{
|
||||
report(state, totalSeconds / rounds, 0.0, wasmName, false);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace xrpl::test::bench
|
||||
110
src/tests/libxrpl/tx/wasm/WasmFixture.h
Normal file
110
src/tests/libxrpl/tx/wasm/WasmFixture.h
Normal file
@@ -0,0 +1,110 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
#include <xrpl/tx/wasm/WasmVM.h>
|
||||
|
||||
#include <gmock/gmock.h>
|
||||
#include <gtest/gtest.h>
|
||||
#include <helpers/CaptureSink.h>
|
||||
#include <tx/wasm/MockHostFunctions.h>
|
||||
#include <tx/wasm/WasmRun.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
// Base for every wasm test that runs a contract against a MOCKED host whose log is captured.
|
||||
// Its real-host counterpart is `RealVmTest`; both run a WAT guest through the real VM and
|
||||
// forward to the shared `runWat` harness (`WasmRun.h`), differing only in the host.
|
||||
//
|
||||
// Modules are written as WebAssembly text and assembled by `assembleWat`. The assembler is in
|
||||
// a test-only crate: the engine itself refuses text
|
||||
// (`the_vm_refuses_a_text_format_module`), because a text assembler on the consensus path
|
||||
// would make a transaction's validity a build flag.
|
||||
struct MockVmTest : testing::Test
|
||||
{
|
||||
// Keeps what a run logged. The host's default journal is a null sink, which would let a
|
||||
// swallowed condition pass a test that only checks the TER.
|
||||
CaptureSink sink{beast::Severity::Warning};
|
||||
|
||||
// Strict: a host call no test asked for is a failure, not a warning. These modules import
|
||||
// exactly what they mean to exercise, so an unplanned call means the engine reached for
|
||||
// something on its own — which is the kind of surprise a test suite exists to catch.
|
||||
testing::StrictMock<MockHostFunctions> host{beast::Journal{sink}};
|
||||
|
||||
MockVmTest()
|
||||
{
|
||||
// `runEscrowWasm` asks every run whether the host is clean, so under a strict mock
|
||||
// every test would have to say so. Declared once here, and any number of times
|
||||
// (including none, for the runs refused before the engine is reached). A test that
|
||||
// cares says otherwise and its own expectation wins.
|
||||
EXPECT_CALL(host, checkSelf()).WillRepeatedly(testing::Return(true));
|
||||
}
|
||||
|
||||
static Bytes
|
||||
assemble(std::string_view wat)
|
||||
{
|
||||
return assembleWat(wat);
|
||||
}
|
||||
|
||||
std::expected<EscrowResult, WasmTER>
|
||||
run(std::string_view wat,
|
||||
std::int64_t gas = kAmpleGas,
|
||||
std::string_view entryPoint = escrowFunctionName)
|
||||
{
|
||||
return runWat(host, wat, gas, entryPoint);
|
||||
}
|
||||
|
||||
std::expected<EscrowResult, WasmTER>
|
||||
runBytes(
|
||||
Bytes const& wasm,
|
||||
std::int64_t gas = kAmpleGas,
|
||||
std::string_view entryPoint = escrowFunctionName)
|
||||
{
|
||||
return runEscrowWasm(wasm, host, gas, entryPoint);
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string
|
||||
logged() const
|
||||
{
|
||||
return sink.messages();
|
||||
}
|
||||
};
|
||||
|
||||
// Base for the per-host-function fixtures. Each derives, supplies the module that exercises
|
||||
// its own import, and runs it through `callHost()` — so a test says only what the host was
|
||||
// asked and what came back.
|
||||
struct HostCallTest : MockVmTest
|
||||
{
|
||||
// The module under test. One import, one `escrow_finish` that calls it.
|
||||
[[nodiscard]] virtual std::string
|
||||
wat() const = 0;
|
||||
|
||||
std::expected<EscrowResult, WasmTER>
|
||||
callHost(std::string_view entryPoint = escrowFunctionName)
|
||||
{
|
||||
return run(wat(), kAmpleGas, entryPoint);
|
||||
}
|
||||
|
||||
// The contract's return value, which for these modules is what the host answered — or
|
||||
// its negative error code. Fails the test if the run did not complete.
|
||||
std::int32_t
|
||||
hostAnswer(std::string_view entryPoint = escrowFunctionName)
|
||||
{
|
||||
auto const outcome = callHost(entryPoint);
|
||||
if (!outcome)
|
||||
{
|
||||
ADD_FAILURE() << "the run did not complete: " << transToken(outcome.error().ter)
|
||||
<< "; logged: " << logged();
|
||||
return 0;
|
||||
}
|
||||
return outcome->result;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace xrpl::test
|
||||
52
src/tests/libxrpl/tx/wasm/WasmRun.cpp
Normal file
52
src/tests/libxrpl/tx/wasm/WasmRun.cpp
Normal file
@@ -0,0 +1,52 @@
|
||||
#include <tx/wasm/WasmRun.h>
|
||||
|
||||
#include <xrpl/tx/wasm/HostFunc.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
#include <xrpl/tx/wasm/WasmVM.h>
|
||||
|
||||
#include <rust/cxx.h>
|
||||
#include <xrpl_wasm_testkit_cxxbridge/lib.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
Bytes
|
||||
assembleWat(std::string_view wat)
|
||||
{
|
||||
auto const wasm = rs::wasm_testkit::compile_wat(rust::Str{wat.data(), wat.size()});
|
||||
return Bytes{wasm.begin(), wasm.end()};
|
||||
}
|
||||
|
||||
std::string
|
||||
watEscaped(std::span<std::uint8_t const> bytes)
|
||||
{
|
||||
static constexpr char kHex[] = "0123456789abcdef";
|
||||
auto out = std::string{};
|
||||
out.reserve(bytes.size() * 3);
|
||||
for (auto const byte : bytes)
|
||||
{
|
||||
out += '\\';
|
||||
out += kHex[byte >> 4];
|
||||
out += kHex[byte & 0x0F];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string
|
||||
watEscaped(Bytes const& bytes)
|
||||
{
|
||||
return watEscaped(std::span<std::uint8_t const>{bytes.data(), bytes.size()});
|
||||
}
|
||||
|
||||
std::expected<EscrowResult, WasmTER>
|
||||
runWat(HostFunctions& host, std::string_view wat, std::int64_t gas, std::string_view entryPoint)
|
||||
{
|
||||
return runEscrowWasm(assembleWat(wat), host, gas, entryPoint);
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
48
src/tests/libxrpl/tx/wasm/WasmRun.h
Normal file
48
src/tests/libxrpl/tx/wasm/WasmRun.h
Normal file
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/tx/wasm/HostFunc.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
#include <xrpl/tx/wasm/WasmVM.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
// Enough gas for a small module to run to completion; a test about budgets passes its own.
|
||||
inline constexpr std::int64_t kAmpleGas = 100'000;
|
||||
|
||||
// Assemble WebAssembly text to bytes via the test-only `wasm_testkit` crate. The engine
|
||||
// itself refuses text (a text assembler on the consensus path would make a transaction's
|
||||
// validity a build flag), so this is where a WAT string becomes something runnable. Throws
|
||||
// `rust::Error` on a typo, which gtest reports against the test that holds it.
|
||||
Bytes
|
||||
assembleWat(std::string_view wat);
|
||||
|
||||
// `bytes` as the escape sequence a WAT string literal wants (`\aa\bb...`), for seeding a
|
||||
// contract's memory through a `(data ...)` segment.
|
||||
//
|
||||
// Guest memory starts zeroed, and zeros are not a usable input to most host functions: an
|
||||
// all-zero account id is `InvalidAccount`, an all-zero float is non-canonical. A contract
|
||||
// that needs real bytes to work on gets them here, once at instantiation, rather than
|
||||
// building them out of `i32.store` instructions.
|
||||
std::string
|
||||
watEscaped(std::span<std::uint8_t const> bytes);
|
||||
|
||||
std::string
|
||||
watEscaped(Bytes const& bytes);
|
||||
|
||||
// Assemble and run `wat`'s `entryPoint` through the real VM, servicing host calls through
|
||||
// `host` — a mock (`MockVmTest`) or the real impl over a ledger (`RealVmTest`). The one
|
||||
// host-agnostic harness both fixtures inject their host into.
|
||||
std::expected<EscrowResult, WasmTER>
|
||||
runWat(
|
||||
HostFunctions& host,
|
||||
std::string_view wat,
|
||||
std::int64_t gas = kAmpleGas,
|
||||
std::string_view entryPoint = escrowFunctionName);
|
||||
|
||||
} // namespace xrpl::test
|
||||
324
src/tests/libxrpl/tx/wasm/WasmVM.cpp
Normal file
324
src/tests/libxrpl/tx/wasm/WasmVM.cpp
Normal file
@@ -0,0 +1,324 @@
|
||||
#include <xrpl/tx/wasm/WasmVM.h>
|
||||
|
||||
#include <xrpl/basics/contract.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <gmock/gmock.h>
|
||||
#include <gtest/gtest.h>
|
||||
#include <tx/wasm/WasmFixture.h>
|
||||
#include <tx/wasm/WasmRun.h>
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
namespace {
|
||||
|
||||
// One module with an export per way a run can end. Kept together because these are properties
|
||||
// of the engine rather than of any host function: the only import is there so the
|
||||
// out-of-gas and no-memory cases have a host call to fail in.
|
||||
constexpr std::string_view kEngineWat = R"wat(
|
||||
(module
|
||||
(import "host_lib" "ldgr_index" (func $ldgr_index (param i32 i32) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
|
||||
(func (export "escrow_finish") (result i32) (i32.const 5))
|
||||
|
||||
(func (export "calls_the_host") (result i32)
|
||||
(call $ldgr_index (i32.const 0) (i32.const 4)))
|
||||
|
||||
(func (export "traps") (result i32) unreachable)
|
||||
|
||||
(func (export "never_returns") (result i32) (loop (br 0)) (i32.const 0))
|
||||
|
||||
(func (export "wrong_signature") (param i32) (result i32) (local.get 0))
|
||||
|
||||
(global (export "not_a_function") i32 (i32.const 0)))
|
||||
)wat";
|
||||
|
||||
// The same host call with no memory exported, so the engine has nothing to resolve a byte
|
||||
// region against.
|
||||
constexpr std::string_view kNoMemoryWat = R"wat(
|
||||
(module
|
||||
(import "host_lib" "ldgr_index" (func $ldgr_index (param i32 i32) (result i32)))
|
||||
(func (export "escrow_finish") (result i32)
|
||||
(call $ldgr_index (i32.const 0) (i32.const 4))))
|
||||
)wat";
|
||||
|
||||
} // namespace
|
||||
|
||||
class WasmVMTest : public MockVmTest
|
||||
{
|
||||
};
|
||||
|
||||
TEST_F(WasmVMTest, ContractReturnValueReachesCaller)
|
||||
{
|
||||
auto const outcome = run(kEngineWat);
|
||||
|
||||
ASSERT_TRUE(outcome.has_value()) << transToken(outcome.error().ter);
|
||||
EXPECT_EQ(outcome->result, 5);
|
||||
EXPECT_GT(outcome->cost, 0) << "running any instruction costs gas";
|
||||
EXPECT_LT(outcome->cost, kAmpleGas);
|
||||
}
|
||||
|
||||
TEST_F(WasmVMTest, GuestTrapIsChargedAsContractFault)
|
||||
{
|
||||
auto const outcome = run(kEngineWat, kAmpleGas, "traps");
|
||||
|
||||
ASSERT_FALSE(outcome.has_value());
|
||||
EXPECT_EQ(outcome.error().ter, tecFAILED_PROCESSING);
|
||||
ASSERT_TRUE(outcome.error().cost.has_value());
|
||||
EXPECT_GT(*outcome.error().cost, 0); // NOLINT(bugprone-unchecked-optional-access)
|
||||
}
|
||||
|
||||
TEST_F(WasmVMTest, NonTerminatingContractSpendsWholeBudget)
|
||||
{
|
||||
auto const outcome = run(kEngineWat, kAmpleGas, "never_returns");
|
||||
|
||||
ASSERT_FALSE(outcome.has_value());
|
||||
EXPECT_EQ(outcome.error().ter, tecOUT_OF_GAS);
|
||||
ASSERT_TRUE(outcome.error().cost.has_value());
|
||||
|
||||
// The cost break down is as follows:
|
||||
// 1. There is a function entry charge (finish function) which seems to be 63 units of fuel.
|
||||
// 2. Each iteration costs 2 units of fuel.
|
||||
// For a GAS amount of 100,000, we will be limited to burning an odd number of fuel.
|
||||
// So the way the test is written, the most fuel that will be used is 99,999 units.
|
||||
EXPECT_EQ(*outcome.error().cost, kAmpleGas - 1); // NOLINT(bugprone-unchecked-optional-access)
|
||||
}
|
||||
|
||||
// A budget too small to reach the first host charge is still out of gas, whatever the engine
|
||||
// can account for by then.
|
||||
TEST_F(WasmVMTest, BudgetTooSmallToRunIsOutOfGas)
|
||||
{
|
||||
auto const outcome = run(kEngineWat, 1, "calls_the_host");
|
||||
|
||||
ASSERT_FALSE(outcome.has_value());
|
||||
EXPECT_EQ(outcome.error().ter, tecOUT_OF_GAS);
|
||||
EXPECT_TRUE(outcome.error().cost.has_value());
|
||||
}
|
||||
|
||||
// A host call needs a memory to resolve its byte regions against, and the export is not
|
||||
// optional for a contract that makes one.
|
||||
TEST_F(WasmVMTest, HostCallWithNoExportedMemoryFails)
|
||||
{
|
||||
auto const outcome = run(kNoMemoryWat);
|
||||
|
||||
ASSERT_FALSE(outcome.has_value());
|
||||
EXPECT_EQ(outcome.error().ter, tecFAILED_PROCESSING);
|
||||
EXPECT_TRUE(outcome.error().cost.has_value());
|
||||
}
|
||||
|
||||
// A module that will not instantiate is the contract's fault and is charged, not the node's.
|
||||
// Screening does not see every way this happens - a linear memory the module keeps to itself
|
||||
// is absent from its exports - so such a module can pass preflight and still be refused here.
|
||||
TEST_F(WasmVMTest, ModuleThatWillNotInstantiateIsChargedToTheContract)
|
||||
{
|
||||
// 129 pages, not exported, so nothing outside the module declares it.
|
||||
static constexpr std::string_view wat = R"wat(
|
||||
(module
|
||||
(memory 129)
|
||||
(func (export "escrow_finish") (result i32) (i32.const 0)))
|
||||
)wat";
|
||||
|
||||
EXPECT_EQ(preflightEscrowWasm(assembleWat(wat), beast::Journal{sink}), tesSUCCESS)
|
||||
<< "screening cannot see an unexported memory";
|
||||
|
||||
auto const outcome = run(wat);
|
||||
|
||||
ASSERT_FALSE(outcome.has_value());
|
||||
EXPECT_EQ(outcome.error().ter, tecFAILED_PROCESSING);
|
||||
EXPECT_TRUE(outcome.error().cost.has_value());
|
||||
}
|
||||
|
||||
// Preflight is meant to refuse these with `temBAD_WASM`; reaching apply means the screening
|
||||
// did not happen, which is the node's fault and not the transaction's.
|
||||
TEST_F(WasmVMTest, UnrunnableModuleIsNodeSideFault)
|
||||
{
|
||||
struct Case
|
||||
{
|
||||
char const* what;
|
||||
Bytes code;
|
||||
std::string_view entryPoint;
|
||||
};
|
||||
std::array const cases = {
|
||||
Case{
|
||||
.what = "not wasm at all", .code = Bytes{0, 1, 2, 3}, .entryPoint = escrowFunctionName},
|
||||
Case{.what = "empty", .code = Bytes{}, .entryPoint = escrowFunctionName},
|
||||
Case{
|
||||
.what = "no such export", .code = assemble(kEngineWat), .entryPoint = "no_such_export"},
|
||||
Case{
|
||||
.what = "export is not a function",
|
||||
.code = assemble(kEngineWat),
|
||||
.entryPoint = "not_a_function"},
|
||||
Case{
|
||||
.what = "export takes a parameter",
|
||||
.code = assemble(kEngineWat),
|
||||
.entryPoint = "wrong_signature"},
|
||||
};
|
||||
|
||||
for (auto const& c : cases)
|
||||
{
|
||||
auto const outcome = runBytes(c.code, kAmpleGas, c.entryPoint);
|
||||
|
||||
ASSERT_FALSE(outcome.has_value()) << c.what;
|
||||
EXPECT_EQ(outcome.error().ter, tecINTERNAL) << c.what;
|
||||
EXPECT_FALSE(outcome.error().cost.has_value()) << c.what;
|
||||
}
|
||||
}
|
||||
|
||||
// wasmi's `wat` feature would make `Module::new` accept text as readily as binary, which would
|
||||
// put an assembler on the consensus path and make a module's validity a build flag. The
|
||||
// engine turns that feature off; this is the guest-side proof, using the very text the rest
|
||||
// of this file assembles.
|
||||
TEST_F(WasmVMTest, TextFormatModuleIsRejected)
|
||||
{
|
||||
Bytes const text{kEngineWat.begin(), kEngineWat.end()};
|
||||
|
||||
auto const outcome = runBytes(text);
|
||||
|
||||
ASSERT_FALSE(outcome.has_value());
|
||||
EXPECT_EQ(outcome.error().ter, tecINTERNAL);
|
||||
}
|
||||
|
||||
// A soft host error is the contract's to interpret, so its code has to cross the boundary
|
||||
// unchanged: the engine must not renumber it, clamp it, or turn it into a failure of its own.
|
||||
//
|
||||
// Over the whole of `HostFunctionError` rather than a sample, because `HostFunctionError` and
|
||||
// the Rust ABI's `HostError` are two hand-maintained lists of the same wire numbers: -1
|
||||
// through -20 have to mean the same thing on both sides, and this is the test that notices if
|
||||
// either side renumbers.
|
||||
//
|
||||
// The two exclusions are the codes the Rust engine converts into a fault, which stops the run
|
||||
// instead of reaching the guest: -1 `Unimplemented` and -14 `NoMemExported`. Both say the call
|
||||
// was not served at all.
|
||||
TEST_F(WasmVMTest, SoftHostErrorCodesCrossUnchanged)
|
||||
{
|
||||
static constexpr HostFunctionError kSoftErrors[] = {
|
||||
HostFunctionError::FieldNotFound,
|
||||
HostFunctionError::BufferTooSmall,
|
||||
HostFunctionError::NoArray,
|
||||
HostFunctionError::NotLeafField,
|
||||
HostFunctionError::LocatorMalformed,
|
||||
HostFunctionError::SlotOutRange,
|
||||
HostFunctionError::SlotsFull,
|
||||
HostFunctionError::EmptySlot,
|
||||
HostFunctionError::LedgerObjNotFound,
|
||||
HostFunctionError::OutOfTransferLimit,
|
||||
HostFunctionError::DataFieldTooLarge,
|
||||
HostFunctionError::PointerOutOfBounds,
|
||||
HostFunctionError::InvalidParams,
|
||||
HostFunctionError::InvalidAccount,
|
||||
HostFunctionError::InvalidField,
|
||||
HostFunctionError::IndexOutOfBounds,
|
||||
HostFunctionError::FloatInputMalformed,
|
||||
HostFunctionError::FloatComputationError,
|
||||
};
|
||||
|
||||
auto refused = HostFunctionError::FieldNotFound;
|
||||
EXPECT_CALL(host, getLedgerSqn())
|
||||
.WillRepeatedly([&refused]() -> std::expected<std::uint32_t, HostFunctionError> {
|
||||
return std::unexpected(refused);
|
||||
});
|
||||
|
||||
for (auto const error : kSoftErrors)
|
||||
{
|
||||
refused = error;
|
||||
|
||||
auto const outcome = run(kEngineWat, kAmpleGas, "calls_the_host");
|
||||
|
||||
ASSERT_TRUE(outcome.has_value()) << hfErrorToInt(error) << " stopped the run";
|
||||
EXPECT_EQ(outcome->result, hfErrorToInt(error));
|
||||
}
|
||||
}
|
||||
|
||||
// The counterpart: a fatal code stops the run rather than reaching the contract, so a host
|
||||
// that cannot serve a call cannot be second-guessed by the contract.
|
||||
TEST_F(WasmVMTest, FatalHostErrorStopsRun)
|
||||
{
|
||||
auto refused = HostFunctionError::Unimplemented;
|
||||
EXPECT_CALL(host, getLedgerSqn())
|
||||
.WillRepeatedly([&refused]() -> std::expected<std::uint32_t, HostFunctionError> {
|
||||
return std::unexpected(refused);
|
||||
});
|
||||
|
||||
for (auto const error :
|
||||
{HostFunctionError::InternalFatal,
|
||||
HostFunctionError::Unimplemented,
|
||||
HostFunctionError::NoMemExported})
|
||||
{
|
||||
refused = error;
|
||||
|
||||
auto const outcome = run(kEngineWat, kAmpleGas, "calls_the_host");
|
||||
|
||||
ASSERT_FALSE(outcome.has_value()) << hfErrorToInt(error) << " reached the contract";
|
||||
}
|
||||
}
|
||||
|
||||
// The point of the bridge's C++ half: an exception must not reach the Rust frames that called
|
||||
// the host, and must not take the node with it.
|
||||
TEST_F(WasmVMTest, ThrowingHostFunctionBecomesInternal)
|
||||
{
|
||||
EXPECT_CALL(host, getLedgerSqn())
|
||||
.WillOnce([]() -> std::expected<std::uint32_t, HostFunctionError> {
|
||||
Throw<std::runtime_error>("the ledger came apart");
|
||||
});
|
||||
|
||||
auto const outcome = run(kEngineWat, kAmpleGas, "calls_the_host");
|
||||
|
||||
ASSERT_FALSE(outcome.has_value());
|
||||
EXPECT_EQ(outcome.error().ter, tecINTERNAL);
|
||||
EXPECT_FALSE(outcome.error().cost.has_value()) << "a node-side fault charges nothing";
|
||||
// Caught is not swallowed: the condition has to be recorded, and the line has to name the
|
||||
// call it came out of.
|
||||
EXPECT_THAT(logged(), testing::HasSubstr("the ledger came apart"));
|
||||
EXPECT_THAT(logged(), testing::HasSubstr("getLedgerSqn"));
|
||||
}
|
||||
|
||||
struct WasmVMDeathTest : WasmVMTest
|
||||
{
|
||||
};
|
||||
|
||||
// No gas is not a small budget, it is a malformed transaction — refused before the engine is
|
||||
// asked to run anything.
|
||||
TEST_F(WasmVMDeathTest, NoGasIsRefusedAsMalformedRatherThanRun)
|
||||
{
|
||||
for (auto const gas : {std::int64_t{0}, std::int64_t{-1}})
|
||||
{
|
||||
EXPECT_DEBUG_DEATH(
|
||||
{
|
||||
auto const outcome = run(kEngineWat, gas);
|
||||
|
||||
ASSERT_FALSE(outcome.has_value()) << "gas: " << gas;
|
||||
EXPECT_EQ(outcome.error().ter, temBAD_AMOUNT) << "gas: " << gas;
|
||||
EXPECT_FALSE(outcome.error().cost.has_value()) << "gas: " << gas;
|
||||
},
|
||||
"gas limit is positive");
|
||||
}
|
||||
}
|
||||
|
||||
// The host caches the current ledger object, the slot table and the contract's data for the
|
||||
// length of one run, so a reused one would answer a later contract out of an earlier
|
||||
// contract's state.
|
||||
TEST_F(WasmVMDeathTest, DirtyHostIsRefusedBeforeContractRuns)
|
||||
{
|
||||
EXPECT_DEBUG_DEATH(
|
||||
{
|
||||
EXPECT_CALL(host, checkSelf()).WillOnce(testing::Return(false));
|
||||
auto const outcome = run(kEngineWat);
|
||||
|
||||
ASSERT_FALSE(outcome.has_value());
|
||||
EXPECT_EQ(outcome.error().ter, tecINTERNAL);
|
||||
EXPECT_FALSE(outcome.error().cost.has_value());
|
||||
EXPECT_THAT(logged(), testing::HasSubstr("not clean"));
|
||||
},
|
||||
"host functions not clean before the run");
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
63
src/tests/libxrpl/tx/wasm/e2e/CacheLedgerObj.cpp
Normal file
63
src/tests/libxrpl/tx/wasm/e2e/CacheLedgerObj.cpp
Normal file
@@ -0,0 +1,63 @@
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <helpers/Account.h>
|
||||
#include <tx/wasm/RealHostFixture.h>
|
||||
#include <tx/wasm/RealVmTest.h>
|
||||
#include <tx/wasm/WasmRun.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <format>
|
||||
#include <string>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
// The keylet -> cache -> read round trip: the only place a contract's host calls depend on
|
||||
// each other. Every other e2e case here is one call in isolation. This one is three, and each
|
||||
// consumes what the last produced: `accountroot_id` computes a key into guest memory, `cache_le`
|
||||
// hands those same bytes back to the host and answers with a slot number, and `le_field`
|
||||
// uses that slot to read the object. The slot table is the one piece of host state that
|
||||
// outlives a single call, so this is the only test at any layer that can catch the two ends
|
||||
// of that state disagreeing — `host_calls` mocks the host, so its slot numbers are whatever
|
||||
// the mock was told to return, and `host_functions` calls the impl directly, so its slots
|
||||
// never cross the guest boundary at all.
|
||||
struct CacheLedgerObjE2e : RealVmTest
|
||||
{
|
||||
};
|
||||
|
||||
TEST_F(CacheLedgerObjE2e, ContractComputesAKeyCachesTheObjectAndReadsItsField)
|
||||
{
|
||||
auto const owner = fund("owner");
|
||||
auto const wat = std::format(
|
||||
R"wat(
|
||||
(module
|
||||
(import "host_lib" "accountroot_id" (func $accountroot_id (param i32 i32 i32 i32) (result i32)))
|
||||
(import "host_lib" "cache_le" (func $cache_le (param i32 i32 i32) (result i32)))
|
||||
(import "host_lib" "le_field" (func $le_field (param i32 i32 i32 i32) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(data (i32.const 0) "{}")
|
||||
(func (export "escrow_finish") (result i32)
|
||||
(local $slot i32)
|
||||
(local $r i32)
|
||||
;; The account's AccountRoot keylet, computed by the host into offset 64.
|
||||
(local.set $r (call $accountroot_id (i32.const 0) (i32.const 20) (i32.const 64) (i32.const 32)))
|
||||
(if (i32.lt_s (local.get $r) (i32.const 0)) (then (return (local.get $r))))
|
||||
;; Those same 32 bytes handed straight back: cache the object they name.
|
||||
(local.set $slot (call $cache_le (i32.const 64) (i32.const 32) (i32.const 0)))
|
||||
(if (i32.lt_s (local.get $slot) (i32.const 0)) (then (return (local.get $slot))))
|
||||
;; And read a field of it through the slot the host just assigned.
|
||||
(call $le_field (local.get $slot) (i32.const {}) (i32.const 128) (i32.const 32))))
|
||||
)wat",
|
||||
watEscaped(RealHostFixture::toBytes(owner.id())),
|
||||
sfAccount.getCode());
|
||||
|
||||
auto const outcome = run(wat);
|
||||
ASSERT_TRUE(outcome.has_value()) << transToken(outcome.error().ter);
|
||||
// 20 bytes: the `sfAccount` the contract read back is the account it started from, so
|
||||
// the key it computed found the right object.
|
||||
EXPECT_EQ(
|
||||
outcome->result, static_cast<std::int32_t>(RealHostFixture::toBytes(owner.id()).size()));
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
66
src/tests/libxrpl/tx/wasm/e2e/CurrentLedgerObjField.cpp
Normal file
66
src/tests/libxrpl/tx/wasm/e2e/CurrentLedgerObjField.cpp
Normal file
@@ -0,0 +1,66 @@
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/Keylet.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/SeqProxy.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/protocol_autogen/transactions/EscrowCreate.h>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <helpers/Account.h>
|
||||
#include <helpers/TxTest.h>
|
||||
#include <tx/wasm/RealVmTest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <format>
|
||||
#include <string>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
// A contract reads a field of its current ledger object (a real escrow) end to end: the real
|
||||
// VM runs the guest, `HostContext` marshals the field code into an `SField`, the real impl
|
||||
// reads the real ledger, and the byte count comes back to the guest. `host_calls/` proves the
|
||||
// marshalling with a mock and `host_functions/` proves the impl's answer without a VM; this
|
||||
// proves the two agree over a real ledger.
|
||||
struct CurrentLedgerObjFieldE2e : RealVmTest
|
||||
{
|
||||
// Create a real escrow owned by `owner` and return its keylet — the object the contract
|
||||
// runs against.
|
||||
Keylet
|
||||
makeEscrow(Account const& owner, Account const& dest)
|
||||
{
|
||||
ledger.createAccount(owner, XRP(1000));
|
||||
ledger.createAccount(dest, XRP(1000));
|
||||
auto const ownerSeq = ledger.getAccountRoot(owner.id()).getSequence();
|
||||
auto const r = ledger.submit(
|
||||
transactions::EscrowCreateBuilder{owner.id(), dest.id(), XRP(100)}.setFinishAfter(
|
||||
900'000'000),
|
||||
owner);
|
||||
EXPECT_EQ(r.ter, tesSUCCESS) << transToken(r.ter);
|
||||
ledger.close();
|
||||
return keylet::escrow(owner.id(), SeqProxy::rawSequence(ownerSeq));
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(CurrentLedgerObjFieldE2e, ContractReadsAFieldOfItsRealEscrow)
|
||||
{
|
||||
auto const owner = Account{"owner"};
|
||||
auto const escrow = makeEscrow(owner, Account{"dest"});
|
||||
|
||||
// Ask the current object for `sfAccount` and return the byte count the host wrote — 20 for
|
||||
// an account id — proving the read reached the real ledger and came back through the VM.
|
||||
auto const wat = std::format(
|
||||
R"wat(
|
||||
(module
|
||||
(import "host_lib" "home_le_field" (func $home_le_field (param i32 i32 i32) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(func (export "escrow_finish") (result i32)
|
||||
(call $home_le_field (i32.const {}) (i32.const 0) (i32.const 32))))
|
||||
)wat",
|
||||
sfAccount.getCode());
|
||||
|
||||
auto const outcome = run(wat, escrow);
|
||||
ASSERT_TRUE(outcome.has_value()) << transToken(outcome.error().ter);
|
||||
EXPECT_EQ(outcome->result, static_cast<std::int32_t>(toBytes(owner.id()).size()));
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
66
src/tests/libxrpl/tx/wasm/e2e/FloatToMantExp.cpp
Normal file
66
src/tests/libxrpl/tx/wasm/e2e/FloatToMantExp.cpp
Normal file
@@ -0,0 +1,66 @@
|
||||
#include <xrpl/protocol/TER.h>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <tx/wasm/FloatFixture.h>
|
||||
#include <tx/wasm/RealVmTest.h>
|
||||
#include <tx/wasm/WasmRun.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <format>
|
||||
#include <string>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
// The only host function that writes to *two* output regions, and so the only place the
|
||||
// "one call, one answer" assumption in every other marshalling path is not what happens.
|
||||
// `float_to_mant_exp` splits a float into an eight-byte mantissa and a four-byte exponent,
|
||||
// each into its own guest buffer, and answers with a status rather than a byte count. Two
|
||||
// regions means two independent bounds checks, two writes, and an ordering between them —
|
||||
// none of which the single-output shapes exercise. `host_calls` pins that wiring against a
|
||||
// mock; this proves the real impl drives it the same way, with the guest reading both
|
||||
// halves back out of its own memory.
|
||||
struct FloatToMantExpE2e : RealVmTest
|
||||
{
|
||||
};
|
||||
|
||||
TEST_F(FloatToMantExpE2e, ContractReadsBothHalvesOfASplitFloat)
|
||||
{
|
||||
// Pi's canonical encoding in, mantissa to offset 64, exponent to offset 128. The
|
||||
// contract returns the low half of the mantissa so the assertion checks that real bytes
|
||||
// landed in the guest's buffer, not merely that the call reported success.
|
||||
auto const wat = std::format(
|
||||
R"wat(
|
||||
(module
|
||||
(import "host_lib" "float_to_mant_exp" (func $split (param i32 i32 i32 i32 i32 i32) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(data (i32.const 0) "{}")
|
||||
(func (export "escrow_finish") (result i32)
|
||||
(local $r i32)
|
||||
(local.set $r (call $split
|
||||
(i32.const 0) (i32.const 12)
|
||||
(i32.const 64) (i32.const 8)
|
||||
(i32.const 128) (i32.const 4)))
|
||||
(if (i32.lt_s (local.get $r) (i32.const 0)) (then (return (local.get $r))))
|
||||
(i32.load (i32.const 64))))
|
||||
)wat",
|
||||
watEscaped(FloatTest::kPi));
|
||||
|
||||
auto const outcome = run(wat);
|
||||
ASSERT_TRUE(outcome.has_value()) << transToken(outcome.error().ter);
|
||||
|
||||
// The expected value is derived from the input rather than written out as a literal,
|
||||
// because the derivation is the interesting part: a float stores its mantissa in the
|
||||
// first eight bytes **big-endian**, while `float_to_mant_exp` writes it to the guest
|
||||
// **little-endian**. So the guest's `i32.load` at the start of the mantissa buffer sees
|
||||
// the *low* 32 bits of a number whose bytes arrived in the opposite order. Getting that
|
||||
// flip wrong is exactly the convention mismatch this layer exists to catch, and a
|
||||
// hard-coded constant would hide it.
|
||||
auto mantissa = std::int64_t{0};
|
||||
for (auto i = 0U; i < 8; ++i)
|
||||
{
|
||||
mantissa = (mantissa << 8) | FloatTest::kPi[i];
|
||||
}
|
||||
EXPECT_EQ(outcome->result, static_cast<std::int32_t>(mantissa & 0xFFFFFFFF));
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
52
src/tests/libxrpl/tx/wasm/e2e/HostError.cpp
Normal file
52
src/tests/libxrpl/tx/wasm/e2e/HostError.cpp
Normal file
@@ -0,0 +1,52 @@
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <helpers/Account.h>
|
||||
#include <tx/wasm/RealVmTest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <format>
|
||||
#include <string>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
// The error channel, driven by a real failure rather than a mock's canned one.
|
||||
// Every other e2e case here proves a success path. But a contract spends most of its life
|
||||
// reacting to codes, and the path a *real* error takes is different from the one a mock
|
||||
// error takes: the impl returns a `HostFunctionError`, `HostContext` turns it into a wire
|
||||
// code, and the engine hands that back to the guest as a negative i32 without disturbing the
|
||||
// run. `host_calls` proves the middle step against a mock that was *told* to fail; nothing
|
||||
// until now has proved that a real impl's real failure comes out the far end intact.
|
||||
struct HostErrorE2e : RealVmTest
|
||||
{
|
||||
};
|
||||
|
||||
TEST_F(HostErrorE2e, ARealHostErrorReachesTheGuestAsItsWireCode)
|
||||
{
|
||||
// The contract runs against an account root, then asks it for `sfMemoData` — a field
|
||||
// that object does not carry. The impl genuinely fails to find it, so the code the
|
||||
// guest reads was produced by the real lookup rather than staged.
|
||||
auto const owner = fund("owner");
|
||||
|
||||
auto const wat = std::format(
|
||||
R"wat(
|
||||
(module
|
||||
(import "host_lib" "home_le_field" (func $home_le_field (param i32 i32 i32) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(func (export "escrow_finish") (result i32)
|
||||
(call $home_le_field (i32.const {}) (i32.const 0) (i32.const 32))))
|
||||
)wat",
|
||||
sfMemoData.getCode());
|
||||
|
||||
auto const outcome = run(wat, keylet::account(owner.id()));
|
||||
|
||||
// The run itself succeeds: a soft host error is an answer to the contract, not a fault
|
||||
// in it. Reporting it as a failed run would be the interesting bug here.
|
||||
ASSERT_TRUE(outcome.has_value()) << transToken(outcome.error().ter);
|
||||
EXPECT_EQ(outcome->result, static_cast<std::int32_t>(HostFunctionError::FieldNotFound));
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
49
src/tests/libxrpl/tx/wasm/e2e/HostFunctionTour.cpp
Normal file
49
src/tests/libxrpl/tx/wasm/e2e/HostFunctionTour.cpp
Normal file
@@ -0,0 +1,49 @@
|
||||
#include <xrpl/protocol/TER.h>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <tx/wasm/RealVmTest.h>
|
||||
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
// A single contract that tours several host functions end to end — a ledger-header read, the
|
||||
// base fee, a hash, a keylet, and a data write — returning 1 only if every call succeeds.
|
||||
struct HostFunctionTourE2e : RealVmTest
|
||||
{
|
||||
};
|
||||
|
||||
TEST_F(HostFunctionTourE2e, AContractTouringManyHostFunctionsSucceeds)
|
||||
{
|
||||
// Each call must return >= 0 (a byte count, i.e. success); the guest returns the first
|
||||
// negative error code, or 1 if the whole tour succeeds. Output regions are disjoint so no
|
||||
// call clobbers another, and buffers are generous so exact value sizes don't matter.
|
||||
static constexpr auto kWat = std::string_view{R"wat(
|
||||
(module
|
||||
(import "host_lib" "ldgr_index" (func $ldgr_index (param i32 i32) (result i32)))
|
||||
(import "host_lib" "base_fee" (func $base_fee (param i32 i32) (result i32)))
|
||||
(import "host_lib" "sha512_half" (func $sha512_half (param i32 i32 i32 i32) (result i32)))
|
||||
(import "host_lib" "accountroot_id" (func $accountroot_id (param i32 i32 i32 i32) (result i32)))
|
||||
(import "host_lib" "set_data" (func $set_data (param i32 i32) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(func (export "escrow_finish") (result i32)
|
||||
(local $r i32)
|
||||
(local.set $r (call $ldgr_index (i32.const 0) (i32.const 32)))
|
||||
(if (i32.lt_s (local.get $r) (i32.const 0)) (then (return (local.get $r))))
|
||||
(local.set $r (call $base_fee (i32.const 32) (i32.const 32)))
|
||||
(if (i32.lt_s (local.get $r) (i32.const 0)) (then (return (local.get $r))))
|
||||
(local.set $r (call $sha512_half (i32.const 0) (i32.const 4) (i32.const 64) (i32.const 32)))
|
||||
(if (i32.lt_s (local.get $r) (i32.const 0)) (then (return (local.get $r))))
|
||||
(local.set $r (call $accountroot_id (i32.const 0) (i32.const 20) (i32.const 128) (i32.const 32)))
|
||||
(if (i32.lt_s (local.get $r) (i32.const 0)) (then (return (local.get $r))))
|
||||
(local.set $r (call $set_data (i32.const 0) (i32.const 8)))
|
||||
(if (i32.lt_s (local.get $r) (i32.const 0)) (then (return (local.get $r))))
|
||||
(i32.const 1)))
|
||||
)wat"};
|
||||
|
||||
auto const outcome = run(kWat);
|
||||
ASSERT_TRUE(outcome.has_value()) << transToken(outcome.error().ter);
|
||||
EXPECT_EQ(outcome->result, 1) << "every host call in the tour should have succeeded";
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
33
src/tests/libxrpl/tx/wasm/e2e/LedgerSqn.cpp
Normal file
33
src/tests/libxrpl/tx/wasm/e2e/LedgerSqn.cpp
Normal file
@@ -0,0 +1,33 @@
|
||||
#include <xrpl/protocol/TER.h>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <tx/wasm/RealVmTest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
// The real ledger's sequence.
|
||||
struct LedgerSqnE2e : RealVmTest
|
||||
{
|
||||
};
|
||||
|
||||
TEST_F(LedgerSqnE2e, ContractReadsTheRealLedgerSequence)
|
||||
{
|
||||
// Ask the host for the ledger sequence into offset 0, then return the i32 stored there.
|
||||
static constexpr auto kWat = std::string_view{R"wat(
|
||||
(module
|
||||
(import "host_lib" "ldgr_index" (func $ldgr_index (param i32 i32) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(func (export "escrow_finish") (result i32)
|
||||
(drop (call $ldgr_index (i32.const 0) (i32.const 4)))
|
||||
(i32.load (i32.const 0))))
|
||||
)wat"};
|
||||
|
||||
auto const outcome = run(kWat);
|
||||
ASSERT_TRUE(outcome.has_value()) << transToken(outcome.error().ter);
|
||||
EXPECT_EQ(outcome->result, static_cast<std::int32_t>(ledger.getOpenLedger().header().seq));
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
31
src/tests/libxrpl/tx/wasm/e2e/SetData.cpp
Normal file
31
src/tests/libxrpl/tx/wasm/e2e/SetData.cpp
Normal file
@@ -0,0 +1,31 @@
|
||||
#include <xrpl/protocol/TER.h>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <tx/wasm/RealVmTest.h>
|
||||
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
// A contract writes its data field end to end.
|
||||
struct SetDataE2e : RealVmTest
|
||||
{
|
||||
};
|
||||
|
||||
TEST_F(SetDataE2e, ContractWritesItsData)
|
||||
{
|
||||
// `set_data` over 8 bytes of (zero-initialized) memory returns the byte count it stored.
|
||||
static constexpr auto kWat = std::string_view{R"wat(
|
||||
(module
|
||||
(import "host_lib" "set_data" (func $set_data (param i32 i32) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(func (export "escrow_finish") (result i32)
|
||||
(call $set_data (i32.const 0) (i32.const 8))))
|
||||
)wat"};
|
||||
|
||||
auto const outcome = run(kWat);
|
||||
ASSERT_TRUE(outcome.has_value()) << transToken(outcome.error().ter);
|
||||
EXPECT_EQ(outcome->result, 8);
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
47
src/tests/libxrpl/tx/wasm/e2e/TxField.cpp
Normal file
47
src/tests/libxrpl/tx/wasm/e2e/TxField.cpp
Normal file
@@ -0,0 +1,47 @@
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <helpers/Account.h>
|
||||
#include <helpers/TxTest.h>
|
||||
#include <tx/wasm/RealHostFixture.h>
|
||||
#include <tx/wasm/RealVmTest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <format>
|
||||
#include <string>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
// A contract reads a field of its transaction end to end.
|
||||
struct TxFieldE2e : RealVmTest
|
||||
{
|
||||
};
|
||||
|
||||
TEST_F(TxFieldE2e, ContractReadsAFieldOfItsTransaction)
|
||||
{
|
||||
auto const owner = Account{"owner"};
|
||||
ledger.createAccount(owner, XRP(1000));
|
||||
static constexpr auto kScale = std::uint8_t{8};
|
||||
auto const tx = mptIssuanceCreateTx(owner, kScale);
|
||||
|
||||
// Ask the tx for `sfAssetScale` (a single byte) and return the i32 the guest loads — the
|
||||
// scale, zero-extended — so the assertion checks the value flowed through, not just a count.
|
||||
auto const wat = std::format(
|
||||
R"wat(
|
||||
(module
|
||||
(import "host_lib" "tx_field" (func $tx_field (param i32 i32 i32) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(func (export "escrow_finish") (result i32)
|
||||
(drop (call $tx_field (i32.const {}) (i32.const 0) (i32.const 4)))
|
||||
(i32.load (i32.const 0))))
|
||||
)wat",
|
||||
sfAssetScale.getCode());
|
||||
|
||||
auto const outcome = run(wat, keylet::account(owner.id()), tx.type, tx.build);
|
||||
ASSERT_TRUE(outcome.has_value()) << transToken(outcome.error().ter);
|
||||
EXPECT_EQ(outcome->result, kScale);
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
64
src/tests/libxrpl/tx/wasm/e2e/TxNestedField.cpp
Normal file
64
src/tests/libxrpl/tx/wasm/e2e/TxNestedField.cpp
Normal file
@@ -0,0 +1,64 @@
|
||||
#include <xrpl/basics/Slice.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STArray.h>
|
||||
#include <xrpl/protocol/STObject.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <helpers/Account.h>
|
||||
#include <tx/wasm/RealHostFixture.h>
|
||||
#include <tx/wasm/RealVmTest.h>
|
||||
|
||||
#include <format>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
// The locator convention, end to end.
|
||||
struct TxNestedFieldE2e : RealVmTest
|
||||
{
|
||||
// An EscrowFinish carrying a memo, so the locator has a real leaf to reach.
|
||||
TxAssembler
|
||||
withMemo(Account const& acct)
|
||||
{
|
||||
auto assembler = escrowFinishTx(ledger, acct);
|
||||
assembler.build = [inner = std::move(assembler.build)](STObject& obj) {
|
||||
inner(obj);
|
||||
auto memos = STArray{};
|
||||
auto memo = STObject::makeInnerObject(sfMemo);
|
||||
memo.setFieldVL(sfMemoData, Slice{"hello", 5});
|
||||
memos.push_back(std::move(memo));
|
||||
obj.setFieldArray(sfMemos, memos);
|
||||
};
|
||||
return assembler;
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(TxNestedFieldE2e, ContractWalksALocatorToANestedTransactionField)
|
||||
{
|
||||
auto const owner = fund("owner");
|
||||
auto assembler = withMemo(owner);
|
||||
|
||||
auto const wat = std::format(
|
||||
R"wat(
|
||||
(module
|
||||
(import "host_lib" "tx_inner" (func $tx_inner (param i32 i32 i32 i32) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(func (export "escrow_finish") (result i32)
|
||||
(i32.store (i32.const 0) (i32.const {}))
|
||||
(i32.store (i32.const 4) (i32.const 0))
|
||||
(i32.store (i32.const 8) (i32.const {}))
|
||||
(call $tx_inner (i32.const 0) (i32.const 12) (i32.const 64) (i32.const 32))))
|
||||
)wat",
|
||||
sfMemos.getCode(),
|
||||
sfMemoData.getCode());
|
||||
|
||||
auto const outcome = run(wat, keylet::account(owner.id()), assembler.type, assembler.build);
|
||||
ASSERT_TRUE(outcome.has_value()) << transToken(outcome.error().ter);
|
||||
// Five bytes: "hello", the memo's data, reached through the locator.
|
||||
EXPECT_EQ(outcome->result, 5);
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
@@ -0,0 +1,74 @@
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <gmock/gmock.h>
|
||||
#include <gtest/gtest.h>
|
||||
#include <tx/wasm/WasmFixture.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <string>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
using testing::Return;
|
||||
|
||||
// home_le_field — a scalar field code in, bytes out.
|
||||
struct CurrentLedgerObjFieldCall : HostCallTest
|
||||
{
|
||||
// The field code the guest asks for. A real one, so the shim's `SField` lookup has
|
||||
// something to find.
|
||||
std::int32_t fieldCode = sfBalance.getCode();
|
||||
|
||||
[[nodiscard]] std::string
|
||||
wat() const override
|
||||
{
|
||||
return std::string{R"wat(
|
||||
(module
|
||||
(import "host_lib" "home_le_field" (func $home_le_field (param i32 i32 i32) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(func (export "escrow_finish") (result i32)
|
||||
(call $home_le_field (i32.const )wat"} +
|
||||
std::to_string(fieldCode) + R"wat() (i32.const 0) (i32.const 32))))
|
||||
)wat";
|
||||
}
|
||||
};
|
||||
|
||||
// The shim turns the guest's `i32` into the `SField` the C++ interface takes; asserting on
|
||||
// the argument is what pins that translation rather than assuming it.
|
||||
TEST_F(CurrentLedgerObjFieldCall, FieldCodeBecomesSFieldHostIsAskedFor)
|
||||
{
|
||||
EXPECT_CALL(host, getCurrentLedgerObjField(testing::Ref(sfBalance)))
|
||||
.WillOnce(Return(Bytes{1, 2, 3}));
|
||||
|
||||
EXPECT_EQ(hostAnswer(), 3) << "the length the host reported";
|
||||
}
|
||||
|
||||
TEST_F(CurrentLedgerObjFieldCall, UnknownFieldCodeIsRefusedWithoutAskingHost)
|
||||
{
|
||||
fieldCode = 0x7fff'0000; // a type nothing is registered under
|
||||
EXPECT_CALL(host, getCurrentLedgerObjField).Times(0);
|
||||
|
||||
EXPECT_EQ(hostAnswer(), hfErrorToInt(HostFunctionError::InvalidField));
|
||||
}
|
||||
|
||||
TEST_F(CurrentLedgerObjFieldCall, HostErrorBecomesContractReturnValue)
|
||||
{
|
||||
EXPECT_CALL(host, getCurrentLedgerObjField)
|
||||
.WillOnce(Return(std::unexpected(HostFunctionError::FieldNotFound)));
|
||||
|
||||
EXPECT_EQ(hostAnswer(), hfErrorToInt(HostFunctionError::FieldNotFound));
|
||||
}
|
||||
|
||||
// The field cap bounds the status, not just the bytes: a host reporting a length past
|
||||
// `kMaxWasmDataLength` is too large whatever the guest's buffer was.
|
||||
TEST_F(CurrentLedgerObjFieldCall, FieldPastProtocolCapIsTooLarge)
|
||||
{
|
||||
EXPECT_CALL(host, getCurrentLedgerObjField)
|
||||
.WillOnce(Return(Bytes(kMaxWasmDataLength + 1, 0xab)));
|
||||
|
||||
EXPECT_EQ(hostAnswer(), hfErrorToInt(HostFunctionError::DataFieldTooLarge));
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
69
src/tests/libxrpl/tx/wasm/host_calls/LedgerSqn.cpp
Normal file
69
src/tests/libxrpl/tx/wasm/host_calls/LedgerSqn.cpp
Normal file
@@ -0,0 +1,69 @@
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <gmock/gmock.h>
|
||||
#include <gtest/gtest.h>
|
||||
#include <tx/wasm/WasmFixture.h>
|
||||
|
||||
#include <expected>
|
||||
#include <string>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
using testing::Return;
|
||||
|
||||
// ldgr_index — no input, one scalar output.
|
||||
struct LedgerSqnCall : HostCallTest
|
||||
{
|
||||
[[nodiscard]] std::string
|
||||
wat() const override
|
||||
{
|
||||
return std::string{R"wat(
|
||||
(module
|
||||
(import "host_lib" "ldgr_index" (func $ldgr_index (param i32 i32) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
|
||||
;; Four bytes is what the value needs. Returns what the host wrote, or its error code.
|
||||
(func (export "escrow_finish") (result i32)
|
||||
(local $n i32)
|
||||
(local.set $n (call $ldgr_index (i32.const 0) (i32.const 4)))
|
||||
(select (local.get $n) (i32.load (i32.const 0)) (i32.lt_s (local.get $n) (i32.const 0))))
|
||||
|
||||
;; Two bytes is not enough for the value. Returns the host's code when memory is still
|
||||
;; zero, or 1 if anything was written into it - so a refused write is visibly a refusal
|
||||
;; and not a truncation.
|
||||
(func (export "into_two_bytes") (result i32)
|
||||
(local $n i32)
|
||||
(local.set $n (call $ldgr_index (i32.const 0) (i32.const 2)))
|
||||
(select (local.get $n) (i32.const 1) (i32.eqz (i32.load (i32.const 0))))))
|
||||
)wat"};
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(LedgerSqnCall, SequenceReachesGuestAsFourLittleEndianBytes)
|
||||
{
|
||||
EXPECT_CALL(host, getLedgerSqn()).WillOnce(Return(0x01020304u));
|
||||
|
||||
// Read back with `i32.load`, which is little-endian by the wasm spec — so the value
|
||||
// arriving intact is the byte order being right.
|
||||
EXPECT_EQ(hostAnswer(), 0x01020304);
|
||||
}
|
||||
|
||||
TEST_F(LedgerSqnCall, HostErrorBecomesContractReturnValue)
|
||||
{
|
||||
EXPECT_CALL(host, getLedgerSqn())
|
||||
.WillOnce(Return(std::unexpected(HostFunctionError::LedgerObjNotFound)));
|
||||
|
||||
EXPECT_EQ(hostAnswer(), hfErrorToInt(HostFunctionError::LedgerObjNotFound));
|
||||
}
|
||||
|
||||
// The engine decides the fit, not the host: the host is never told the guest's capacity, it
|
||||
// reports the value's true length and the engine turns a length past the buffer into
|
||||
// `BufferTooSmall` — with nothing written.
|
||||
TEST_F(LedgerSqnCall, BufferTooSmallIsRefusedWholeNotTruncated)
|
||||
{
|
||||
EXPECT_CALL(host, getLedgerSqn()).WillOnce(Return(0x01020304u));
|
||||
|
||||
EXPECT_EQ(hostAnswer("into_two_bytes"), hfErrorToInt(HostFunctionError::BufferTooSmall));
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
78
src/tests/libxrpl/tx/wasm/host_calls/Sha512Half.cpp
Normal file
78
src/tests/libxrpl/tx/wasm/host_calls/Sha512Half.cpp
Normal file
@@ -0,0 +1,78 @@
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <gmock/gmock.h>
|
||||
#include <gtest/gtest.h>
|
||||
#include <tx/wasm/MockHostFunctions.h>
|
||||
#include <tx/wasm/WasmFixture.h>
|
||||
|
||||
#include <expected>
|
||||
#include <string>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
using testing::Return;
|
||||
|
||||
// sha512_half — bytes in and bytes out, the shape that needs the engine's output buffer.
|
||||
struct Sha512HalfCall : HostCallTest
|
||||
{
|
||||
[[nodiscard]] std::string
|
||||
wat() const override
|
||||
{
|
||||
return std::string{R"wat(
|
||||
(module
|
||||
(import "host_lib" "sha512_half" (func $sha512_half (param i32 i32 i32 i32) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(data (i32.const 64) "abc")
|
||||
|
||||
;; Hashes the three bytes at 64 into the 32 at 0, then returns the first four bytes of the
|
||||
;; digest so the answer is shown to have arrived, not just been counted.
|
||||
(func (export "escrow_finish") (result i32)
|
||||
(local $n i32)
|
||||
(local.set $n (call $sha512_half (i32.const 64) (i32.const 3) (i32.const 0) (i32.const 32)))
|
||||
(select (local.get $n) (i32.load (i32.const 0)) (i32.lt_s (local.get $n) (i32.const 0))))
|
||||
|
||||
;; Reports the length the host gave, for the cases where the digest itself is not the point.
|
||||
(func (export "digest_length") (result i32)
|
||||
(call $sha512_half (i32.const 64) (i32.const 3) (i32.const 0) (i32.const 32))))
|
||||
)wat"};
|
||||
}
|
||||
|
||||
// A digest whose first four bytes are distinctive, so the load below cannot pass by
|
||||
// accident.
|
||||
static Hash
|
||||
digest()
|
||||
{
|
||||
Hash value;
|
||||
value.begin()[0] = 0x0d;
|
||||
value.begin()[1] = 0x0c;
|
||||
value.begin()[2] = 0x0b;
|
||||
value.begin()[3] = 0x0a;
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
// Both directions in one call: the guest's bytes reach the host borrowed from its memory, and
|
||||
// the answer comes back into the same memory through the engine's buffer.
|
||||
TEST_F(Sha512HalfCall, GuestBytesReachHostAndDigestComesBack)
|
||||
{
|
||||
EXPECT_CALL(host, computeSha512HalfHash(BytesAre("abc"))).WillOnce(Return(digest()));
|
||||
|
||||
EXPECT_EQ(hostAnswer(), 0x0a0b0c0d) << "the digest's first four bytes, little-endian";
|
||||
}
|
||||
|
||||
TEST_F(Sha512HalfCall, DigestIsThirtyTwoBytes)
|
||||
{
|
||||
EXPECT_CALL(host, computeSha512HalfHash).WillOnce(Return(digest()));
|
||||
|
||||
EXPECT_EQ(hostAnswer("digest_length"), 32);
|
||||
}
|
||||
|
||||
TEST_F(Sha512HalfCall, HostErrorBecomesContractReturnValue)
|
||||
{
|
||||
EXPECT_CALL(host, computeSha512HalfHash)
|
||||
.WillOnce(Return(std::unexpected(HostFunctionError::InvalidParams)));
|
||||
|
||||
EXPECT_EQ(hostAnswer(), hfErrorToInt(HostFunctionError::InvalidParams));
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
220
src/tests/libxrpl/tx/wasm/host_calls/Trace.cpp
Normal file
220
src/tests/libxrpl/tx/wasm/host_calls/Trace.cpp
Normal file
@@ -0,0 +1,220 @@
|
||||
#include <xrpl/basics/Number.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
#include <xrpl/protocol/STAmount.h>
|
||||
#include <xrpl/protocol/Serializer.h>
|
||||
#include <xrpl/tx/wasm/HostContext.h>
|
||||
#include <xrpl/tx/wasm/HostFunc.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <gmock/gmock.h>
|
||||
#include <gtest/gtest.h>
|
||||
#include <tx/wasm/WasmFixture.h>
|
||||
// For `TraceDataType`: declared in the cxx bridge, defined in the header it generates.
|
||||
#include <xrpl_wasm_vm_ffi_cxxbridge/lib.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <format>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
namespace {
|
||||
|
||||
// Bytes as a WAT data segment's contents. Hex-escaped throughout, so a buffer needs no
|
||||
// thought about which of its bytes the text format would otherwise read.
|
||||
std::string
|
||||
watBytes(Bytes const& bytes)
|
||||
{
|
||||
std::string escaped;
|
||||
escaped.reserve(bytes.size() * 4);
|
||||
for (auto const byte : bytes)
|
||||
escaped += std::format("\\{:02x}", byte);
|
||||
return escaped;
|
||||
}
|
||||
|
||||
Bytes
|
||||
serialized(STAmount const& amount)
|
||||
{
|
||||
Serializer s;
|
||||
amount.add(s);
|
||||
return s.getData();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// trace — a message, a data type, and a buffer holding what that type says. One import
|
||||
// covers every rendering, so what a test varies is the type rather than the function.
|
||||
//
|
||||
// The buffer arrives as bytes and leaves as text: `HostContext` renders it, and the host is
|
||||
// handed the finished line. So a test says which renderer the type selected.
|
||||
struct TraceCall : HostCallTest
|
||||
{
|
||||
static constexpr std::int32_t kDataAt = 64;
|
||||
|
||||
// What the guest passes. `typeCode` rather than a `TraceDataType` so a test can send a
|
||||
// code that names no type, which is the guest's to get wrong.
|
||||
std::int32_t typeCode{static_cast<std::int32_t>(TraceDataType::AsText)};
|
||||
Bytes data;
|
||||
|
||||
void
|
||||
traces(TraceDataType type, Bytes bytes)
|
||||
{
|
||||
typeCode = static_cast<std::int32_t>(type);
|
||||
data = std::move(bytes);
|
||||
}
|
||||
|
||||
void
|
||||
traces(TraceDataType type, std::string_view text)
|
||||
{
|
||||
traces(type, Bytes{text.begin(), text.end()});
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string
|
||||
wat() const override
|
||||
{
|
||||
// {0} data offset, {1} the data itself, {2} the type under test, {3} its length,
|
||||
// {4} a type the constant modules can name, {5} the data cap.
|
||||
return std::format(
|
||||
R"wat(
|
||||
(module
|
||||
(import "host_lib" "trace" (func $trace (param i32 i32 i32 i32 i32)))
|
||||
(memory (export "memory") 1)
|
||||
(data (i32.const 0) "note")
|
||||
(data (i32.const {0}) "{1}")
|
||||
|
||||
(func (export "escrow_finish") (result i32)
|
||||
(call $trace (i32.const 0) (i32.const 4) (i32.const {2}) (i32.const {0}) (i32.const {3}))
|
||||
(i32.const 1))
|
||||
|
||||
(func (export "unnamed_type") (result i32)
|
||||
(call $trace (i32.const 0) (i32.const 4) (i32.const 0) (i32.const {0}) (i32.const 0))
|
||||
(i32.const 1))
|
||||
|
||||
(func (export "past_memory") (result i32)
|
||||
(call $trace (i32.const 0) (i32.const 4) (i32.const {4}) (i32.const 65536) (i32.const 1))
|
||||
(i32.const 1))
|
||||
|
||||
(func (export "too_long") (result i32)
|
||||
(call $trace (i32.const 0) (i32.const 4) (i32.const {4}) (i32.const {0}) (i32.const {5}))
|
||||
(i32.const 1)))
|
||||
)wat",
|
||||
kDataAt,
|
||||
watBytes(data),
|
||||
typeCode,
|
||||
data.size(),
|
||||
static_cast<std::int32_t>(TraceDataType::AsHex),
|
||||
kMaxWasmDataLength);
|
||||
}
|
||||
|
||||
// The line the host was handed, for a run that is expected to reach it.
|
||||
void
|
||||
expectTraced(std::string_view text)
|
||||
{
|
||||
EXPECT_CALL(host, trace(std::string_view("note"), text));
|
||||
|
||||
EXPECT_EQ(hostAnswer(), 1) << "the contract runs on past its trace";
|
||||
}
|
||||
};
|
||||
|
||||
// The eight-byte types are the pair worth naming: the same bytes, and the type is the whole
|
||||
// difference between the two readings.
|
||||
TEST_F(TraceCall, Int64ReadsTheBufferSigned)
|
||||
{
|
||||
traces(TraceDataType::Int64, Bytes(8, 0xff));
|
||||
|
||||
expectTraced("-1");
|
||||
}
|
||||
|
||||
TEST_F(TraceCall, Uint64ReadsTheSameBufferUnsigned)
|
||||
{
|
||||
traces(TraceDataType::Uint64, Bytes(8, 0xff));
|
||||
|
||||
expectTraced("18446744073709551615");
|
||||
}
|
||||
|
||||
TEST_F(TraceCall, AsTextTakesTheBufferVerbatim)
|
||||
{
|
||||
traces(TraceDataType::AsText, "hello");
|
||||
|
||||
expectTraced("hello");
|
||||
}
|
||||
|
||||
TEST_F(TraceCall, AsHexEncodesTheBuffer)
|
||||
{
|
||||
traces(TraceDataType::AsHex, Bytes{0x07, 0x08, 0xff});
|
||||
|
||||
expectTraced("0708FF");
|
||||
}
|
||||
|
||||
// The zero account, so the expectation is the well-known base58 rather than a rendering of
|
||||
// whatever the renderer happened to do.
|
||||
TEST_F(TraceCall, AccountIsBase58)
|
||||
{
|
||||
traces(TraceDataType::Account, Bytes(AccountID::size(), 0));
|
||||
|
||||
expectTraced("rrrrrrrrrrrrrrrrrrrrrhoLvTp");
|
||||
}
|
||||
|
||||
TEST_F(TraceCall, AmountCarriesItsAssetIntoTheText)
|
||||
{
|
||||
traces(TraceDataType::Amount, serialized(STAmount{XRPAmount{1000}}));
|
||||
|
||||
expectTraced("1000/XRP");
|
||||
}
|
||||
|
||||
TEST_F(TraceCall, XfloatIsDecodedToItsValue)
|
||||
{
|
||||
auto const encoded = wasm_float::floatFromIntImpl(
|
||||
42, static_cast<std::int32_t>(Number::RoundingMode::ToNearest));
|
||||
ASSERT_TRUE(encoded.has_value());
|
||||
traces(TraceDataType::Xfloat, *encoded);
|
||||
|
||||
expectTraced("42");
|
||||
}
|
||||
|
||||
// The width is part of the type, and a buffer that is not it holds no value to print. The
|
||||
// contract is not told: a trace answers nothing at all.
|
||||
TEST_F(TraceCall, ABufferOfTheWrongWidthIsDropped)
|
||||
{
|
||||
traces(TraceDataType::Int64, Bytes(4, 0xff));
|
||||
|
||||
EXPECT_CALL(host, trace).Times(0);
|
||||
EXPECT_EQ(hostAnswer(), 1);
|
||||
}
|
||||
|
||||
// `STAmount`'s deserializer rejects this by throwing, which must not escape into the run.
|
||||
TEST_F(TraceCall, AMalformedAmountIsDroppedRatherThanThrown)
|
||||
{
|
||||
traces(TraceDataType::Amount, Bytes(3, 0xff));
|
||||
|
||||
EXPECT_CALL(host, trace).Times(0);
|
||||
EXPECT_EQ(hostAnswer(), 1);
|
||||
}
|
||||
|
||||
// Zero is the code a guest sends by omission, which is why no type carries it.
|
||||
TEST_F(TraceCall, ACodeThatNamesNoTypeIsDropped)
|
||||
{
|
||||
EXPECT_CALL(host, trace).Times(0);
|
||||
|
||||
EXPECT_EQ(hostAnswer("unnamed_type"), 1);
|
||||
}
|
||||
|
||||
// The memory policy every input region is held to, on the one call that cannot report it.
|
||||
TEST_F(TraceCall, ARegionPastMemoryIsDropped)
|
||||
{
|
||||
EXPECT_CALL(host, trace).Times(0);
|
||||
|
||||
EXPECT_EQ(hostAnswer("past_memory"), 1);
|
||||
}
|
||||
|
||||
TEST_F(TraceCall, AMessageAndBufferPastTheDataCapAreDropped)
|
||||
{
|
||||
EXPECT_CALL(host, trace).Times(0);
|
||||
|
||||
EXPECT_EQ(hostAnswer("too_long"), 1);
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user