Compare commits

...

437 Commits

Author SHA1 Message Date
TimothyBanks
5973d60665 fix: Correct variance between charged and implied gas for guestInstruction test 2026-08-27 16:15:34 -04:00
TimothyBanks
2787af8cd0 chore: Address self review comments 2026-08-26 22:16:43 -04:00
TimothyBanks
abc888707a chore: Address self review comments 2026-08-26 21:48:27 -04:00
TimothyBanks
e29e24dd50 chore: Self code review 2026-08-26 18:09:56 -04:00
TimothyBanks
37128fb8bd fix: Add benchmark tests for host functions 2026-08-26 15:25:10 -04:00
TimothyBanks
072c7cb501 fix: Remove orphaned files 2026-08-25 20:49:12 -04:00
TimothyBanks
8320dd4982 fix: Remove orphaned files 2026-08-25 20:41:00 -04:00
TimothyBanks
7e3ac18345 fix: Remove orphaned files 2026-08-25 19:46:50 -04:00
TimothyBanks
e850e10651 fix: Remove orphaned files 2026-08-25 19:43:58 -04:00
TimothyBanks
6b8c28fddd fix: Port rest of wasm tests over to new design 2026-08-25 19:39:44 -04:00
TimothyBanks
d1cf8c2214 fix: Port rest of wasm tests over to new design 2026-08-25 19:34:14 -04:00
Timothy Banks
20750084ab Merge pull request #8073 from XRPLF/timothybanks/Upgrade-Wasmi-Version
chore: upgrade wasmi to 2.0.0-beta.10
2026-08-25 16:40:30 -04:00
TimothyBanks
e101ef7923 fix: Address failing CI 2026-08-25 16:01:50 -04:00
TimothyBanks
3f66957fd2 fix: Move E2E over to new wasm design 2026-08-25 15:39:22 -04:00
TimothyBanks
b6cb3c9591 chore: Merge upstream 2026-08-25 15:06:22 -04:00
TimothyBanks
ef7f6025e4 fix: Port E2E tests to new wasm design 2026-08-25 13:20:15 -04:00
Timothy Banks
b268a25f24 Merge pull request #8103 from XRPLF/timothybanks/Improve_test_coverage
fix:  Move host function test over to new wasm design
2026-08-25 18:15:12 +01:00
Sergey Kuznetsov
be2fbd4bf7 test: Add HostContext unit tests (#8101) 2026-08-25 16:34:42 +01:00
TimothyBanks
a8316180e4 chore: Address more review comments 2026-08-25 11:27:43 -04:00
TimothyBanks
71d0f26ed5 fix: Port wasm tests to new design 2026-08-25 11:10:18 -04:00
TimothyBanks
433955f022 chore: Address review comments 2026-08-25 10:33:53 -04:00
TimothyBanks
3d14bce9d8 chore: Address review comments 2026-08-25 10:26:24 -04:00
TimothyBanks
03eabd537e fix: Porting Wasm tests to new wasm design 2026-08-25 10:22:54 -04:00
TimothyBanks
cabbb15c6c fix: Correct merge issue 2026-08-24 19:54:57 -04:00
TimothyBanks
31b2fa5b37 fix: Correct CI build failures 2026-08-24 19:32:55 -04:00
TimothyBanks
135e395e8d chore: Merge upstream branch 2026-08-24 19:08:38 -04:00
TimothyBanks
17a00ed449 fix: Add host function tests on new wasm design 2026-08-24 18:52:35 -04:00
TimothyBanks
49049f9592 chore: Merge all downstream wasm tests to prepare for refactoring 2026-08-24 14:53:48 -04:00
Sergey Kuznetsov
7898a5f402 test: Add HostContext unit tests
Port the host_context/ suite from Improve_test_coverage: one file per
HostContext method over a strict MockHostFunctions, the HostContextFixture
they share, and the mock's remaining entries.

434 tests in 61 files, over argument decoding, the SField lookup, forwarding
fidelity, the out-region contract and guarded's exception containment - the
properties the WAT-driven host_calls/ layer cannot reach without a checked-in
module per function.
2026-08-24 18:04:30 +01:00
Sergey Kuznetsov
a0794738a6 refactor: Wasm vm redesign (#8012)
Co-authored-by: TimothyBanks <tbanks@ripple.com>
2026-08-24 15:22:39 +01:00
TimothyBanks
31d8267c2e chore: merge upstream in branch 2026-08-21 20:10:04 -04:00
TimothyBanks
bb90d20511 fix: Correct failing tests 2026-08-21 15:32:36 -04:00
Sergey Kuznetsov
b57ead1a8a Fix review comments 2026-08-21 18:42:10 +01:00
Sergey Kuznetsov
27859c1f6f Improve docs 2026-08-21 18:10:30 +01:00
Sergey Kuznetsov
e4baece501 Fix review comments 2026-08-21 17:52:34 +01:00
Sergey Kuznetsov
86c09d8acc Check value for nullptr 2026-08-21 17:38:07 +01:00
TimothyBanks
21f1a3e1f7 fix: Correct failing tests 2026-08-21 11:57:35 -04:00
Sergey Kuznetsov
57ae858f14 Fix windows build 2026-08-21 15:28:12 +01:00
Sergey Kuznetsov
e3ceab3f49 Fix clang-tidy and formatting 2026-08-21 15:28:11 +01:00
Sergey Kuznetsov
4300c5d7d6 Limit vm table size 2026-08-21 15:03:38 +01:00
TimothyBanks
cc0a6ef77f fix: Correct failing tests 2026-08-21 09:44:47 -04:00
TimothyBanks
804b7d2dcd fix: Correct failing tests 2026-08-21 08:46:14 -04:00
Sergey Kuznetsov
ddaa958754 Fix write_into 2026-08-21 11:58:36 +01:00
TimothyBanks
3530a869cf fix: Correct failing tests 2026-08-20 20:47:44 -04:00
TimothyBanks
9f5e08de66 chore: upgrade wasmi to 2.0.0-beta.10 2026-08-20 18:26:15 -04:00
Sergey Kuznetsov
2851ff46ab Fixes after merge 2026-08-20 16:40:37 +01:00
Sergey Kuznetsov
000fcf88bd Remove hello world crate 2026-08-20 16:21:39 +01:00
Sergey Kuznetsov
36e6dfaf62 Remove rust cmake flag 2026-08-20 16:15:06 +01:00
Sergey Kuznetsov
0c759f6cbd Merge wasmi-host-functions 2026-08-20 15:41:14 +01:00
Sergey Kuznetsov
35fab72e97 Merge ripple/wasmi 2026-08-20 15:12:57 +01:00
Sergey Kuznetsov
ee7ac58ffb Merge develop 2026-08-20 15:09:15 +01:00
TimothyBanks
82876ca47c chore: Address review comments 2026-08-19 16:04:31 -04:00
Sergey Kuznetsov
1761d83b8a Merge ripple/wasmi 2026-08-19 18:10:16 +01:00
Sergey Kuznetsov
579c44bb22 Merge develop 2026-08-19 17:49:41 +01:00
Sergey Kuznetsov
17d7bceadd Update comments 2026-08-19 16:08:02 +01:00
TimothyBanks
ec64f23736 fix: Additional AccountKeyley test cases 2026-08-18 13:07:52 -04:00
TimothyBanks
dc0326a2a0 fix: Use local variable for makeHost where applicable 2026-08-18 13:00:21 -04:00
TimothyBanks
80d8e827d7 fix: Update CacheLedgerObjImpl to use implicit parameter 2026-08-18 11:57:56 -04:00
TimothyBanks
b84d25fbed fix: Update test names on AmmKeylet tests 2026-08-18 11:35:59 -04:00
TimothyBanks
fda9c6995e fix: Add std::source_location to expectValue and expectError functions 2026-08-18 11:32:58 -04:00
TimothyBanks
c6c68090f2 fix: Move float constants under FloatTest 2026-08-18 11:28:18 -04:00
TimothyBanks
6c32ca3538 fix: Cleanup per self review of code 2026-08-17 15:10:35 -04:00
TimothyBanks
7122bb8a43 fix: Make Wasm_tests.cpp run again 2026-08-17 15:01:46 -04:00
TimothyBanks
c5524e4881 fix: Port WASM host function tests to new WASM design 2026-08-17 14:24:34 -04:00
Sergey Kuznetsov
37e2f23ee6 Fix review comments 2026-08-17 15:33:47 +01:00
TimothyBanks
e863db5061 feat: Porting wasm host function tests to new design 2026-08-15 19:41:32 -04:00
Sergey Kuznetsov
d469fc2cdf Fix clang-tidy 2026-08-13 14:14:16 +01:00
Sergey Kuznetsov
362ea7a5d1 More tests 2026-08-13 13:59:25 +01:00
Sergey Kuznetsov
1b4fede15b Add unit tests for host function 2026-08-12 18:04:54 +01:00
Sergey Kuznetsov
2605b4a78b Fix clippy and doc test errors 2026-08-12 11:57:19 +01:00
Sergey Kuznetsov
00488bf0b5 Check total 2026-08-12 11:21:35 +01:00
Sergey Kuznetsov
ecbfb8ea06 Merge pull request #5 from XRPLF/Wasm-vm-redesign-host-functions 2026-08-12 10:51:58 +01:00
TimothyBanks
e91a30d004 fix: Merge upstream branch 2026-08-11 16:03:15 -04:00
TimothyBanks
694fbb7ce3 fix: Merge upstream branch 2026-08-11 15:57:15 -04:00
TimothyBanks
b67a896bff fix: Merge upstream branch 2026-08-11 14:51:40 -04:00
TimothyBanks
9288432da5 feat: Self code review changes 2026-08-11 14:31:55 -04:00
TimothyBanks
952450255f feat: Self code review changes 2026-08-11 14:16:42 -04:00
TimothyBanks
00dd93e77d feat: Self code review changes 2026-08-11 13:59:33 -04:00
Sergey Kuznetsov
4526a97c54 Fix clang-tidy issues 2026-08-11 17:58:08 +01:00
Sergey Kuznetsov
ecc2f07ea3 Add internal fatal error code 2026-08-11 17:39:47 +01:00
TimothyBanks
c5d25e3055 feat: Self code review changes 2026-08-11 12:22:39 -04:00
TimothyBanks
0749043d09 feat: Clean up a few errant host function signatures 2026-08-11 12:03:39 -04:00
Sergey Kuznetsov
95526371b0 Update error codes 2026-08-11 16:45:06 +01:00
TimothyBanks
97f32869df feat: Hook up float host functions 2026-08-11 10:10:41 -04:00
Sergey Kuznetsov
454c651c44 Move macros in a separate file 2026-08-11 14:57:25 +01:00
Sergey Kuznetsov
91a23fc92c Update trace method 2026-08-11 14:47:30 +01:00
TimothyBanks
7d52867e3c feat: Hook up nft_uri, nft_issuer, nft_taxon, nft_flags, nft_xfer_fee, nft_serial host functions 2026-08-11 09:44:00 -04:00
TimothyBanks
98cf3a0532 feat: Hook up set_data host function 2026-08-10 21:58:24 -04:00
TimothyBanks
98abdef208 feat: Hook up vault_id host function 2026-08-10 21:49:46 -04:00
TimothyBanks
7e7014c7ca feat: Hook up ticket_id host function 2026-08-10 21:45:31 -04:00
TimothyBanks
d6a66d7249 feat: Hook up signers_id host function 2026-08-10 21:41:38 -04:00
TimothyBanks
6eab6c7c28 feat: Hook up permissioned_domain_id host function 2026-08-10 21:35:33 -04:00
TimothyBanks
ca560ed6b0 feat: Hook up paychan_id host function 2026-08-10 21:31:09 -04:00
TimothyBanks
58e47b0101 feat: Hook up oracle_id host function 2026-08-10 21:26:29 -04:00
TimothyBanks
5f6f367b23 feat: Hook up oracle_id host function 2026-08-10 21:20:07 -04:00
TimothyBanks
f52eb08d8a feat: Hook up nft_offer_id host function 2026-08-10 21:12:57 -04:00
TimothyBanks
2821cc3e8e feat: Hook up mptoken_id host function 2026-08-10 21:08:26 -04:00
TimothyBanks
c1008c473c feat: Hook up mpt_issuance_id host function 2026-08-10 21:04:38 -04:00
TimothyBanks
50623665c7 feat: Hook up trustline_id host function 2026-08-10 21:00:11 -04:00
TimothyBanks
78c8128c98 feat: Hook up escrow_id host function 2026-08-10 20:56:18 -04:00
TimothyBanks
c5605b6bcd feat: Hook up did_id host function 2026-08-10 20:50:40 -04:00
TimothyBanks
a321a5dbfb feat: Hook up deposit_preauth_id host function 2026-08-10 20:45:52 -04:00
TimothyBanks
3e113db4f5 feat: Hook up delegate_id host function 2026-08-10 18:07:49 -04:00
TimothyBanks
9c423d2743 feat: Hook up credential_id host function 2026-08-10 18:04:25 -04:00
TimothyBanks
e60029d5a0 feat: Hook up check_id host function 2026-08-10 17:59:57 -04:00
TimothyBanks
13196b839e feat: Hook up amm_id host function 2026-08-10 17:53:59 -04:00
TimothyBanks
17fb37871c feat: Hook up accountroot_id host function 2026-08-10 17:46:07 -04:00
TimothyBanks
3caaecff07 feat: Hook up check_sig host function 2026-08-10 17:39:53 -04:00
TimothyBanks
229377abd9 feat: Hook up le_inner_arr_len host function 2026-08-10 17:30:57 -04:00
TimothyBanks
e23f8e266a feat: Hook up home_le_inner_arr_len host function 2026-08-10 17:27:13 -04:00
TimothyBanks
1d9485c926 feat: Hook up tx_inner_arr_len host function 2026-08-10 17:23:06 -04:00
TimothyBanks
930ba88921 feat: Hook up le_arr_len host function 2026-08-10 17:18:56 -04:00
TimothyBanks
c619ae0263 feat: Hook up home_le_arr_len host function 2026-08-10 17:15:30 -04:00
TimothyBanks
93db40a25e feat: Hook up tx_arr_len host function 2026-08-10 17:11:36 -04:00
TimothyBanks
8cae773691 feat: Hook up le_inner host function 2026-08-10 17:05:46 -04:00
TimothyBanks
fe325ea96a feat: Hook up home_le_inner host function 2026-08-10 17:01:09 -04:00
TimothyBanks
881d040a22 feat: Hook up tx_inner host function 2026-08-10 16:56:20 -04:00
TimothyBanks
ce5e724b93 feat: Hook up le_field host function 2026-08-10 16:49:58 -04:00
TimothyBanks
1656a19fe6 feat: Hook up tx_field host function 2026-08-10 16:40:51 -04:00
TimothyBanks
3a2cf64a69 feat: Hook up cache_le host function 2026-08-10 16:20:50 -04:00
TimothyBanks
accd0cac6c feat: Hook up amendment_enabled host function 2026-08-10 16:06:05 -04:00
TimothyBanks
9a6efde771 feat: Hook up base_fee host function 2026-08-10 15:19:52 -04:00
TimothyBanks
6abd492ebb feat: Hook up parent_ldgr_hash host function 2026-08-10 15:04:35 -04:00
TimothyBanks
8da36db515 feat: Hook up parent_ldgr_time host function 2026-08-10 14:55:57 -04:00
Sergey Kuznetsov
152406b698 Merge ripple/wasmi-host-functions 2026-08-10 16:07:31 +01:00
Sergey Kuznetsov
b5a90e76ee Fix build on windows 2026-08-10 15:41:09 +01:00
Peng Wang
abfa572370 fix: Adapt wasm keylet calls to SeqProxy API (#7890)
develop changed every sequence-based `keylet::` factory to take
`SeqProxy const&` instead of `std::uint32_t`, and removed the
two-argument `mptokenIssuance(seq, issuer)` overload. The wasm host
functions and their tests still passed raw sequences, so the branch
merged cleanly but did not compile.

Wrap the raw sequences at the call sites, matching the idiom develop
adopted in its own tests:

- `SeqProxy::rawSequence` for check, escrow, nftokenOffer, offer,
  payChannel, permissionedDomain and vault
- `SeqProxy::rawTicket` for ticket
- `keylet::mptokenIssuance(makeMptID(seq, issuer))` for the removed
  overload

No computed keylet changes: the factories only read `seq.value()`, and
the removed overload was itself defined as `mptokenIssuance(makeMptID(
seq, issuer))`.
2026-08-09 15:52:10 -04:00
Peng Wang
d8e44687bf Merge branch 'ripple/wasmi' into ripple/wasmi-host-functions 2026-08-09 14:50:30 -04:00
pwang200
8a3363752f trace host function refactor (#7920) 2026-08-09 14:41:56 -04:00
Peng Wang
226ec160bb Merge remote-tracking branch 'origin/develop' into ripple/wasmi 2026-08-09 14:34:11 -04:00
Sergey Kuznetsov
6bc7a9a858 Fix clang-tidy errors 2026-08-07 17:51:35 +01:00
Sergey Kuznetsov
ba7bf92725 Fix build 2026-08-07 17:12:52 +01:00
Sergey Kuznetsov
8f0eff4dc4 Bring tests back 2026-08-07 16:49:03 +01:00
Sergey Kuznetsov
7f9ece3891 Fixed review comments 2026-08-06 17:54:28 +01:00
Sergey Kuznetsov
ffb2d37d17 Pre-commit and removed implementation docs 2026-08-04 17:02:17 +01:00
Sergey Kuznetsov
6c02c45cbe Clean up 2026-08-04 16:55:10 +01:00
Sergey Kuznetsov
eb946d23af Merge 'Rust_integration' 2026-08-04 15:29:54 +01:00
Sergey Kuznetsov
994967521e Merge 'ripple/wasmi-host-functions' 2026-08-04 15:27:26 +01:00
Sergey Kuznetsov
52d2008797 Merge branch 'ripple/wasmi' into ripple/wasmi-host-functions 2026-08-04 15:24:35 +01:00
Sergey Kuznetsov
4d685d4bad Merge branch 'develop' into ripple/wasmi 2026-08-04 15:24:06 +01:00
Sergey Kuznetsov
3f159d624b Fixes 2026-08-04 15:12:19 +01:00
Sergey Kuznetsov
f8f8a6e059 Merge branch 'develop' into Rust_integration 2026-08-04 14:59:21 +01:00
Sergey Kuznetsov
5b2fc952d3 Add preflight to c++ code 2026-08-04 14:56:05 +01:00
Sergey Kuznetsov
8ecb77dcfb Add check to vm 2026-08-04 14:09:51 +01:00
Sergey Kuznetsov
f2271ecc03 Split design doc 2026-08-04 13:30:05 +01:00
Sergey Kuznetsov
0bf4739efa Implement ffi and host functions bindings 2026-08-03 16:55:50 +01:00
Sergey Kuznetsov
0df034a685 Add Region 2026-08-03 14:54:00 +01:00
Sergey Kuznetsov
047a3f5cb8 Update docs 2026-08-03 14:30:58 +01:00
Sergey Kuznetsov
e484a2902c Change tmp buffer to output and make it global 2026-08-03 13:18:17 +01:00
Sergey Kuznetsov
ef0b5dd1ac Update doc 2026-07-30 18:01:52 +01:00
Sergey Kuznetsov
041869ff3d Search memory in exports. Cache memory 2026-07-30 17:56:24 +01:00
Sergey Kuznetsov
25afc04420 More fixes 2026-07-30 16:25:25 +01:00
Sergey Kuznetsov
ed6f0f3019 Don't charge for reading in host functions 2026-07-30 15:48:31 +01:00
Sergey Kuznetsov
b61b18a92c Trap on critical errors 2026-07-30 15:18:27 +01:00
Sergey Kuznetsov
011235f9a7 Writing tests for vm 2026-07-30 14:04:23 +01:00
Sergey Kuznetsov
be7532e3f7 Move output into params. Now rust code compiles 2026-07-29 15:38:48 +01:00
Sergey Kuznetsov
14e7dea7ed WIP 2026-07-29 15:11:07 +01:00
Sergey Kuznetsov
9e28519e56 Move HostFnSpec out of macro 2026-07-29 14:20:37 +01:00
Sergey Kuznetsov
2cc8b87c87 Add self to host functions trat 2026-07-29 11:04:20 +01:00
Sergey Kuznetsov
d8d1ec46dc WIP 2026-07-29 10:48:57 +01:00
Sergey Kuznetsov
641ecb47bd Fix bugs, add tests, improve docs 2026-07-28 14:30:47 +01:00
Sergey Kuznetsov
94ed8e2f49 Finish host function macro 2026-07-28 13:48:39 +01:00
Sergey Kuznetsov
c4ce52c810 Moved HostFunctionsInput into a separte file 2026-07-28 12:55:46 +01:00
Sergey Kuznetsov
be3d98e8ff Fix codecov settings 2026-07-27 17:21:04 +01:00
Sergey Kuznetsov
7915202cbc Run pre-commit 2026-07-27 16:55:51 +01:00
Sergey Kuznetsov
7ebd92ad3e Implementing macro 2026-07-27 16:52:37 +01:00
Sergey Kuznetsov
f458c0f981 Merge branch 'develop' into Rust_integration 2026-07-27 16:30:57 +01:00
Sergey Kuznetsov
abcdeab11e Update ci image 2026-07-27 16:24:44 +01:00
Sergey Kuznetsov
55911cf526 Merge branch 'develop' into Rust_integration 2026-07-27 15:58:54 +01:00
Sergey Kuznetsov
b7059deb9f Remove wasmi dependency 2026-07-24 16:56:05 +01:00
Sergey Kuznetsov
11fd30b02a Add empty crates 2026-07-24 16:43:11 +01:00
Sergey Kuznetsov
1b52b6519a Merge branches 2026-07-24 15:18:03 +01:00
Sergey Kuznetsov
45e3d3f73e Run pre-commit 2026-07-24 13:42:18 +01:00
Sergey Kuznetsov
fcf376d3a9 Merge develop 2026-07-24 13:37:23 +01:00
Sergey Kuznetsov
ab97f4be20 Fix build and exclue generated code from clang-tidy
chore: Fix clang version in devshell
2026-07-24 13:37:22 +01:00
Mayukha Vadari
974b17f427 Merge branch 'ripple/wasmi' into ripple/wasmi-host-functions 2026-07-23 12:29:03 -04:00
Mayukha Vadari
91de196312 Merge pull request #7834 from XRPLF/ripple/se/hf-merge-july
chore: Merge in from `ripple/wasmi`
2026-07-23 12:28:38 -04:00
Mayukha Vadari
ac71553abe Merge pull request #7851 from kuznetsss/Update_hf-merge-july
chore: Update hf merge july
2026-07-23 11:30:09 -04:00
Mayukha Vadari
9a8ec56d42 Merge branch 'develop' into ripple/wasmi 2026-07-23 11:29:49 -04:00
Sergey Kuznetsov
7a3bd8ace2 Update CI image hashes 2026-07-23 16:06:25 +01:00
Sergey Kuznetsov
012f894b0a Merge develop 2026-07-23 15:29:32 +01:00
Sergey Kuznetsov
cb6dad0bc4 chore: Fix clang-tidy issues 2026-07-23 14:10:40 +01:00
Sergey Kuznetsov
d54ca4cd3b Merge develop 2026-07-23 13:15:00 +01:00
Mayukha Vadari
5a0269aca7 fix test 2026-07-22 18:37:10 -04:00
Mayukha Vadari
0774ca32a9 fix build 2026-07-22 18:05:56 -04:00
Mayukha Vadari
8feb75011e Merge remote-tracking branch 'upstream/ripple/wasmi-host-functions' into ripple/se/hf-merge-july 2026-07-22 16:17:14 -04:00
Mayukha Vadari
088c5b3b03 Merge remote-tracking branch 'upstream/ripple/wasmi' into ripple/se/hf-merge-july 2026-07-22 16:12:12 -04:00
Mayukha Vadari
1c4abe8c5e refactor: Remove WasmNumber class (#7833) 2026-07-22 15:56:33 -04:00
Mayukha Vadari
91a5747bf9 Merge branch 'ripple/wasmi-host-functions' into ripple/se/hf-merge-july 2026-07-21 15:16:33 -04:00
Mayukha Vadari
bc892f0bd3 feat: Change function name from finish to escrow_finish (#7840) 2026-07-21 15:12:36 -04:00
Sergey Kuznetsov
8950530b04 Merge branch 'develop' into ripple/wasmi 2026-07-21 12:39:31 +01:00
Mayukha Vadari
5cbebf3c58 fix clang-tidy issues 2026-07-20 18:23:10 -04:00
Mayukha Vadari
c634c75aee fix build issues 2026-07-20 18:07:42 -04:00
Mayukha Vadari
9801c23bad Merge branch 'ripple/wasmi' of https://github.com/XRPLF/rippled into ripple/wasmi-host-functions 2026-07-20 17:38:18 -04:00
Mayukha Vadari
f6472347e9 Merge branch 'develop' into ripple/wasmi 2026-07-20 17:33:13 -04:00
Sergey Kuznetsov
aeee77f6ec Update docker image 2026-07-20 16:02:06 +01:00
Sergey Kuznetsov
35583fcf97 Merge branch 'develop' into Rust_integration 2026-07-20 15:40:10 +01:00
Sergey Kuznetsov
4277c806e4 Merge branch 'develop' into Rust_integration 2026-07-09 11:42:59 +01:00
Sergey Kuznetsov
9e427ae3ba Merge develop 2026-07-08 14:24:21 +01:00
pwang200
8022fc33cf host function error path refactor (#7639) 2026-07-02 13:35:46 -04:00
Sergey Kuznetsov
465fa8ec4b Add rust to CI 2026-06-18 14:47:29 +01:00
Sergey Kuznetsov
b5990be624 Merge develop 2026-06-18 13:41:05 +01:00
Olek
9767d86de4 Memory transfer limit (#7000)
Count bytes copied across the boundaries (Wasm VM <-> Hostfunctions) and return error if limit reached (1 mb default)
2026-06-17 12:45:01 -04:00
Mayukha Vadari
ca2d999618 refactor: rename host functions (#7338)
Co-authored-by: xrplf-ai-reviewer[bot] <266832837+xrplf-ai-reviewer[bot]@users.noreply.github.com>
2026-06-12 15:53:12 -04:00
Mayukha Vadari
5ee903befc remove wasm engine tests 2026-06-09 17:09:45 -04:00
Olek
d582ae7990 HF one entry point (#7393)
Add one entry point for all HF for centralized exceptions handling, gas calculation and general checks.
Add exception handling for HF
Add FieldLocator object
Switch pointers to references for HF and runtime
Max size for parameters and sfData field is 1 kb now
Fix Allhf unittest, to provide correct locator
2026-06-03 21:53:12 -04:00
Olek
0dbe51c740 Cleanup and some refactoring (#7383) 2026-06-02 21:15:58 -04:00
Olek
63fff4b518 Fix HF tests (#7365) 2026-05-29 17:49:03 -04:00
Mayukha Vadari
d85bf722ea fix: Fix build issues post-clang-tidy changes (#7298) 2026-05-20 13:44:18 -04:00
Mayukha Vadari
b664989cfb fix clang-tidy issues 2026-05-19 15:11:55 -04:00
Mayukha Vadari
e77934302a Merge branch 'ripple/wasmi' of https://github.com/XRPLF/rippled into ripple/wasmi-host-functions 2026-05-19 15:10:21 -04:00
Mayukha Vadari
ef7aeca6bf Merge branch 'develop' into ripple/wasmi 2026-05-18 18:25:09 -04:00
Mayukha Vadari
eec1d29b92 Merge branch 'develop' into ripple/wasmi 2026-05-15 11:36:56 -04:00
pwang200
971ba2281e clarify XLS-0102 host function stability rule (#7146) 2026-05-14 20:18:05 -04:00
pwang200
90357eeae1 bump get_nft host function cost from 1000 to 5000 (#7200) 2026-05-14 18:53:29 -04:00
Sergey Kuznetsov
1dce793acc Merge develop 2026-05-14 12:21:07 +01:00
Olek
597202a6f0 Refactoring float hostfunctions (#7053) 2026-05-07 12:33:22 -04:00
pwang200
1600b3e7f3 ai review nits fixes of host functions (#6963) 2026-04-30 13:56:55 -04:00
Sergey Kuznetsov
5be406e2df Add expample of panic handling 2026-04-29 17:42:29 +01:00
Sergey Kuznetsov
bc483b2a1d Another try to fix windows 2026-04-29 15:49:50 +01:00
Sergey Kuznetsov
175259df28 Try to fix windows 2026-04-29 15:35:34 +01:00
Sergey Kuznetsov
8bb8c3b24b Temporarily disable linkage check 2026-04-29 14:26:38 +01:00
Sergey Kuznetsov
abb2ef3bec Try fixing linkage 2026-04-29 14:13:07 +01:00
Sergey Kuznetsov
d009ef221f More improvements 2026-04-29 13:55:39 +01:00
Sergey Kuznetsov
5fdedd7e99 Minor improvements 2026-04-28 15:23:29 +01:00
Sergey Kuznetsov
ffc55797b2 Add rust interop PoC 2026-04-27 17:47:49 +01:00
Mayukha Vadari
ecee732187 Merge branch 'develop' into ripple/wasmi 2026-04-22 17:22:28 -04:00
Olek
ce2586c039 Review fixes (#6512) 2026-04-20 14:03:39 -04:00
Olek
8cc2169939 test: Calling wrap functions from c++ side (#6699) 2026-04-09 18:48:58 -04:00
Mayukha Vadari
826f613ad8 Merge branch 'ripple/wasmi' of https://github.com/XRPLF/rippled into ripple/wasmi-host-functions 2026-04-08 13:51:09 -04:00
Mayukha Vadari
1259c1d5ca Merge branch 'develop' of https://github.com/XRPLF/rippled into ripple/wasmi 2026-04-08 13:48:41 -04:00
Olek
d2641d85bd New floats format, STAmount compatible (#6600) 2026-04-07 20:19:19 -04:00
Mayukha Vadari
75f66bd9fe fix build 2026-04-07 17:24:48 -04:00
Mayukha Vadari
7cd71cb659 Merge branch 'ripple/wasmi' into ripple/wasmi-host-functions 2026-04-07 16:03:02 -04:00
Mayukha Vadari
9917f96166 Merge remote-tracking branch 'upstream/develop' into ripple/wasmi 2026-04-07 16:02:56 -04:00
Mayukha Vadari
e1cc82587b Merge branch 'ripple/wasmi' of https://github.com/XRPLF/rippled into ripple/wasmi-host-functions 2026-04-07 16:02:32 -04:00
Pratik Mankawde
2cc9439fde fix: Handle WSClient write failure when server closes WebSocket (#6671)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 16:01:26 -04:00
Ayaz Salikhov
52af9582e2 ci: Change conditions for uploading artifacts in public/private/org repos (#6734) 2026-04-07 16:01:26 -04:00
Bart
46e88dc732 refactor: Rename non-functional uses of ripple(d) to xrpl(d) (#6676)
Co-authored-by: Bart <11445373+bthomee@users.noreply.github.com>
2026-04-07 16:01:26 -04:00
Mayukha Vadari
bc24f2e211 refactor: Move more helper files into libxrpl/ledger/helpers (#6731)
Co-authored-by: xrplf-ai-reviewer[bot] <266832837+xrplf-ai-reviewer[bot]@users.noreply.github.com>
2026-04-07 16:01:26 -04:00
Mayukha Vadari
7a7c993b15 fix: Minor RPC fixes (#6730) 2026-04-07 16:01:26 -04:00
Zhiyuan Wang
9733ca8f91 fix: Prevent deletion of MPTokens with active escrow (#6635)
Co-authored-by: Bart <bthomee@users.noreply.github.com>
2026-04-07 16:01:26 -04:00
Vito Tumas
18d5e3e226 fix: Clamp VaultClawback to assetsAvailable for zero-amount clawback (#6646) 2026-04-07 16:01:25 -04:00
Vito Tumas
b30b4e1d65 fix: Add assorted Lending Protocol fixes (#6678)
Co-authored-by: Shawn Xie <35279399+shawnxie999@users.noreply.github.com>
2026-04-07 16:01:25 -04:00
Mayukha Vadari
d435893602 fix: Change variable signedness and correctly handle std::optional (#6657) 2026-04-07 16:01:25 -04:00
Olek
00b0cf50f6 Update wasmi to 1.0.9 (#6727) 2026-04-07 15:58:29 -04:00
Mayukha Vadari
7ef256499c Merge branch 'ripple/wasmi' of https://github.com/XRPLF/rippled into wasmi-host-functions 2026-04-03 09:57:07 -04:00
Mayukha Vadari
1338062be7 Merge branch 'develop' of https://github.com/XRPLF/rippled into ripple/wasmi 2026-04-03 09:56:55 -04:00
Mayukha Vadari
4fc1778ec8 fix clang-tidy issues 2026-04-03 09:56:42 -04:00
Oleksandr
65322d9e78 fix Clang-tidy 2026-04-02 21:33:43 -04:00
Mayukha Vadari
c5598a4284 fix clang-tidy issues 2026-04-02 19:05:34 -04:00
Mayukha Vadari
0deb6bcadf fix build 2026-04-02 18:39:14 -04:00
Mayukha Vadari
9b013b559b Merge branch 'ripple/wasmi' of https://github.com/XRPLF/rippled into wasmi-host-functions 2026-04-02 17:54:32 -04:00
Mayukha Vadari
1d4a3c00b8 Merge branch 'develop' of https://github.com/XRPLF/rippled into ripple/wasmi 2026-04-02 17:53:53 -04:00
Mayukha Vadari
4b34102e8e test: Use proper length limits in codecov_tests (#6626) 2026-03-25 09:10:12 -07:00
Olek
d006433579 Base divison of large fixtures (#6637) 2026-03-25 09:42:33 -04:00
Mayukha Vadari
a7ab8ee923 clang-tidy fixes 2026-03-24 10:22:01 -07:00
Mayukha Vadari
e0073a4402 Merge branch 'ripple/wasmi' into ripple/wasmi-host-functions 2026-03-24 10:20:43 -07:00
Mayukha Vadari
2930ef217f Merge remote-tracking branch 'upstream/develop' into ripple/wasmi 2026-03-24 10:20:38 -07:00
Mayukha Vadari
9dbb301699 more clang-tidy fixes 2026-03-24 10:20:06 -07:00
Mayukha Vadari
531e8b6ebd fix clang-tidy 2026-03-24 09:46:01 -07:00
Mayukha Vadari
90397e1a52 more build fixes 2026-03-24 09:41:08 -07:00
Mayukha Vadari
888ca2e6d9 fix build 2026-03-24 09:29:05 -07:00
Mayukha Vadari
b6514b680f Merge branch 'ripple/wasmi' into ripple/wasmi-host-functions 2026-03-24 08:47:12 -07:00
Mayukha Vadari
913e4b919e Merge remote-tracking branch 'upstream/develop' into ripple/wasmi 2026-03-24 08:41:12 -07:00
Olek
196e6a1b27 Clang-format fixtures.cpp (#6610) 2026-03-20 14:26:26 -04:00
Olek
27468ddbcf Add import / export sections test (#6497) 2026-03-19 12:46:58 -04:00
Mayukha Vadari
bce5d91e45 Merge branch 'develop' into ripple/wasmi 2026-03-12 14:37:01 -04:00
Mayukha Vadari
654338fa66 Merge branch 'develop' into ripple/wasmi 2026-03-06 16:27:50 -04:00
Mayukha Vadari
9c25d18851 Merge branch 'ripple/wasmi' into ripple/wasmi-host-functions 2026-03-05 13:48:33 -04:00
Mayukha Vadari
3a825a41e1 Merge branch 'develop' into ripple/wasmi 2026-03-05 13:48:16 -04:00
Jingchen
a9ebf786c6 Modularise wasm (#6441)
Signed-off-by: JCW <a1q123456@users.noreply.github.com>
2026-03-04 20:21:51 +00:00
Olek
5afe8cc321 Fix clang tidy (#6463)
* Fix clang tidy

* Add exponent overflow test
2026-03-04 11:30:33 -05:00
Mayukha Vadari
bc5ec3c962 assorted fixes (#6376) 2026-03-04 09:30:09 -04:00
Mayukha Vadari
1775251e90 Merge branch 'ripple/wasmi' into ripple/wasmi-host-functions 2026-03-03 11:18:41 -04:00
Mayukha Vadari
61bcb7621f Merge branch 'develop' into ripple/wasmi 2026-03-03 11:18:26 -04:00
Mayukha Vadari
a3f71b1774 Merge branch 'develop' into ripple/wasmi 2026-03-02 17:06:17 -05:00
Mayukha Vadari
4df7d1a4bb rename variable 2026-03-02 16:48:02 -04:00
Mayukha Vadari
125df7a425 Merge remote-tracking branch 'upstream/ripple/wasmi' into wasmi-host-functions 2026-02-27 16:46:43 -05:00
Mayukha Vadari
b08bcf5d21 Merge branch 'develop' into ripple/wasmi 2026-02-27 16:41:44 -05:00
Mayukha Vadari
dc413aef0c Merge branch 'ripple/wasmi' into ripple/wasmi-host-functions 2026-02-27 16:28:34 -05:00
Mayukha Vadari
77dfd56ace Merge branch 'develop' into ripple/wasmi 2026-02-27 13:49:25 -05:00
Olek
953b9a3500 Disable reusing wasm module (#6364)
* Remove ability to re-use wasm module

* Check that HFS object is always new

* Fix clang format

* Remove perf tests

* temp build fix

* Fix merge
2026-02-26 15:30:46 -05:00
Olek
1d9ec84350 Test invalid opcodes (#6392) 2026-02-26 09:59:30 -05:00
Olek
0392846a17 UT for wasm parameters (#6413) 2026-02-25 11:49:27 -05:00
Mayukha Vadari
1b4a564369 fix build issues 2026-02-18 13:20:29 -05:00
Mayukha Vadari
fd524c4be9 fix pre-commit 2026-02-18 12:41:56 -05:00
Mayukha Vadari
495dda7f58 Merge branch 'ripple/wasmi' into ripple/wasmi-host-functions 2026-02-18 12:36:07 -05:00
Mayukha Vadari
9c3c0280b1 Merge branch 'develop' into ripple/wasmi 2026-02-18 12:35:51 -05:00
Mayukha Vadari
f73d8a6cf2 clean up some hf code (#6354)
* clean up some hf code

* fix comments

* fix ubsan

* Revert "fix ubsan"
2026-02-13 11:27:50 -05:00
Olek
6728ab52b7 Add tests for wasm functions with many parameters (#6343)
* Add functions with many parameters

* Add 10k locals function

* Module with  5k functions

* fix typo

Co-authored-by: Mayukha Vadari <mvadari@gmail.com>

---------

Co-authored-by: Mayukha Vadari <mvadari@gmail.com>
2026-02-10 18:10:33 -05:00
Mayukha Vadari
77673663ca fix cspell issues in tests (#6348) 2026-02-10 17:42:41 -05:00
Mayukha Vadari
c1381f8ddd Merge branch 'ripple/wasmi' into wasmi-host-functions 2026-02-10 17:27:18 -05:00
Mayukha Vadari
bd16f7989d Merge branch 'develop' into ripple/wasmi 2026-02-10 17:26:33 -05:00
Mayukha Vadari
65f9cf80c0 add readme to src/xrpld/app/wasm (#6340)
* add readme to src/xrpl/app/wasm

* important block

* respond to copilot
2026-02-09 12:13:39 -05:00
Mayukha Vadari
de55a5ebfc Merge branch 'ripple/wasmi' into wasmi-host-functions 2026-02-04 18:13:15 -05:00
Mayukha Vadari
2ec4a1114e Merge branch 'develop' into ripple/wasmi 2026-02-04 18:13:00 -05:00
Olek
ba03a8a9d2 Fix negation of int64_t (#6296) 2026-02-03 17:43:54 -05:00
Mayukha Vadari
7c8279ec83 use buffers for uint32 WASM params (#6291) 2026-02-03 16:08:46 -05:00
Mayukha Vadari
0418ffb26a Merge branch 'ripple/wasmi' into wasmi-host-functions 2026-02-03 14:52:16 -05:00
Mayukha Vadari
b2627039f6 Merge branch 'develop' into ripple/wasmi 2026-02-03 14:51:59 -05:00
Mayukha Vadari
8f97ec3bde Merge branch 'ripple/wasmi' into wasmi-host-functions 2026-01-29 13:54:30 -05:00
Mayukha Vadari
e85e7b1b1a Merge branch 'develop' into ripple/wasmi 2026-01-29 13:53:55 -05:00
Mayukha Vadari
803a344c65 fix clang-format 2026-01-28 16:35:02 -05:00
Mayukha Vadari
4eb34f381a Merge branch 'ripple/wasmi' into wasmi-host-functions 2026-01-28 15:56:40 -05:00
Mayukha Vadari
72fffb6e51 Merge branch 'develop' into ripple/wasmi 2026-01-28 15:56:18 -05:00
Mayukha Vadari
f7ee580f01 Merge commit '5f638f55536def0d88b970d1018a465a238e55f4' into ripple/wasmi 2026-01-28 15:56:11 -05:00
Mayukha Vadari
122d405750 Merge commit '92046785d1fea5f9efe5a770d636792ea6cab78b' into ripple/wasmi 2026-01-28 15:56:04 -05:00
Olek
c1c1b4ea67 Reject non-canonical binaries (#6277)
* Reject non-canonical binaries

* Review fixes

* Cleanup Number2 class

* Use enum instead of 0
2026-01-27 16:30:51 -05:00
Mayukha Vadari
977caea0a5 Merge branch 'ripple/wasmi' into ripple/wasmi-host-functions 2026-01-27 13:26:55 -05:00
Mayukha Vadari
d7ed6d6512 Merge branch 'develop' into ripple/wasmi 2026-01-27 13:26:39 -05:00
Olek
f1f2e2629f Fix for Big-Endian machines (#6245) 2026-01-27 13:05:54 -05:00
Olek
917c610f96 Ensure request size less than int limit (#6239)
* Ensure request size less than int limit

* Move size check to wasmParams function
2026-01-27 12:37:47 -05:00
Mayukha Vadari
317e533d81 clean up Wasm_test.cpp more (#6278) 2026-01-26 15:21:15 -05:00
Olek
4160677878 Switch to series expansion method for ln() (#6268)
* Switch to series expansion method for ln()
Add float lg() tests to Number tests;
* Rename lg -> log10
* Add check for 0 to log10()
2026-01-26 14:04:03 -05:00
Olek
df98db1452 Check wasm return type (#6240)
* Check wasm return type

* Add more tests
2026-01-23 16:12:14 -05:00
Mayukha Vadari
673476ef1b Merge branch 'ripple/wasmi' into ripple/wasmi-host-functions 2026-01-23 13:13:26 -05:00
Mayukha Vadari
8bc6f9cd70 Merge branch 'develop' into ripple/wasmi 2026-01-23 13:13:11 -05:00
Mayukha Vadari
ba5debfecd update return calculation (#6250) 2026-01-22 17:01:56 -05:00
Mayukha Vadari
f4a27c9b6d minor refactor of Wasm_test (#6229) 2026-01-21 18:05:48 -05:00
Olek
fd1cb318e3 Check that max parameters length is multiple of sizeof(int32) (#6253) 2026-01-21 17:22:47 -05:00
Mayukha Vadari
8c3544a58c Merge branch 'ripple/wasmi' into wasmi-host-functions 2026-01-21 12:57:47 -05:00
Mayukha Vadari
ed5139d4e3 Merge branch 'develop' into ripple/wasmi 2026-01-21 12:57:29 -05:00
Olek
42494dd4cf Ensure lifetime of imports (#6230) 2026-01-21 12:43:12 -05:00
Mayukha Vadari
ce84cc8b44 improve trace hf code (#6190)
* adjust trace statements

* add helper function

* use lambda instead

* use same paradigm in TestHostFunctions

* oops
2026-01-15 20:50:55 -05:00
Mayukha Vadari
9a9a7aab01 Add Vector256 support to the locator (#6131)
* add Vector256 nesting/length support

* [WIP] add tests

* fix tests

* simplify with helper function

* oops typo

* remove static variable

* respond to comments

* STBaseOrUInt256->FieldValue

* oops

* add more tests for coverage

* respond to comments
2026-01-15 20:14:42 -05:00
Olek
209a1a6ffa Don't throw from hostfunctions stack (#6221) 2026-01-15 19:52:22 -05:00
Oleksandr
fc35a9f9c8 Fix usage of the Number class 2026-01-14 19:36:50 -05:00
Oleksandr
c5e50aa221 Fix merge issues 2026-01-14 14:46:35 -05:00
Mayukha Vadari
074b1f00d5 Merge branch 'ripple/wasmi' into wasmi-host-functions 2026-01-14 13:04:28 -05:00
Mayukha Vadari
7a9d245950 Merge branch 'develop' into ripple/wasmi 2026-01-14 13:01:35 -05:00
Mayukha Vadari
1809fe07f2 remove test file 2026-01-14 12:43:12 -05:00
Mayukha Vadari
409c67494a move helper functions to separate file (#6178)
* move helper functions to separate file

* break it up into sections, split out float helpers

* split impls into multiple cpp files

* namespace detail

* fix build issue

* fix tests

* clean up

* put float helpers into wasm_float namespace
2026-01-13 20:34:57 -05:00
Olek
c626b6403a Fix unaligned access (#6208) 2026-01-13 16:40:42 -05:00
Olek
81cbc91927 Fix traces (#6127)
* Fix traces
* More tests for codecov
* Review fixes
* trace float test
* Fix return value for traces
* Remove SuiteJournalSink2
* Add explicit severity
* Move logs to ApplyView
* Add check for output strings
* Merging fix
2026-01-13 16:38:48 -05:00
pwang200
1c812a6c4d disable Wasm features added in Wasmi 1.0, and fix unit test fuel cost due to Wasmi 1.0 fuel changes (#6173)
* disable 4 more wasm features

* unit tests for disabled Wasmi 1.0 features

* fix unit tests failed due to fuel changes

* rearrange wasm feature unit tests

* fix gas costs

* Update src/test/app/wasm_fixtures/wat/custom_page_sizes.wat

---------

Co-authored-by: Mayukha Vadari <mvadari@ripple.com>
2026-01-12 22:04:33 -05:00
Mayukha Vadari
0724927799 Merge branch 'ripple/wasmi' into ripple/wasmi-host-functions 2026-01-12 15:17:36 -05:00
Olek
d83ec96848 Switch to wasmi v1.0.6 (#6204) 2026-01-12 13:36:02 -05:00
Mayukha Vadari
375dd50b35 Merge branch 'ripple/wasmi' into ripple/wasmi-host-functions 2026-01-12 13:19:17 -05:00
Mayukha Vadari
419d53ec4c Merge branch 'develop' into ripple/wasmi 2026-01-12 13:10:58 -05:00
Mayukha Vadari
d4d70d5675 Merge branch 'develop' into ripple/wasmi 2026-01-12 12:27:48 -05:00
Olek
6ab15f8377 Add checks to allocate (#6185) 2026-01-09 14:49:09 -05:00
pwang200
91f3d51f3d fix start function loop 2026-01-09 11:38:54 -05:00
pwang200
9ed60b45f8 section corruption unit tests 2026-01-08 16:15:36 -05:00
pwang200
d5c53dcfd2 fix Uninitialized import entries lead to undefined behavior During WASM Instantiation 2026-01-08 16:14:49 -05:00
Mayukha Vadari
e94321fb41 Merge branch 'ripple/wasmi' into wasmi-host-functions 2026-01-08 11:44:15 -05:00
Mayukha Vadari
bbc28b3b1c Merge branch 'develop' into ripple/wasmi 2026-01-08 11:42:28 -05:00
Mayukha Vadari
843e981c8a Merge remote-tracking branch 'upstream/ripple/wasmi' into wasmi-host-functions 2026-01-07 16:52:56 -05:00
Mayukha Vadari
5aab274b7a Merge branch 'develop' into ripple/wasmi 2026-01-07 16:52:10 -05:00
Mayukha Vadari
2c30e41191 use the develop hashes 2026-01-07 16:50:45 -05:00
Mayukha Vadari
8ea5106b0b Merge branch 'develop' into ripple/wasmi 2026-01-07 14:34:49 -05:00
Mayukha Vadari
f57f67a8ae infinite loop test (#6064) 2026-01-07 11:51:58 -05:00
pwang200
a98269f049 a batch of memory, table, and trap tests (#6100)
wasm memory, table, and trap unit tests
2026-01-06 14:03:18 -05:00
Mayukha Vadari
b66bc47ca9 fix more merge issues 2026-01-06 13:30:30 -05:00
Mayukha Vadari
0e9c7458bb fix more merge issues 2026-01-05 18:53:14 -05:00
Mayukha Vadari
1d89940653 merge fixes 2026-01-05 18:48:09 -05:00
Mayukha Vadari
1a1a6806ec Merge branch 'ripple/wasmi' into ripple/wasmi-host-functions 2026-01-05 18:44:41 -05:00
Mayukha Vadari
1977df9c2e Merge remote-tracking branch 'upstream/develop' into ripple/wasmi 2026-01-05 18:43:49 -05:00
Mayukha Vadari
6c95548df5 Merge remote-tracking branch 'upstream/develop' into ripple/wasmi 2025-12-22 15:51:19 -08:00
Olek
69ab39d658 Fix potential memory leaks found by srlabs (#6145) 2025-12-18 14:13:48 -05:00
Mayukha Vadari
b9eb66eecc fix parameter index desynchronization (#6148) 2025-12-17 14:19:34 -08:00
Mayukha Vadari
881087dd3d Merge remote-tracking branch 'upstream/ripple/wasmi' into wasmi-host-functions 2025-12-08 14:29:47 -05:00
Mayukha Vadari
90e0bbd0fc Merge branch 'develop' into ripple/wasmi 2025-12-08 14:28:41 -05:00
Olek
b57df290de Use conan repo for wasmi lib (#6109)
* Use conan repo for wasmi lib
* Generate lockfile
2025-12-08 13:02:01 -05:00
Mayukha Vadari
8a403f1241 Merge branch 'develop' into ripple/wasmi 2025-12-05 14:32:48 -05:00
Mayukha Vadari
6d2640871d Merge branch 'develop' into ripple/wasmi 2025-12-02 18:40:54 -05:00
pwang200
c145598ff9 add memory limit and disable float and other advanced instructions 2025-12-02 00:09:20 -05:00
Olek
50e5608d86 wasmi HF cost 2025-12-01 20:21:52 -05:00
Mayukha Vadari
7a7b96107c Merge branch 'ripple/wasmi' into ripple/wasmi-host-functions 2025-11-25 03:42:05 +05:30
Olek
500bb68831 Fix win build (#6076) 2025-11-24 16:56:23 -05:00
Mayukha Vadari
53eb0f60bc fix another build issue 2025-11-25 03:10:58 +05:30
Mayukha Vadari
41205ae928 Merge branch 'ripple/wasmi' into wasmi-host-functions 2025-11-25 03:01:51 +05:30
Mayukha Vadari
c33b0ae463 fix build issue 2025-11-25 02:58:57 +05:30
Mayukha Vadari
16087c9680 fix merge issue 2025-11-25 02:57:47 +05:30
Mayukha Vadari
56bc6d58f6 Merge branch 'ripple/wasmi' into wasmi-host-functions 2025-11-25 02:45:00 +05:30
Mayukha Vadari
ef5d335e09 update 2025-11-25 02:44:18 +05:30
Mayukha Vadari
25c3060fef remove conan.lock (temporary) 2025-11-25 02:40:57 +05:30
Mayukha Vadari
ce9f0b38a4 Merge branch 'develop' into ripple/wasmi 2025-11-25 02:33:47 +05:30
Mayukha Vadari
35f7cbf772 update 2025-11-25 02:31:51 +05:30
Mayukha Vadari
0db564d261 WASMI data 2025-11-04 15:57:07 -05:00
Mayukha Vadari
427b7ea104 run rename script 2025-11-04 15:29:08 -05:00
Mayukha Vadari
7bf6878b4b fix imports 2025-11-04 14:49:45 -05:00
Mayukha Vadari
0bc1a115ff Merge branch 'wamr' into wamr-host-functions 2025-11-04 13:36:22 -05:00
Mayukha Vadari
334bcfa5ef Merge branch 'develop' into wamr 2025-11-04 13:36:01 -05:00
Mayukha Vadari
106dea4559 update fixtures to use the latest version of stdlib 2025-11-04 13:35:25 -05:00
Mayukha Vadari
3ffdcf8114 allow 0-value trace amounts 2025-11-04 13:19:40 -05:00
Olek
4021a7eb28 Wamr and HF security review fixes (#5965) 2025-10-31 10:34:31 -04:00
Ayaz Salikhov
0690fda0f1 Merge branch 'develop' into ripple/wamr 2025-10-30 14:12:15 +00:00
Mayukha Vadari
d0cc48c6d3 Update cmake/RippledCore.cmake
Co-authored-by: Ayaz Salikhov <mathbunnyru@users.noreply.github.com>
2025-10-29 16:41:11 -04:00
Olek
d66e3c949e Chores: Sort package list (#5963) 2025-10-29 12:55:07 -04:00
Mayukha Vadari
0c65a386b5 fix tests 2025-10-24 18:01:01 -04:00
Mayukha Vadari
29f5430881 fix bug 2025-10-24 16:05:38 -04:00
Mayukha Vadari
101f285bcd return size from updateData 2025-10-24 16:01:45 -04:00
Mayukha Vadari
286dc6322b Merge branch 'ripple/wamr' into ripple/wamr-host-functions 2025-10-23 15:38:28 -04:00
Mayukha Vadari
c9346cd40d Merge branch 'develop' into ripple/wamr 2025-10-23 15:38:04 -04:00
Mayukha Vadari
1c5683ec78 Merge branch 'ripple/wamr' into ripple/wamr-host-functions 2025-10-20 11:53:22 -04:00
Mayukha Vadari
9bee155d59 Merge branch 'develop' into ripple/wamr 2025-10-20 11:53:03 -04:00
Mayukha Vadari
f34b05f4de Merge branch 'ripple/wamr' into ripple/wamr-host-functions 2025-10-16 12:12:05 -04:00
Mayukha Vadari
97ce25f4ce Merge branch 'develop' into ripple/wamr 2025-10-16 12:11:55 -04:00
Olek
9e14c14a26 Use xrplf conan repo for wamr (#5862) 2025-10-13 15:11:21 -04:00
Mayukha Vadari
c507880d8f Merge branch 'ripple/wamr' into ripple/wamr-host-functions 2025-10-13 13:57:22 -04:00
Mayukha Vadari
3f8328bbf8 Merge branch 'develop' into ripple/wamr 2025-10-13 13:55:07 -04:00
Mayukha Vadari
c10a5f9ef6 Merge branch 'ripple/wamr' into ripple/wamr-host-functions 2025-10-09 17:10:31 -04:00
Mayukha Vadari
3c141de695 Merge branch 'develop' into ripple/wamr 2025-10-09 16:52:25 -04:00
Mayukha Vadari
da2b9455f2 fix: remove get_ledger_account_hash and get_ledger_tx_hash host functions (#5850)
* remove `get_ledger_account_hash` and `get_ledger_tx_hash`

* fix build+tests
2025-10-06 16:38:40 -04:00
Mayukha Vadari
cb622488c0 Merge branch 'ripple/wamr' into ripple/wamr-host-functions 2025-10-02 14:35:25 -04:00
Mayukha Vadari
32f971fec6 Merge branch 'develop' into ripple/wamr 2025-10-02 14:35:13 -04:00
Mayukha Vadari
8dea76baa4 Merge branch 'ripple/wamr' into ripple/wamr-host-functions 2025-09-30 14:42:49 -04:00
Mayukha Vadari
299fbe04c4 Merge branch 'develop' into ripple/wamr 2025-09-30 14:42:24 -04:00
Mayukha Vadari
57fc1df7d7 switch from wasm32-unknown-unknown to wasm32v1-none (#5814) 2025-09-29 15:43:22 -04:00
Mayukha Vadari
eaba76f9e6 Merge branch 'ripple/wamr' into ripple/wamr-host-functions 2025-09-26 16:37:25 -04:00
Mayukha Vadari
cb702cc238 Merge branch 'develop' into ripple/wamr 2025-09-26 16:37:04 -04:00
Mayukha Vadari
b69b4a0a4a Merge branch 'ripple/wamr' into ripple/wamr-host-functions 2025-09-26 15:51:48 -04:00
Mayukha Vadari
50d6072a73 Merge branch 'develop' into ripple/wamr 2025-09-26 15:51:40 -04:00
Olek
d24cd50e61 Switch to own wamr fork (#5808) 2025-09-23 16:39:21 -04:00
Mayukha Vadari
9f5875158c Merge branch 'ripple/wamr' into ripple/wamr-host-functions 2025-09-22 18:23:45 -04:00
Mayukha Vadari
c3dc33c861 Merge branch 'develop' into ripple/wamr 2025-09-22 18:23:35 -04:00
Olek
6be8f2124c Latests HF perf test (#5789) 2025-09-18 15:51:39 -04:00
Mayukha Vadari
edfed06001 fix merge issues 2025-09-18 15:39:49 -04:00
Mayukha Vadari
1c646dba91 Merge remote-tracking branch 'upstream/ripple/wamr' into wamr-host-functions 2025-09-18 15:29:02 -04:00
Mayukha Vadari
6781068058 Merge branch 'develop' into ripple/wamr 2025-09-18 15:27:54 -04:00
Mayukha Vadari
cfe57c1dfe Merge branch 'ripple/wamr' into ripple/wamr-host-functions 2025-09-18 14:37:58 -04:00
Mayukha Vadari
c34d09a971 Merge branch 'develop' into ripple/wamr 2025-09-18 14:24:34 -04:00
Mayukha Vadari
ebd90c4742 chore: remove unneeded float stuff (#5729) 2025-09-11 18:41:24 -04:00
Mayukha Vadari
ba52d34828 test: improve codecov in HostFuncWrapper.cpp (#5730) 2025-09-11 18:09:08 -04:00
Mayukha Vadari
1b6312afb3 rearrange files 2025-09-11 16:34:03 -04:00
Mayukha Vadari
bf32dc2e72 add fixtures files 2025-09-11 16:28:11 -04:00
Mayukha Vadari
a15d65f7a2 update tests 2025-09-11 16:20:33 -04:00
Mayukha Vadari
2de8488855 add temBAD_WASM 2025-09-11 16:02:17 -04:00
Mayukha Vadari
129aa4bfaa bring out IOUAmount.h 2025-09-11 13:18:42 -04:00
Mayukha Vadari
b1d70db63b limits 2025-09-10 15:05:06 -04:00
Mayukha Vadari
f03c3aafe4 misc host function files 2025-09-10 15:02:48 -04:00
Mayukha Vadari
51a9f106d1 CODEOWNERS 2025-09-10 14:59:09 -04:00
Mayukha Vadari
bfc048e3fe add tests 2025-09-10 14:57:23 -04:00
Mayukha Vadari
83418644f7 add host functions 2025-09-10 14:56:21 -04:00
Mayukha Vadari
dbc9dd5bfc Add WAMR integration code 2025-09-10 14:56:08 -04:00
Mayukha Vadari
45ab15d4b5 add WAMR dependency 2025-09-10 14:40:48 -04:00
283 changed files with 34097 additions and 105 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -87,7 +87,6 @@ jobs:
-Dwerr=ON \
-Dxrpld=ON \
-Dverify_headers=ON \
-Drust=ON \
..
- name: Build clang-tidy prerequisites

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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"

View File

@@ -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(

View File

@@ -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

View File

@@ -152,8 +152,12 @@ class Xrpl(ConanFile):
"CMakeLists.txt",
"cfg/*",
"cmake/*",
"crates/*",
"crates/.cargo/*",
"!crates/target/*",
"external/*",
"include/*",
"rust-toolchain.toml",
"src/*",
)

View File

@@ -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
View File

@@ -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",
]

View File

@@ -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"

View File

@@ -1,10 +0,0 @@
[package]
name = "rs-hello_world"
version = "0.1.0"
edition.workspace = true
[lib]
crate-type = ["staticlib"]
[dependencies]
cxx.workspace = true

View File

@@ -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()
}

View 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"

View 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
})
}

View 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:?}"
);
}
}

View 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:?}"
);
}
}

View 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"

View 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>;
}

View 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,
}
}
}
};
}

View 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));
}

File diff suppressed because it is too large Load Diff

View 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}"
);
}
}

View 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" }

View 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"
);
}
}

View 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" }

File diff suppressed because it is too large Load Diff

View 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"

View 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);
}
}

View 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,
};

View 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"
);
}
}

View 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)
}
}

File diff suppressed because it is too large Load Diff

View 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");
}
}

View 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");
}

File diff suppressed because it is too large Load Diff

View 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");
}

View 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(_));
}
}

File diff suppressed because it is too large Load Diff

View 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(_));
}

View File

@@ -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
View File

@@ -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.

View File

@@ -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

View File

@@ -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.
*/

View File

@@ -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,
};
//------------------------------------------------------------------------------

View 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

View 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

View 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

View 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.

View 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

View 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

View File

@@ -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()

View File

@@ -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."),

File diff suppressed because it is too large Load Diff

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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)

View File

@@ -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");
}

View File

@@ -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&

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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 |

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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

View 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

View 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

View 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