Compare commits

..

229 Commits

Author SHA1 Message Date
Sergey Kuznetsov
cd8d68d4ae feat: Report all errors in wasm code check (#8225) 2026-09-16 16:03:23 +01:00
Mayukha Vadari
824997df60 feat: Add sponsorship, loanBroker, and loan keylet host functions (#8195) 2026-09-15 12:40:32 -04:00
Sergey Kuznetsov
becfa96f64 Merge pull request #8212 from kuznetsss/Merge_develop
chore: Merge develop, fix pre-commit, update docs
2026-09-14 14:54:22 +01:00
Sergey Kuznetsov
2841b795f6 Merge ripple/smart-escrow 2026-09-14 13:58:32 +01:00
Timothy Banks
ba968988fe Merge pull request #8201 from XRPLF/timothybanks/port-smart-escrow-tests
fix: Port smart escrow tests to new design
2026-09-11 09:30:10 -04:00
TimothyBanks
d6757ce1a5 chore: Address code review comments 2026-09-10 13:07:34 -04:00
Sergey Kuznetsov
b68a2276f5 Comment test to make clang-tidy happy 2026-09-10 17:52:50 +01:00
Sergey Kuznetsov
4eac5dbd3b Fix coverage flags. Update AGENTS.md. Add crates/README.md 2026-09-10 16:28:50 +01:00
Sergey Kuznetsov
97b272be23 Merge develop 2026-09-10 16:10:13 +01:00
TimothyBanks
09c4e35c51 chore: Address code review comments 2026-09-10 09:40:55 -04:00
Sergey Kuznetsov
646c7ed494 refactor: Generate code to register host functions in wasm vm (#8206) 2026-09-10 11:27:40 +01:00
TimothyBanks
e1b1a010f5 fix: Address review comments 2026-09-09 11:53:38 -04:00
TimothyBanks
2d7e92ffc5 fix: Address review comments 2026-09-09 11:45:50 -04:00
TimothyBanks
c9b365315f fix: Port remaining escrow wasm tests 2026-09-09 10:41:34 -04:00
TimothyBanks
c1133a7443 fix: Port remaining escrow wasm tests 2026-09-09 10:04:37 -04:00
TimothyBanks
dd09ec2c2f fix: Port smart escrow tests to new design 2026-09-08 21:34:23 -04:00
TimothyBanks
a4d1bd1a55 fix: Port smart escrow tests to new design 2026-09-08 20:58:16 -04:00
TimothyBanks
4aa695f7d9 chore: Merge upstream branch 2026-09-08 15:54:17 -04:00
TimothyBanks
c9f7e39822 Merge branch 'ripple/se/wasmi-tests' into ripple/se/fees 2026-09-08 14:33:44 -04:00
TimothyBanks
9867cc295e chore: Merge upstream branch 2026-09-08 14:27:33 -04:00
Timothy Banks
0ba8b306e3 Merge pull request #8189 from XRPLF/timothybanks/upgrade-to-wasmi-2
chore: Upgrade to Wasmi 2.0
2026-09-08 10:34:47 -04:00
TimothyBanks
daecad43c9 chore: Upgrade to Wasmi 2.0 2026-09-08 09:58:32 -04:00
Timothy Banks
e9074726cd Merge pull request #8114 from XRPLF/timothybanks/port-wat-based-tests
feat: Port wat based wasm tests to new wasm design
2026-09-08 09:31:01 -04:00
TimothyBanks
4208f14a91 fix: Add additional vm benchmarks 2026-09-03 20:47:09 -04:00
TimothyBanks
491b930ebb chore: Make Calibration type a non singleton 2026-09-03 12:08:40 -04:00
TimothyBanks
422b8d0a43 chore: Address review comments 2026-09-02 18:49:20 -04:00
TimothyBanks
a1ea8d3a6e chore: Address review comments 2026-09-02 17:03:47 -04:00
Sergey Kuznetsov
c061cdb49e Merge pull request #8138 from kuznetsss/Update-ripple/smart-escrow
chore: Update ripple/smart escrow
2026-09-02 14:44:27 +01:00
Sergey Kuznetsov
5f41471b7d Merge ripple/se/fees and resolve conflicts 2026-09-02 13:38:38 +01:00
Sergey Kuznetsov
2e78c260a9 Merge ripple/se/wasmi-tests 2026-09-02 13:17:13 +01:00
Sergey Kuznetsov
05a81b3974 Merge ripple/wasmi-host-functions 2026-09-02 13:15:31 +01:00
Sergey Kuznetsov
ae87e88690 refactor: Use temINVALID_BYTECODE istead of temBAD_WASM (#8156) 2026-09-02 11:41:36 +01:00
TimothyBanks
279ef458c8 chore: Addressing code review comments 2026-09-01 16:06:13 -04:00
TimothyBanks
f9527b90da chore: Addressing code review comments 2026-09-01 15:25:00 -04:00
TimothyBanks
0ffeb86491 chore: Merge upstream branch 2026-09-01 13:41:40 -04:00
TimothyBanks
549d0f5757 chore: Addressing code review code comments 2026-09-01 12:45:07 -04:00
TimothyBanks
fe38635dd0 chore: Addressing code review code comments 2026-09-01 12:31:03 -04:00
Mayukha Vadari
74ec538296 feat: Remove float_root (#8110) 2026-08-31 11:19:39 -04:00
Sergey Kuznetsov
b050dbba83 Comment out broken tests 2026-08-28 18:00:10 +01:00
Sergey Kuznetsov
86c781f945 Merge ripple/se/fees and resolve conflicts 2026-08-28 18:00:10 +01:00
Sergey Kuznetsov
05a1497ebc Merge ripple/se/wasmi-tests 2026-08-28 13:30:45 +01:00
Sergey Kuznetsov
5e48fe0b33 Merge ripple/wasmi-host-functions 2026-08-28 12:58:47 +01:00
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
Sergey Kuznetsov
76aa7b1910 Merge ripple/wasmi 2026-08-26 17:37:11 +01:00
Sergey Kuznetsov
222ca8f3ce Merge develop 2026-08-26 16:31:36 +01: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
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
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
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
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
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
Sergey Kuznetsov
1dce793acc Merge develop 2026-05-14 12:21:07 +01: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
953 changed files with 37709 additions and 158876 deletions

View File

@@ -7,8 +7,6 @@ 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)
@@ -45,7 +43,6 @@ suggestWords:
- synched->synced
- synch->sync
words:
- cusip
- abempty
- AMMID
- AMMMPT
@@ -73,9 +70,7 @@ words:
- canonicality
- cdylib
- canonicalised
- canonicality
- cctools
- CGNAT
- changespq
- checkme
- choco
@@ -111,6 +106,7 @@ words:
- deleteme
- demultiplexer
- deserializaton
- desugars
- desync
- desynced
- determ
@@ -120,7 +116,7 @@ words:
- dsymutil
- dxrpl
- elgamal
- emittance
- enabled
- enablerepo
- endmacro
- envrc
@@ -137,6 +133,7 @@ words:
- gcov
- gcovr
- ghead
- gmock
- Gnutella
- godexsoft
- gpgcheck
@@ -146,7 +143,9 @@ words:
- hwaddress
- hwrap
- ifndef
- impls
- inequation
- initialiser
- insuf
- insuff
- invasively
@@ -183,11 +182,11 @@ words:
- mathbunnyru
- mcmodel
- MEMORYSTATUSEX
- MPTAMM
- MPTDEX
- Merkle
- misprediction
- mispricing
- missingok
- MPTAMM
- mptbalance
- MPTDEX
- mptflags
@@ -256,14 +255,15 @@ words:
- pyparsing
- qalloc
- qbsprofile
- qself
- queuable
- Raphson
- rcflags
- replayer
- repodata
- repomd
- rerandomization
- rerandomize
- rerandomization
- rerandomized
- rerandomizes
- rerere
@@ -286,8 +286,8 @@ words:
- rustup
- sahyadri
- Satoshi
- Schnorr
- scons
- Schnorr
- secp
- sendq
- seqit
@@ -312,7 +312,6 @@ words:
- statsd
- STATSDCOLLECTOR
- stissue
- stjson
- stnum
- stnumber
- stobj
@@ -341,8 +340,8 @@ words:
- txn
- txns
- txs
- UBSAN
- ubsan
- UBSAN
- ufdio
- umant
- unacquired
@@ -354,7 +353,6 @@ words:
- unfindable
- unflatten
- unfund
- ungated
- unimpair
- unmetered
- unroutable
@@ -377,12 +375,13 @@ words:
- vfalco
- vinnie
- wasmi
- wasmparser
- Werror
- wextra
- wptr
- writeme
- wsrch
- wthread
- Xahau
- xbridge
- xchain
- xcrun
@@ -397,3 +396,5 @@ words:
- xxhash
- xxhasher
- zstdio
- CGNAT
- ungated

2
.github/CODEOWNERS vendored
View File

@@ -1,2 +0,0 @@
# Allow anyone to review any change by default.
*

View File

@@ -1,6 +1,9 @@
benchmarks.libxrpl > xrpl.basics
benchmarks.libxrpl > xrpl.config
benchmarks.libxrpl > xrpl.nodestore
benchmarks.libxrpl > xrpl.protocol
benchmarks.libxrpl > xrpl.protocol_autogen
benchmarks.libxrpl > xrpl.tx
libxrpl.basics > xrpl.basics
libxrpl.conditions > xrpl.basics
libxrpl.conditions > xrpl.conditions
@@ -19,7 +22,6 @@ libxrpl.ledger > xrpl.json
libxrpl.ledger > xrpl.ledger
libxrpl.ledger > xrpl.nodestore
libxrpl.ledger > xrpl.protocol
libxrpl.ledger > xrpl.server
libxrpl.ledger > xrpl.shamap
libxrpl.net > xrpl.basics
libxrpl.net > xrpl.net
@@ -207,7 +209,6 @@ xrpl.core > xrpl.json
xrpl.core > xrpl.protocol
xrpl.json > xrpl.basics
xrpl.ledger > xrpl.basics
xrpl.ledger > xrpl.core
xrpl.ledger > xrpl.json
xrpl.ledger > xrpl.nodestore
xrpl.ledger > xrpl.protocol

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.
@@ -81,8 +80,6 @@ class LinuxConfig:
suffix: str = ""
extra_cmake_args: str = ""
package: PackageConfig | None = None # set to also package this config
# Flip every amendment to Supported::Yes before building (perf/test only).
force_supported: bool = False
def __post_init__(self) -> None:
if isinstance(self.package, dict):
@@ -170,7 +167,6 @@ class MatrixEntry:
image: str = "" # container image; empty for macOS/Windows (runs natively)
compiler: str = "" # compiler name ("gcc" or "clang"); empty for macOS/Windows
toolchain: str = "" # "nix" for the flake's CI environment; see PlatformConfig
force_supported: bool = False # flip amendments to Supported::Yes before build
@dataclasses.dataclass
@@ -236,7 +232,6 @@ def expand_linux_matrix(linux: LinuxFile, minimal: bool) -> list[MatrixEntry]:
architecture=arch_info,
sanitizers=sanitizer,
compiler=compiler,
force_supported=cfg.force_supported,
)
)

View File

@@ -17,6 +17,7 @@
"minimal": true,
"benchmark": true
},
{
"compiler": ["gcc"],
"build_type": ["Release"],
@@ -29,6 +30,7 @@
"arch": ["arm64"],
"minimal": false
},
{
"compiler": ["gcc", "clang"],
"build_type": ["Debug", "Release"],
@@ -36,6 +38,7 @@
"minimal": false,
"sanitizers": ["address", "undefinedbehavior"]
},
{
"compiler": ["clang"],
"build_type": ["Debug"],
@@ -59,21 +62,9 @@
"minimal": false,
"suffix": "unity",
"extra_cmake_args": "-Dunity=ON"
},
{
"compiler": ["gcc"],
"build_type": ["Release"],
"arch": ["amd64"],
"minimal": false,
"suffix": "supported",
"force_supported": true,
"extra_cmake_args": "-Dvalidator_keys=ON",
"package": {
"type": "deb",
"image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-49cdc10"
}
}
],
"debian": [
{
"compiler": ["gcc"],
@@ -87,6 +78,7 @@
}
}
],
"rhel": [
{
"compiler": ["gcc"],

View File

@@ -1,103 +0,0 @@
# Package the "all amendments Supported::Yes" build into a runtime Docker image
# and push it to GHCR, as a drop-in for the rippleci/xrpld image xrpl.js uses
# for standalone testing -- except every amendment is built Supported::Yes.
#
# This does NOT build or package anything: the Trigger workflow already builds
# the supported binary and the supported .deb (the force_supported build config
# and the matching supported package config in linux.json). This workflow waits
# for a successful Trigger run on develop, downloads that run's supported .deb
# artifact, installs it into a slim base (docker/supported.Dockerfile, which
# replicates rippleci's layout), and pushes ghcr.io/xrplf/xrpld/supported.
#
# Perf/test artifact only -- never run it on a production validator.
name: Build supported Docker image
on:
workflow_run:
workflows: ["Trigger"]
types: [completed]
branches: [develop]
# Manual runs: point at a specific completed Trigger run via its run id.
workflow_dispatch:
inputs:
trigger_run_id:
description: "Run id of the Trigger workflow whose supported .deb to package."
required: true
type: string
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
defaults:
run:
shell: bash
env:
IMAGE_NAME: ghcr.io/xrplf/xrpld/supported
# The supported .deb artifact uploaded by reusable-package.yml:
# <artifact_name>-pkg, where artifact_name carries the -supported suffix.
DEB_ARTIFACT: xrpld-ubuntu-gcc-release-amd64-supported-pkg
SOURCE_RUN_ID: ${{ github.event.workflow_run.id || github.event.inputs.trigger_run_id }}
jobs:
image:
# Only for successful Trigger runs (workflow_run), and only on the canonical
# repo where GITHUB_TOKEN can push to ghcr.io/xrplf/*.
if: ${{ github.repository == 'XRPLF/rippled' && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success') }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Download the supported .deb from the Trigger run
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ env.DEB_ARTIFACT }}
path: dl
run-id: ${{ env.SOURCE_RUN_ID }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Assemble build context
run: |
set -euo pipefail
mkdir -p docker-context
deb="$(find dl -name '*.deb' | head -n1)"
[ -n "${deb}" ] || {
echo "::error::no supported .deb found in run ${SOURCE_RUN_ID}"
exit 1
}
mv "${deb}" docker-context/xrpld.deb
echo "Packaging $(basename "${deb}") into ${IMAGE_NAME}"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Login to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-,format=short
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: docker-context
file: docker/supported.Dockerfile
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -74,11 +74,6 @@ on:
required: false
type: string
default: ""
force_supported:
description: "Flip every amendment to Supported::Yes before building. For perf/test builds only; never for release artifacts."
required: false
type: boolean
default: false
secrets:
CODECOV_TOKEN:
@@ -133,20 +128,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Force all amendments to Supported::Yes
if: ${{ inputs.force_supported && runner.os == 'Linux' }}
run: |
set -euo pipefail
MACRO="include/xrpl/protocol/detail/features.macro"
echo "Flipping Supported::No -> Supported::Yes in ${MACRO}:"
grep -n 'Supported::No,' "${MACRO}" || echo " (none found)"
sed -i 's/Supported::No,/Supported::Yes,/g' "${MACRO}"
if grep -q 'Supported::No,' "${MACRO}"; then
echo "::error::Supported::No entries remain after sed"
exit 1
fi
git diff -- "${MACRO}" || true
- name: Prepare runner
uses: XRPLF/actions/prepare-runner@b3e255d74d785d053e4903da8ac90983cd7d9e82
with:
@@ -391,7 +372,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

@@ -52,6 +52,5 @@ jobs:
sanitizers: ${{ matrix.sanitizers }}
compiler: ${{ matrix.compiler || '' }}
toolchain: ${{ matrix.toolchain || '' }}
force_supported: ${{ matrix.force_supported || false }}
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

View File

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

View File

@@ -39,4 +39,4 @@ Paths below reflect the current layout; update this section if modularization mo
- `src/xrpld/` — the server application built on top of `libxrpl`: `app`, `core`, `overlay` (P2P networking), `peerfinder`, `perflog`, `rpc`, `shamap`. `main` builds an `ApplicationImp` implementing `Application`; most components hold a reference to it (`app_`), giving broad cross-component access — expect to trace call chains through `Application&`.
- `src/test/` — unit tests mirroring the subsystems above, plus `jtx/` (the transaction-building test DSL — e.g. `jtx/escrow.h`, `jtx/vault.h`, `jtx/sponsor.h`, `jtx/permissioned_dex.h`) and `unit_test/` (the custom test framework itself, derived from Beast).
- `src/tests/` — unit tests for `libxrpl` written in `gtest`, gradually replacing the `src/test` equivalents.
- `crates/` — a Rust workspace (only built with `-Dxrpld -Drust=ON`) bridged into C++ via `cxxbridge`/the `cxx` crate; currently just a `hello_world` interop scaffold. Requires the Rust toolchain pinned in `rust-toolchain.toml` (the Nix devshell provides it automatically).
- `crates/` — a Rust workspace holding the WebAssembly engine that runs Smart Escrow contracts, bridged into C++ via `cxxbridge`/the `cxx` crate; see [crates/README.md](./crates/README.md) for more details.

View File

@@ -28,10 +28,6 @@ Version 3.4.0 is not yet released. These changes are available in the 3.4.0 beta
### Additions in 3.4.0
- `book_offers`, `account_offers`: With the `OfferQualifiers` amendment, an offer entry may now include `all_or_none: true` (the offer carries the `lsfAllOrNone` flag) and/or `min_quantity` (the offer's `MinQuantity` amount). These mark execution-qualified ("contingent") offers that cannot be taken to arbitrary depth; clients should exclude them from quoted/takeable depth. The `OfferCreate` transaction gains the `tfAllOrNone` and `tfPostOnly` flags and an optional `MinQuantity` field, and a marketable `tfPostOnly` offer is rejected with the new `tecWOULD_CROSS` result.
- `account_tx`: Added an optional `delegate` request object to filter delegated transactions. The object requires `delegate_filter`, which must be either `actor` for transactions owned by the requested account but signed by another account, or `authorizer` for transactions signed by the requested account on behalf of another account. The optional `counter_party` account narrows the results to a specific signer/delegate for `actor` or a specific owner/delegator for `authorizer`. Malformed `delegate`, `delegate_filter`, and `counter_party` values return standard invalid field errors, and invalid account IDs return `actMalformed`.
When paginating delegate-filtered queries, a marker from a delegate-filtered query includes a `delegate` flag and is only valid for follow-up requests that also supply `delegate` (mixing marker conventions returns `invalidParams`). Because filtering is applied after the ledger scan, a page may contain fewer results than `limit` (possibly zero) while still returning a marker, so callers must continue until no marker is present.
- `ledger`: `nftoken_id`, `nftoken_ids`, and `offer_id` are now included in transaction metadata when transactions are expanded (`expand`, or admin-only `full`), matching the `tx`, `account_tx`, and `subscribe` (`transactions` stream) responses. ([#5706](https://github.com/XRPLF/rippled/pull/5706))
### Bugfixes in 3.4.0
@@ -66,11 +62,6 @@ This release contains bug fixes only and no API changes.
### Additions in 3.2.0
- `ledger_entry`, `account_objects`: Added the `Ballot` and `BallotVote` ledger entry types introduced by the `ConfidentialVoting` amendment. `ledger_entry` accepts a `ballot` request object (`owner` + `seq`) and a `ballot_vote` request object (`ballot_id` + `account`), or a hex object ID for either. `account_objects` returns these entries and accepts them as `type` filters.
- `account_tx`: Added an optional `delegate` request object to filter delegated transactions. The object requires `delegate_filter`, which must be either `actor` for transactions owned by the requested account but signed by another account, or `authorizer` for transactions signed by the requested account on behalf of another account. The optional `counter_party` account narrows the results to a specific signer/delegate for `actor` or a specific owner/delegator for `authorizer`. Malformed `delegate`, `delegate_filter`, and `counter_party` values return standard invalid field errors, and invalid account IDs return `actMalformed`.
When paginating delegate-filtered queries, a marker from a delegate-filtered query includes a `delegate` flag and is only valid for follow-up requests that also supply `delegate` (mixing marker conventions returns `invalidParams`). Because filtering is applied after the ledger scan, a page may contain fewer results than `limit` (possibly zero) while still returning a marker, so callers must continue until no marker is present.
- `ledger_entry`, `account_objects`: The `Delegate` ledger entry now includes an optional `DestinationNode` field, which stores the index into the authorized account's owner directory. This field is present on entries created after bidirectional directory tracking was introduced and may appear in RPC responses for those entries. ([#6681](https://github.com/XRPLF/rippled/pull/6681))
- `server_definitions`: Added the following new sections to the response ([#6321](https://github.com/XRPLF/rippled/pull/6321)):
- `TRANSACTION_FORMATS`: Describes the fields and their optionality for each transaction type, including common fields shared across all transactions.

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

@@ -99,7 +99,6 @@ if(only_docs)
return()
endif()
include(deps/dilithium)
include(deps/Boost)
add_subdirectory(external/antithesis-sdk)
@@ -115,7 +114,6 @@ find_package(OpenSSL REQUIRED)
find_package(secp256k1 REQUIRED)
find_package(SOCI REQUIRED)
find_package(SQLite3 REQUIRED)
find_package(wasmi REQUIRED)
find_package(xxHash REQUIRED)
target_link_libraries(
@@ -162,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

@@ -332,7 +332,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

@@ -69,8 +69,6 @@ target_link_libraries(
Xrpl::opts
Xrpl::syslibs
secp256k1::secp256k1
wasmi::wasmi
NIH::dilithium2_ref
xrpl.libpb
xxHash::xxhash
$<$<BOOL:${voidstar}>:antithesis-sdk-cpp>
@@ -209,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

@@ -1,62 +0,0 @@
include(FetchContent)
ExternalProject_Add(
dilithium_src
PREFIX ${nih_cache_path}
# Pin to an explicit commit, not a moving branch ref. Bumping this SHA
# is a supply-chain decision that must be reviewed; never revert to a
# branch tag here. Upstream:
# https://github.com/Transia-RnD/dilithium/commit/3032292cfd4d94e0df9bd49a0098669ca9166aa1
GIT_REPOSITORY https://github.com/Transia-RnD/dilithium.git
GIT_TAG 3032292cfd4d94e0df9bd49a0098669ca9166aa1
GIT_SHALLOW FALSE
CONFIGURE_COMMAND ""
LOG_BUILD ON
BUILD_IN_SOURCE 0
BUILD_COMMAND
COMMAND ${CMAKE_COMMAND} -E copy_directory <SOURCE_DIR>/ref <BINARY_DIR>/ref
COMMAND make -C <BINARY_DIR>/ref clean
COMMAND /bin/sh -c "CFLAGS='-DDILITHIUM_MODE=2 -DDILITHIUM_RANDOMIZED_SIGNING' make -C <BINARY_DIR>/ref libdilithium2_ref.a libfips202_ref.a"
INSTALL_COMMAND ""
BUILD_BYPRODUCTS
<BINARY_DIR>/ref/libdilithium2_ref.a
<BINARY_DIR>/ref/libfips202_ref.a
)
ExternalProject_Get_Property(dilithium_src SOURCE_DIR BINARY_DIR)
set(dilithium_src_SOURCE_DIR "${SOURCE_DIR}")
set(dilithium_src_BINARY_DIR "${BINARY_DIR}")
# Include the reference implementation headers from source
include_directories("${dilithium_src_SOURCE_DIR}/ref")
# Create imported targets for each static library using BINARY_DIR
add_library(dilithium::dilithium2_ref STATIC IMPORTED GLOBAL)
set_target_properties(dilithium::dilithium2_ref PROPERTIES
IMPORTED_LOCATION "${dilithium_src_BINARY_DIR}/ref/libdilithium2_ref.a"
INTERFACE_INCLUDE_DIRECTORIES "${dilithium_src_SOURCE_DIR}/ref/"
)
add_library(dilithium::libfips202_ref STATIC IMPORTED GLOBAL)
set_target_properties(dilithium::libfips202_ref PROPERTIES
IMPORTED_LOCATION "${dilithium_src_BINARY_DIR}/ref/libfips202_ref.a"
INTERFACE_INCLUDE_DIRECTORIES "${dilithium_src_SOURCE_DIR}/ref/"
)
# Add dependencies to ensure the external project is built first
add_dependencies(dilithium::dilithium2_ref dilithium_src)
add_dependencies(dilithium::libfips202_ref dilithium_src)
# Note: We do NOT link the Dilithium library's randombytes.c because we provide
# our own thread-safe implementation in src/libxrpl/protocol/SecretKey.cpp
# that uses xrpld's crypto_prng() instead of direct /dev/urandom access.
# Create an interface library that links to the Dilithium libraries
# Note: Link order matters - libraries that provide symbols must come AFTER libraries that use them
target_link_libraries(xrpl_libs INTERFACE
dilithium::dilithium2_ref
dilithium::libfips202_ref
)
# Create alias for convenience
add_library(NIH::dilithium2_ref ALIAS dilithium::dilithium2_ref)

View File

@@ -61,8 +61,6 @@ SETTING_DEFAULTS = {
"delegable": "Delegation::NotDelegable",
"amendment": "uint256{}",
"privileges": "Privilege::NoPriv",
"emittance": "Emittance::Emitable",
"firewall": "FirewallAction::Allow",
}

View File

@@ -3,7 +3,6 @@
"requires": [
"zlib/1.3.2#1cb806da49011867778ffb6ac7190fcb%1782392402.122708",
"xxhash/0.8.3#681d36a0a6111fc56e5e45ea182c19cc%1782392402.420688",
"wasmi/1.0.9#1fecdab9b90c96698eb35ea99ca4f5cb%1782307153.343419",
"sqlite3/3.53.0#324ada52333108388a9a6108bfa96734%1782392403.185447",
"soci/4.0.3#e726491a03468795453f7c83fc924a96%1782392402.679521",
"snappy/1.1.10#968fef506ff261592ec30c574d4a7809%1782307151.633168",

View File

@@ -36,7 +36,6 @@ class Xrpl(ConanFile):
"nudb/2.0.9",
"openssl/3.6.3",
"soci/4.0.3",
"wasmi/1.0.9",
"zlib/1.3.2",
]
@@ -153,8 +152,12 @@ class Xrpl(ConanFile):
"CMakeLists.txt",
"cfg/*",
"cmake/*",
"crates/*",
"crates/.cargo/*",
"!crates/target/*",
"external/*",
"include/*",
"rust-toolchain.toml",
"src/*",
)
@@ -225,7 +228,6 @@ class Xrpl(ConanFile):
"soci::soci",
"secp256k1::secp256k1",
"sqlite3::sqlite",
"wasmi::wasmi",
"xxhash::xxhash",
"zlib::zlib",
]

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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78693fcdd618e0fc34af59c6b8efa9ac5d58c68df940beff4bedddb6acfe7c27"
dependencies = [
"spin",
"wasmi_collections",
"wasmi_core",
"wasmi_ir",
"wasmparser 0.228.0",
]
[[package]]
name = "wasmi_collections"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a8be2aa467cf2d29e96ff759472c36eeb44a3c81c67fc9cb76c9a24c519c557"
dependencies = [
"string-interner",
]
[[package]]
name = "wasmi_core"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69372d5fda3ea3d1e0aa6603c7888110e0187e88ea17cd8fc2e2df0a0e1f37fa"
dependencies = [
"libm",
]
[[package]]
name = "wasmi_ir"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f17b774caa13c618c7244f1ee51fe23c5e7b8538a471fa46d9949779758aed6"
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"

120
crates/README.md Normal file
View File

@@ -0,0 +1,120 @@
# Rust crates
This directory holds the WebAssembly engine that runs Smart Escrow contracts,
bridged into C++ via `cxxbridge`/the `cxx` crate.
The workspace is built unconditionally — `add_subdirectory(crates)` in the
top-level `CMakeLists.txt` is not behind an option, and
`xrpl_wasm_vm_ffi_cxxbridge` is a `PUBLIC` dependency of
`xrpl.libxrpl.ledger` (see `cmake/XrplCore.cmake`). The Rust toolchain pinned in
[`rust-toolchain.toml`](../rust-toolchain.toml) is therefore required to build
`libxrpl` at all; the Nix devshell provides it automatically.
## The crates
Dependencies run in one direction: the ABI crate at the bottom, the engine on
top of it, and the two bridges at the edge.
### `xrpl-host-functions`
The wasm host ABI, declared exactly once. A `host_functions!` block at the
bottom of `src/lib.rs` generates the `HostFunctions` trait a host implements and
the `HostFunctionSpec` table a wasm engine registers from. Only the vocabulary
the declarations are written in — `HostError`, `HostResult`, `TraceDataType`,
`HASH_LEN` — is hand-written.
**Add or change a host function here**, never in the engine or the bridge: the
expansion names nothing this file does not, so neither side of the FFI boundary
gets to restate a signature.
`no_std`, because this crate is also what a guest contract links against.
### `xrpl-host-functions-macros`
The proc macro behind that block, plus the `wasmi_glue!` marshalling it
generates. An implementation detail of the crate above — nothing else should
depend on it.
The dev-dependency back on `xrpl-host-functions` is a deliberate cycle: the
doctests declare host functions returning `HostResult`, which the facade crate
hand-writes. Cargo allows it because dev-dependencies sit outside the library
build graph.
### `xrpl-wasm-vm`
The engine itself, on `wasmi`: preflight validation (`preflight/`), gas
metering and execution (`vm.rs`), and host-call dispatch (`abi.rs`, `args.rs`,
`register.rs`).
Two lint decisions are load-bearing, both because this is a consensus path:
- `forbid(unsafe_code)`, so "every guest access reaches linear memory only
through wasmi's bounds-checked slice operations" is a property rather than a
claim.
- The truncating, wrapping and sign-losing cast lints are `deny` and each
remaining cast is argued for at its site — a bad cast here changes what a
contract is charged or told.
It pins `wasmi` with `default-features = false` deliberately. wasmi's `wat`
feature is on by default and makes `Module::new` accept text as readily as
binary, which would turn a transaction's validity into a build flag.
Not bridged to C++ directly; it reaches `xrpld` through `xrpl-wasm-vm-ffi`.
### `xrpl-wasm-vm-ffi`
The cxx bridge into `xrpld`. Three crossings:
- **In:** C++ calls `run_escrow`, once per escrow finish.
- **Back out:** that run's host calls leave through the C++ `HostContext`, which
`CxxHost` presents to the engine as an ordinary `HostFunctions` implementor.
- **In only:** C++ screens a module with `check_escrow`. Screening needs no
host, so nothing comes back out.
The C++ side is `src/libxrpl/tx/wasm/WasmVM.cpp` and
`src/libxrpl/tx/wasm/HostContext.cpp`.
**Neither language may unwind into the other**, and the two halves are not
symmetric:
- A **Rust panic** is caught here, by `guarded`. Letting one reach C++ is
undefined behaviour, and `[profile.release]` enables overflow checks, so this
is a live path rather than a formality.
- A **C++ exception** is stopped on the C++ side: every `HostContext` method is
`noexcept` and catches its own. That is what makes `guarded` sufficient.
Everything hand-written here is private, so `cargo doc` needs
`--document-private-items` to show any of it. That is also why this crate,
unlike `xrpl-wasm-vm`, does not `deny(unreachable_pub)` — cxx's expansion is
`pub` throughout by necessity.
### `xrpl-wasm-testkit`
**Test-only.** Assembles WebAssembly text for the C++ test suite, and exposes
the gas price of each host function by its guest import name for the C++ gas
benchmarks (read through the bridge rather than transcribed into C++, so the
numbers cannot drift silently).
A crate of its own rather than an entry on `xrpl-wasm-vm-ffi`, and the
separation is the point: 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 text assembler in the
shipped node" holds by the link graph rather than by a flag someone can flip.
## Testing
```bash
cargo test --manifest-path crates/Cargo.toml --workspace
```
CI uses `cargo nextest`. This is independent of the CMake build.
One gap that command does not cover: it never compiles `xrpl-host-functions`
with its `wasmi_glue` feature **off**, because `xrpl-wasm-vm` enables the
feature and Cargo unifies features across a workspace build. The feature-off
configuration is the one a guest contract sees, so after touching that crate
also run:
```bash
cargo check -p xrpl-host-functions --manifest-path crates/Cargo.toml
```

View File

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

View File

@@ -1,23 +0,0 @@
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
#[cxx::bridge(namespace = "rs::hello_world")]
mod ffi {
extern "Rust" {
fn hello_world() -> String;
}
}
pub fn hello_world() -> String {
"hello_world".to_string()
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
#[test]
fn hello_world_returns_hello_world() {
assert_eq!(hello_world(), "hello_world")
}
}

View File

@@ -0,0 +1,21 @@
[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"
[lints]
workspace = true

View File

@@ -0,0 +1,33 @@
/// 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
})
}
/// `value`, or the folded diagnostics if any were recorded.
pub(crate) fn into_result<T>(value: T, errors: Vec<syn::Error>) -> syn::Result<T> {
match combine(errors) {
Some(error) => Err(error),
None => Ok(value),
}
}
/// `result`'s value, or `None` with its error filed in `errors` — so a check that
/// yields a value can be reported like one that yields nothing, and the caller
/// keeps going.
pub(crate) fn record<T>(result: syn::Result<T>, errors: &mut Vec<syn::Error>) -> Option<T> {
match result {
Ok(value) => Some(value),
Err(error) => {
errors.push(error);
None
}
}
}

View File

@@ -0,0 +1,472 @@
//! The wasmi registration, generated from the declarations the ABI table is
//! generated from — so the closure a guest links against cannot disagree with the
//! signature preflight screens it by.
//!
//! Emitted as a `macro_rules!` rather than as the registration itself, because the
//! crate the expansion lands in is `no_std`, zero-dependency and links into the
//! guest, and a `macro_rules!` body is inert tokens until someone expands it.
//!
//! The body therefore resolves in two crates at once and names nothing free:
//! `$crate` is the ABI crate, `$env` the module the caller passes in, and every
//! other path starts at `::wasmi` or `::core`. `$env` is matched as an `ident`
//! because `$env:path` used as `$env::Foo` is `error: missing angle brackets in
//! associated item path`.
//!
//! The one file that knows an engine's calling convention: how a region arrives as
//! two wasm parameters, where the gas charge goes, and which helper a result-less
//! function takes. A second engine would be a second file like it.
use proc_macro2::TokenStream;
use quote::{ToTokens, quote};
use crate::lowering::{ResultType, WasmValType};
use crate::parsed_host_function::{Param, ParsedHostFunction};
/// The `wasmi_glue!` macro: the trait a VM implements one body per host function
/// in, and the registration that hands each of them to a `Linker`.
pub(crate) fn wasmi_glue(functions: &[ParsedHostFunction]) -> TokenStream {
let bodies = functions.iter().map(body_declaration);
let registrations = functions.iter().map(registration);
let assertions = charging_assertions();
let env = env();
quote! {
/// Expands to the wasmi glue for this ABI: the `HostFunctionBodies` trait
/// and `register_host_functions`, at the scope it is called in. A
/// declaration added to the ABI is then a missing trait item rather than a
/// forgotten registration.
///
/// `$env` names a module holding everything the expansion reaches for on
/// the engine's side, since this crate can name none of it: the store type
/// `VmState`, the charging helpers `charged` and `charged_unreported` with
/// their `CallResult`, and the argument types `InBytes`, `InStr`, `InU32`,
/// `OutBytes` and `TraceCode`.
///
/// ```ignore
/// mod glue_env {
/// pub(crate) use crate::abi::{CallResult, charged, charged_unreported};
/// pub(crate) use crate::args::{InBytes, InStr, InU32, OutBytes, TraceCode};
/// pub(crate) use crate::vm::VmState;
/// }
///
/// xrpl_host_functions::wasmi_glue!(glue_env);
/// ```
///
/// The module supplies the spellings; the shapes are pinned by the
/// expansion. Each argument type implements [`FromWasmRegion`] or
/// [`FromWasmScalar`] — which one is the ABI's decision, so a declared
/// `u32` is a region — and each charging helper's signature is asserted
/// against a `const _`.
#[cfg(feature = "wasmi_glue")]
#[macro_export]
macro_rules! wasmi_glue {
($env:ident) => {
/// One body per host function: what the engine runs once the call's
/// gas is charged and its arguments are off the wire.
///
/// The methods take no receiver, so a registered closure captures
/// nothing — which is what satisfies wasmi's
/// `Fn + Send + Sync + 'static` bound, and why the implementor
/// itself need not be `'static`. A body does not charge gas; the
/// generated closure does, so it cannot be forgotten or charged
/// twice.
pub(crate) trait HostFunctionBodies {
#(#bodies)*
}
/// Register every host function on `linker`, one `func_wrap` per
/// declaration, at the ABI's derived wasm signature — so an import
/// that passes [`crate::check`] links here by construction.
pub(crate) fn register_host_functions<B: HostFunctionBodies>(
linker: &mut ::wasmi::Linker<#env::VmState<'_>>,
) -> ::core::result::Result<(), ::wasmi::errors::LinkerError> {
#(#registrations)*
Ok(())
}
#assertions
};
}
}
}
/// The macro argument every engine-side path is qualified by.
fn env() -> TokenStream {
quote!($env)
}
/// The signature of each charging helper, pinned as a `const _` the expansion
/// carries: the one part of the contract neither `$env` nor the argument traits
/// state.
///
/// Its value is the diagnostic. A changed helper is already a type error at the
/// call, but there it is failed inference inside a generated closure and here it
/// is one line stating the signature that was expected.
fn charging_assertions() -> TokenStream {
let env = env();
let assertion = |helper: TokenStream, answer: TokenStream| {
quote! {
const _: fn(
&mut ::wasmi::Caller<'_, #env::VmState<'_>>,
$crate::HostFunctionSpec,
fn(&mut ::wasmi::Caller<'_, #env::VmState<'_>>) -> #env::CallResult<#answer>,
) -> ::core::result::Result<#answer, ::wasmi::Error> = #env::#helper;
}
};
let reported = assertion(quote!(charged), quote!(i32));
let unreported = assertion(quote!(charged_unreported), quote!(()));
quote! {
#reported
#unreported
}
}
/// `fn check_keylet(caller: &mut Caller<'_, $env::VmState<'_>>, account:
/// $env::InBytes, seq: $env::InU32, out: $env::OutBytes) ->
/// $env::CallResult<i32>;`
fn body_declaration(function: &ParsedHostFunction) -> TokenStream {
let env = env();
let name = &function.signature.ident;
let params = function.params().iter().map(|param| {
let name = &param.name;
let ty = param.ty.argument_type(&env);
quote! { #name: #ty }
});
let answer = answer_type(function.result());
quote! {
fn #name(
caller: &mut ::wasmi::Caller<'_, #env::VmState<'_>>,
#(#params),*
) -> #answer;
}
}
/// One `linker.func_wrap(…)?;`: the wasm signature as the closure's parameters,
/// the gas charge around the call, and the body between them.
fn registration(function: &ParsedHostFunction) -> TokenStream {
let env = env();
let body = &function.signature.ident;
let spec = spec_path(function);
let params = function.params().iter().flat_map(closure_params);
let arguments = function.params().iter().map(lift);
let (answer, charge) = match function.result() {
ResultType::BufferLength | ResultType::Value => (quote!(i32), quote!(#env::charged)),
ResultType::Nothing => (quote!(()), quote!(#env::charged_unreported)),
};
quote! {
linker.func_wrap(
$crate::HOST_MODULE,
#spec.wasm_name(),
|mut caller: ::wasmi::Caller<'_, #env::VmState<'_>>, #(#params),*|
-> ::core::result::Result<#answer, ::wasmi::Error> {
#charge(&mut caller, #spec, |caller| {
B::#body(caller, #(#arguments),*)
})
},
)?;
}
}
/// `$crate::HostFunctionSpec::CheckKeylet` — the one name the expansion reaches
/// back into the ABI crate for.
fn spec_path(function: &ParsedHostFunction) -> TokenStream {
let variant = &function.variant;
quote! { $crate::HostFunctionSpec::#variant }
}
/// One declared parameter as the closure declares it: `account_ptr: i32,
/// account_len: i32`, or `field: i32`.
///
/// Names and types both come from the lowering, so the arity a closure is
/// registered at *is* the derived arity.
fn closure_params(param: &Param) -> Vec<TokenStream> {
param
.ty
.wasm_names(&param.name)
.into_iter()
.zip(param.ty.as_wasm_params())
.map(|(name, val_type)| {
let ty = rust_type(*val_type);
quote! { #name: #ty }
})
.collect()
}
/// The argument a body is handed, built from the wasm parameters it arrived as:
/// `<$env::InBytes as $crate::FromWasmRegion>::from_wasm(account_ptr,
/// account_len)`, or the scalar itself.
///
/// Qualified rather than an inherent call, so the arity comes from the trait the
/// lowering chose: an argument type implementing the other one is an unsatisfied
/// bound named at the type, where `Ty::from_wasm(a, b)` would be an unrelated
/// arity error named here.
fn lift(param: &Param) -> TokenStream {
let Some(argument_trait) = param.ty.argument_trait() else {
return param.name.to_token_stream();
};
let ty = param.ty.argument_type(&env());
let names = param.ty.wasm_names(&param.name);
quote! { <#ty as $crate::#argument_trait>::from_wasm(#(#names),*) }
}
/// What a body answers: the value the guest is told, or nothing at all for the
/// function whose whole effect is on the host.
fn answer_type(result: ResultType) -> TokenStream {
let env = env();
match result {
ResultType::BufferLength | ResultType::Value => quote!(#env::CallResult<i32>),
ResultType::Nothing => quote!(#env::CallResult<()>),
}
}
/// A wasm value type as a closure parameter spells it — a Rust type, not
/// [`WasmValType`]'s own `ToTokens`, which spells the ABI crate's variant.
fn rust_type(val_type: WasmValType) -> TokenStream {
match val_type {
WasmValType::I32 => quote!(i32),
WasmValType::I64 => quote!(i64),
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
use proc_macro2::{Delimiter, Group, TokenTree};
use syn::parse_quote;
fn parsed(function: syn::TraitItemFn) -> ParsedHostFunction {
ParsedHostFunction::parse(function).expect("the declaration should parse")
}
/// The declaration whose declared and wasm parameter lists differ most:
/// `account`, `out` and `seq` are a `(ptr, len)` pair each, so three arguments
/// to the body and six on the wire.
#[test]
fn lowers_a_declaration_to_a_body_and_a_registration() {
let keylet = parsed(parse_quote! {
#[gas = 350]
#[wasm_name = "check_id"]
fn check_keylet(&self, account: &[u8], seq: u32, out: &mut [u8]) -> HostResult<usize>;
});
assert_eq!(
body_declaration(&keylet).to_string(),
"fn check_keylet (caller : & mut :: wasmi :: Caller < '_ , $ env :: VmState < '_ >> , \
account : $ env :: InBytes , seq : $ env :: InU32 , out : $ env :: OutBytes) \
-> $ env :: CallResult < i32 > ;"
);
assert_eq!(
registration(&keylet).to_string(),
"linker . func_wrap ($ crate :: HOST_MODULE , \
$ crate :: HostFunctionSpec :: CheckKeylet . wasm_name () , \
| mut caller : :: wasmi :: Caller < '_ , $ env :: VmState < '_ >> , \
account_ptr : i32 , account_len : i32 , seq_ptr : i32 , seq_len : i32 , \
out_ptr : i32 , out_len : i32 | \
-> :: core :: result :: Result < i32 , :: wasmi :: Error > \
{ $ env :: charged (& mut caller , \
$ crate :: HostFunctionSpec :: CheckKeylet , | caller | \
{ B :: check_keylet (caller , \
< $ env :: InBytes as $ crate :: FromWasmRegion > \
:: from_wasm (account_ptr , account_len) , \
< $ env :: InU32 as $ crate :: FromWasmRegion > :: from_wasm (seq_ptr , seq_len) , \
< $ env :: OutBytes as $ crate :: FromWasmRegion > \
:: from_wasm (out_ptr , out_len)) }) } ,) ? ;"
);
}
/// A wasm scalar is passed through as itself, in declaration order: no pair,
/// no argument type, and an `i64` that stays one.
#[test]
fn passes_the_wasm_scalars_through_untouched() {
let from_int = parsed(parse_quote! {
#[gas = 100]
#[wasm_name = "float_from_int"]
fn float_from_int(&self, x: i64, out: &mut [u8], mode: i32) -> HostResult<usize>;
});
assert_eq!(
body_declaration(&from_int).to_string(),
"fn float_from_int (caller : & mut :: wasmi :: Caller < '_ , \
$ env :: VmState < '_ >> , \
x : i64 , out : $ env :: OutBytes , mode : i32) -> $ env :: CallResult < i32 > ;"
);
let registration = registration(&from_int).to_string();
assert!(
registration.contains(
"| mut caller : :: wasmi :: Caller < '_ , $ env :: VmState < '_ >> , \
x : i64 , out_ptr : i32 , out_len : i32 , mode : i32 |"
),
"{registration}"
);
assert!(
registration.contains(
"B :: float_from_int (caller , x , \
< $ env :: OutBytes as $ crate :: FromWasmRegion > \
:: from_wasm (out_ptr , out_len) , mode)"
),
"{registration}"
);
}
/// The function that answers nothing takes the other charging helper, derived
/// from its declared `HostResult<()>` rather than named as a special case.
/// Its `TraceCode` is also the only place `FromWasmScalar` is reached for.
#[test]
fn a_declaration_that_answers_nothing_takes_the_other_charge() {
let trace = parsed(trace_declaration());
assert_eq!(
body_declaration(&trace).to_string(),
"fn trace (caller : & mut :: wasmi :: Caller < '_ , $ env :: VmState < '_ >> , \
msg : $ env :: InStr , data_type : $ env :: TraceCode , data : $ env :: InBytes) \
-> $ env :: CallResult < () > ;"
);
let registration = registration(&trace).to_string();
assert!(
registration.contains(":: core :: result :: Result < () , :: wasmi :: Error >"),
"{registration}"
);
assert!(
registration.contains("$ env :: charged_unreported (& mut caller"),
"{registration}"
);
assert!(
registration.contains(
"B :: trace (caller , \
< $ env :: InStr as $ crate :: FromWasmRegion > :: from_wasm (msg_ptr , msg_len) , \
< $ env :: TraceCode as $ crate :: FromWasmScalar > :: from_wasm (data_type) , \
< $ env :: InBytes as $ crate :: FromWasmRegion > \
:: from_wasm (data_ptr , data_len))"
),
"{registration}"
);
}
/// The two worlds the macro body resolves in: the ABI crate through `$crate`,
/// and one engine by name. `names_no_crate_of_its_own` holds the ABI half of
/// the expansion to naming neither.
#[test]
fn reaches_the_abi_crate_through_dollar_crate_and_the_engine_by_name() {
let glue = code(wasmi_glue(&[parsed(parse_quote! {
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn(&self, out: &mut [u8]) -> HostResult<usize>;
})]));
assert!(
glue.contains("$ crate :: HostFunctionSpec :: GetLedgerSqn"),
"{glue}"
);
assert!(glue.contains("$ crate :: HOST_MODULE"), "{glue}");
assert!(glue.contains(":: wasmi :: Linker"), "{glue}");
assert!(!glue.contains("xrpl_host_functions"), "{glue}");
}
/// Every engine-side item is reached through the module the macro is handed: a
/// bare name would resolve against whatever the call site has in scope.
///
/// `charged` covers `charged_unreported`, being its prefix.
#[test]
fn names_the_engine_s_own_items_only_through_the_module_it_is_handed() {
let glue = code(wasmi_glue(&[
parsed(trace_declaration()),
parsed(parse_quote! {
#[gas = 350]
#[wasm_name = "check_id"]
fn check_keylet(&self, account: &[u8], seq: u32, out: &mut [u8])
-> HostResult<usize>;
}),
]));
for item in [
"VmState",
"CallResult",
"charged",
"InBytes",
"InStr",
"InU32",
"OutBytes",
"TraceCode",
] {
for (index, _) in glue.match_indices(item) {
assert!(
glue[..index].ends_with("$ env :: "),
"`{item}` named outside `$env`: {glue}"
);
}
}
}
/// The charging helpers' signatures, which nothing else in the contract
/// states.
#[test]
fn pins_both_charging_helpers_signatures() {
assert_eq!(
charging_assertions().to_string(),
"const _ : fn (& mut :: wasmi :: Caller < '_ , $ env :: VmState < '_ >> , \
$ crate :: HostFunctionSpec , \
fn (& mut :: wasmi :: Caller < '_ , $ env :: VmState < '_ >>) \
-> $ env :: CallResult < i32 > ,) \
-> :: core :: result :: Result < i32 , :: wasmi :: Error > = $ env :: charged ; \
const _ : fn (& mut :: wasmi :: Caller < '_ , $ env :: VmState < '_ >> , \
$ crate :: HostFunctionSpec , \
fn (& mut :: wasmi :: Caller < '_ , $ env :: VmState < '_ >>) \
-> $ env :: CallResult < () > ,) \
-> :: core :: result :: Result < () , :: wasmi :: Error > \
= $ env :: charged_unreported ;"
);
}
fn trace_declaration() -> syn::TraitItemFn {
parse_quote! {
#[gas = 30]
#[wasm_name = "trace"]
fn trace(&self, msg: &str, data_type: TraceDataType, data: &[u8]) -> HostResult<()>;
}
}
/// The expansion's code alone. `to_string` renders a doc comment as a
/// `#[doc = "…"]` literal, and the macro's own documentation names the very
/// items the scans above look for.
fn code(tokens: TokenStream) -> String {
fn is_doc(tree: Option<&TokenTree>) -> bool {
let Some(TokenTree::Group(group)) = tree else {
return false;
};
group.delimiter() == Delimiter::Bracket
&& matches!(group.stream().into_iter().next(),
Some(TokenTree::Ident(ident)) if ident == "doc")
}
fn strip(tokens: TokenStream) -> TokenStream {
let mut trees = tokens.into_iter().peekable();
let mut kept = Vec::new();
while let Some(tree) = trees.next() {
match tree {
TokenTree::Punct(ref punct)
if punct.as_char() == '#' && is_doc(trees.peek()) =>
{
trees.next();
}
TokenTree::Group(group) => kept.push(TokenTree::Group(Group::new(
group.delimiter(),
strip(group.stream()),
))),
other => kept.push(other),
}
}
kept.into_iter().collect()
}
strip(tokens).to_string()
}
}

View File

@@ -0,0 +1,496 @@
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
mod errors;
mod glue;
mod lowering;
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
///
/// Four 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`, `gas`,
/// `wasm_params` and `wasm_result` 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 those accessors read from a single `match` over the declarations, and
/// never appears in a signature a caller can name.
/// - `macro_rules! wasmi_glue`: the registration for a wasmi engine, emitted as a
/// macro rather than as code because it names an engine this crate must not
/// depend on. Inert until expanded — see its own documentation.
///
/// The wasm signature is derived from the declared types rather than stated a
/// second time: `i32` and `i64` are the wasm scalars spelled as themselves, every
/// other parameter type is marshalled through a `(ptr, len)` pair or an `i32`
/// code, and the result comes from the `HostResult<T>` success type. The glue is
/// generated from that same derivation, so the closure a guest links against and
/// the signature it is screened by are one statement.
///
/// Outside the glue's body the expansion builds only `Self::Variant` and
/// `WasmValType` paths, so the block compiles wherever the types it names —
/// `HostResult` and `WasmValType` — resolve.
///
/// ```
/// use xrpl_host_functions::{HostResult, WasmValType};
/// 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],
/// );
///
/// // So is the wasm signature: `out: &mut [u8]` is the pair `(ptr, len)`, and
/// // `HostResult<usize>` answers the length written to it.
/// assert_eq!(
/// HostFunctionSpec::GetLedgerSqn.wasm_params(),
/// &[WasmValType::I32, WasmValType::I32],
/// );
/// assert_eq!(
/// HostFunctionSpec::GetLedgerSqn.wasm_result(),
/// Some(WasmValType::I32),
/// );
///
/// // `trace_num` answers nothing at all, so its import has no result.
/// assert_eq!(
/// HostFunctionSpec::TraceNum.wasm_params(),
/// &[WasmValType::I32, WasmValType::I32, WasmValType::I64],
/// );
/// assert_eq!(HostFunctionSpec::TraceNum.wasm_result(), None);
/// ```
///
/// A declaration must be a plain `fn` taking `&self`, with no body and no
/// generics: it maps to exactly one wasm import signature. Its parameters must be
/// `i32`, `i64`, `u32`, `&[u8]`, `&mut [u8]`, `&str` or `TraceDataType`, and it
/// must return `HostResult<usize>` if it writes an output region,
/// `HostResult<i32>` if it answers a value directly, or `HostResult<()>` if it
/// answers nothing. 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 functions = parse_block(input)?;
let abi = abi_items(&functions);
let glue = glue::wasmi_glue(&functions);
Ok(quote! {
#abi
#glue
})
}
/// Every declaration in the block, parsed and checked against each other, or
/// every mistake in it.
fn parse_block(input: TokenStream) -> syn::Result<Vec<ParsedHostFunction>> {
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(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
}
/// The ABI itself: the trait a host implements and the table everything else
/// reads. `glue::wasmi_glue` is the other half of the expansion.
fn abi_items(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`]'s accessors
/// read from.
///
/// Private, and the only reason it exists is to keep all of them fed
/// from a single `match` over the declarations.
struct HostFnSpec {
name: &'static str,
gas: u64,
wasm_params: &'static [WasmValType],
wasm_result: Option<WasmValType>,
}
/// 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
}
/// The wasm parameters this function is imported with, in wire
/// order — the list a guest's import must match, which the
/// declaration's own parameter list is not: a declared parameter
/// marshalled through a `(ptr, len)` region is two of these.
///
/// Usable in `const` context, so import lists can be built at
/// compile time.
pub const fn wasm_params(self) -> &'static [WasmValType] {
self.spec().wasm_params
}
/// The wasm result this function answers with, or `None` for the
/// one whose whole effect is on the host. An `i32` where there is
/// one, whether the host answered a value or the length of what it
/// wrote — the wire does not distinguish those.
pub const fn wasm_result(self) -> Option<WasmValType> {
self.spec().wasm_result
}
}
}
}
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)]
#[cfg_attr(coverage_nightly, coverage(off))]
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, out: &mut [u8]) -> HostResult<usize>;
#[gas = 2000]
fn sha512_half(&self, data: &[u8], out: &mut [u8]) -> HostResult<usize>;
})
.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()
}
/// The ABI half of the expansion alone: the glue's body is written against
/// another crate entirely, so the tests below about what the expansion may
/// name are not about it.
fn abi_expansion(input: TokenStream) -> String {
abi_items(&parse_block(input).expect("the block should parse")).to_string()
}
#[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, out: &mut [u8]) -> HostResult<usize>;
#[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 , out : & mut [u8]) -> HostResult < usize > ;",
"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 , \
wasm_params : & 'static [WasmValType] , wasm_result : Option < WasmValType > , }",
"const fn spec (self) -> HostFnSpec",
// Two wasm parameters for the one declared region, and a result for
// the length written to it.
"Self :: GetLedgerSqn => HostFnSpec { name : \"ldgr_index\" , gas : 60u64 , \
wasm_params : & [WasmValType :: I32 , WasmValType :: I32] , \
wasm_result : Some (WasmValType :: I32) , }",
"Self :: TraceNum => HostFnSpec { name : \"trace_num\" , gas : 500u64 , \
wasm_params : & [WasmValType :: I32 , WasmValType :: I32 , WasmValType :: I64] , \
wasm_result : None , }",
"pub const fn wasm_name (self) -> & 'static str",
"pub const fn gas (self) -> u64",
"pub const fn wasm_params (self) -> & 'static [WasmValType]",
"pub const fn wasm_result (self) -> Option < WasmValType >",
// The fourth item; its contents are `glue`'s own tests.
"macro_rules ! wasmi_glue",
] {
assert!(generated.contains(expected), "missing {expected:?}");
}
}
/// The ABI reaches for nothing outside the crate it lands in, which is what
/// lets that crate stay zero-dependency and link into the guest. The glue is
/// not held to this — its body names one engine throughout, and `glue`'s own
/// tests pin that instead.
#[test]
fn names_no_crate_of_its_own() {
let generated = abi_expansion(quote! {
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn(&self, out: &mut [u8]) -> HostResult<usize>;
});
assert!(!generated.contains("xrpl_host_functions"), "{generated}");
// Two roots and no others: `Self::Variant` and `WasmValType::I32`.
// Doc comments spell paths without spaces (`Self::ALL`), so they do not
// match.
for (index, _) in generated.match_indices(" :: ") {
let prefix = &generated[..index];
assert!(
prefix.ends_with("Self") || prefix.ends_with("WasmValType"),
"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 = abi_expansion(quote! {
#[gas = 60]
#[wasm_name = "ldgr_index"]
fn get_ledger_sqn(&self, out: &mut [u8]) -> HostResult<usize>;
});
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<i32>;
#[gas = 70]
#[wasm_name = "b"]
fn get_ledger__sqn(&self) -> HostResult<i32>;
});
assert_eq!(messages.len(), 1, "{messages:?}");
assert!(
messages[0].contains("`GetLedgerSqn` variant"),
"{messages:?}"
);
}
}

View File

@@ -0,0 +1,488 @@
//! The wire shape of a declaration: everything a declared Rust type decides — the
//! wasm value types it lowers to, the names those wasm parameters take, the type a
//! generated body is handed for it, and which of the ABI's two argument traits
//! builds that type. Kept as one set of `match` arms because the four must agree.
//!
//! The whole mapping, and the only place it is written down: a type no arm here
//! names is a type the ABI does not have, not one that falls back to something.
//!
//! Two rows are worth knowing before reading a declaration:
//!
//! - **`u32` is not a scalar.** It is a `(ptr, len)` region holding four
//! little-endian bytes, which is how the guest SDK passes a sequence number.
//! - **`usize` and `i32` results are the same on the wire and not
//! interchangeable**: the first is the length of what was written to an output
//! region, the second the answer itself.
//!
//! Matching is on types as they are spelled — a proc macro resolves nothing, so
//! `type Bytes = u32; … x: Bytes` is unrecognizable — but on a path's last
//! segment, so any of these types may be spelled qualified.
use proc_macro2::TokenStream;
use quote::{ToTokens, format_ident, quote};
use syn::{Ident, PathArguments, Type, TypePath, TypeReference};
/// What a host function may be handed, and what each costs on the wire.
///
/// Declaration order is wasm parameter order, so a reader of a declaration is
/// reading the import the guest links against.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ParamType {
/// `i32`, passed through as itself. Also the spelling for a raw scalar
/// whose signedness the ABI does not fix.
I32,
/// `i64`, passed through as itself.
I64,
/// `TraceDataType`: an `i32` code the engine resolves to the enum before a
/// host sees it.
TraceDataType,
/// `&[u8]`: a borrowed input region.
InBytes,
/// `&str`: an input region whose read is also the UTF-8 check.
InStr,
/// `u32`: an input region holding four little-endian bytes.
InU32,
/// `&mut [u8]`: the writable output region.
OutBytes,
}
/// The success type of the `HostResult<T>` every declaration returns. These three
/// are what the ABI has; any other `T` is an error.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ResultType {
/// `usize`: the true length of a value written to an output region, which
/// the engine turns into the wire's `i32` or into `BufferTooSmall` /
/// `DataFieldTooLarge`. Never itself the wire type.
BufferLength,
/// `i32`: the answer, from a function that writes no region.
Value,
/// `()`: no wasm result at all — the call's whole effect is on the host, and
/// an `Err` reaches the guest in no form.
Nothing,
}
/// The wasm value types this ABI uses, mirroring `xrpl_host_functions::WasmValType`.
///
/// Mirrored rather than shared because the dependency runs the other way: the ABI
/// crate depends on this one, so nothing here can name its types. The [`ToTokens`]
/// impl below is the whole of the crossing, and emits references to that enum's
/// variants — so falling out of sync with it is a compile error at the
/// `host_functions!` call site rather than drift.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum WasmValType {
I32,
I64,
}
impl ParamType {
/// Recognizes the declared type, or refuses it against its own span.
pub(crate) fn parse(ty: &Type) -> syn::Result<Self> {
const ALLOWED: &str = "a host function's parameter must be `i32`, `i64`, `u32`, \
`&[u8]`, `&mut [u8]`, `&str` or `TraceDataType`";
let recognized = match ty {
// A lifetime on the reference changes nothing on the wire.
Type::Reference(TypeReference {
mutability, elem, ..
}) => match (mutability, &**elem) {
(None, Type::Slice(slice)) if is_named(&slice.elem, "u8") => Some(Self::InBytes),
(Some(_), Type::Slice(slice)) if is_named(&slice.elem, "u8") => {
Some(Self::OutBytes)
}
(None, elem) if is_named(elem, "str") => Some(Self::InStr),
_ => None,
},
_ => match last_path_segment(ty) {
Some(name) if name == "i32" => Some(Self::I32),
Some(name) if name == "i64" => Some(Self::I64),
Some(name) if name == "u32" => Some(Self::InU32),
Some(name) if name == "TraceDataType" => Some(Self::TraceDataType),
_ => None,
},
};
recognized.ok_or_else(|| syn::Error::new_spanned(ty, ALLOWED))
}
/// The wasm parameters this declared type lowers to, in order. `InBytes` and
/// `OutBytes` lower alike, so a region's direction survives only in the
/// variant.
pub(crate) fn as_wasm_params(self) -> &'static [WasmValType] {
match self {
Self::I32 | Self::TraceDataType => &[WasmValType::I32],
Self::I64 => &[WasmValType::I64],
Self::InBytes | Self::InStr | Self::InU32 | Self::OutBytes => {
&[WasmValType::I32, WasmValType::I32]
}
}
}
/// What a declaration calls each of those wasm parameters: the declared name
/// for a scalar, and `{name}_ptr`/`{name}_len` for the pair a region lowers
/// to.
///
/// **It must answer as many names as [`Self::as_wasm_params`] answers types**,
/// since the generated closure declares them one against the other — hence the
/// matching arms, and `lowers_every_declared_parameter_type`'s row-by-row
/// length check.
pub(crate) fn wasm_names(self, name: &Ident) -> Vec<Ident> {
match self {
Self::I32 | Self::I64 | Self::TraceDataType => vec![name.clone()],
Self::InBytes | Self::InStr | Self::InU32 | Self::OutBytes => {
vec![format_ident!("{name}_ptr"), format_ident!("{name}_len")]
}
}
}
/// The type a generated body takes this parameter as: a wasm scalar spelled as
/// itself, everything else the argument type carrying its shape and direction —
/// which is what makes an input region used as an output one a compile error
/// naming both.
///
/// The argument types are the engine's, so `vm` is the path they are reached
/// under; which types need it is decided here, a wasm scalar being `i32` under
/// every engine.
pub(crate) fn argument_type(self, vm: &TokenStream) -> TokenStream {
match self {
Self::I32 => quote!(i32),
Self::I64 => quote!(i64),
Self::TraceDataType => quote!(#vm::TraceCode),
Self::InBytes => quote!(#vm::InBytes),
Self::InStr => quote!(#vm::InStr),
Self::InU32 => quote!(#vm::InU32),
Self::OutBytes => quote!(#vm::OutBytes),
}
}
/// Which of the ABI's two argument traits builds this parameter's argument
/// type, or `None` for a wasm scalar, which reaches a body as itself.
///
/// The arity is the whole of the distinction — `FromWasmRegion` takes the two
/// of a `(ptr, len)` pair, `FromWasmScalar` the one of a code — so this answers
/// alongside [`Self::as_wasm_params`] rather than from a predicate elsewhere.
pub(crate) fn argument_trait(self) -> Option<TokenStream> {
match self {
Self::I32 | Self::I64 => None,
Self::TraceDataType => Some(quote!(FromWasmScalar)),
Self::InBytes | Self::InStr | Self::InU32 | Self::OutBytes => {
Some(quote!(FromWasmRegion))
}
}
}
/// Whether this parameter is a region the host writes to — what
/// [`ResultType::BufferLength`] is the length *of*.
pub(crate) fn is_out_region(self) -> bool {
matches!(self, Self::OutBytes)
}
}
impl ResultType {
/// Recognizes the success type of a declaration's `HostResult<T>`, or
/// refuses it against its own span.
pub(crate) fn parse(success: &Type) -> syn::Result<Self> {
const ALLOWED: &str = "a host function must return `HostResult<usize>` for a value it \
writes to an output region, `HostResult<i32>` for one it answers \
directly, or `HostResult<()>` for none at all";
if let Type::Tuple(tuple) = success
&& tuple.elems.is_empty()
{
return Ok(Self::Nothing);
}
match last_path_segment(success) {
Some(name) if name == "usize" => Ok(Self::BufferLength),
Some(name) if name == "i32" => Ok(Self::Value),
_ => Err(syn::Error::new_spanned(success, ALLOWED)),
}
}
/// The generated table's `wasm_result` field: `Some(WasmValType::I32)`, or
/// `None` for the function that answers nothing.
///
/// Spelled out here rather than left to `quote`'s `Option` impl, which emits
/// nothing at all for `None`.
pub(crate) fn wasm_result_tokens(self) -> TokenStream {
match self.as_wasm_result() {
Some(val_type) => quote! { Some(#val_type) },
None => quote! { None },
}
}
/// Whether the value reaches the guest as the length of what was written to
/// an output region.
pub(crate) fn is_buffer_length(self) -> bool {
matches!(self, Self::BufferLength)
}
/// The wasm result, which does not distinguish a length from a value.
fn as_wasm_result(self) -> Option<WasmValType> {
match self {
Self::BufferLength | Self::Value => Some(WasmValType::I32),
Self::Nothing => None,
}
}
}
/// `WasmValType::I32` — the ABI crate's variant, named but never defined here.
impl ToTokens for WasmValType {
fn to_tokens(&self, tokens: &mut TokenStream) {
tokens.extend(match self {
Self::I32 => quote! { WasmValType::I32 },
Self::I64 => quote! { WasmValType::I64 },
});
}
}
/// The last segment of a plain path type, when it carries no generic arguments:
/// `i32`, `core::primitive::i32` and `TraceDataType` all answer their own name,
/// `Vec<u8>` and `[u8; 4]` nothing.
fn last_path_segment(ty: &Type) -> Option<&Ident> {
let Type::Path(TypePath {
qself: None, path, ..
}) = ty
else {
return None;
};
let last = path.segments.last()?;
matches!(last.arguments, PathArguments::None).then_some(&last.ident)
}
/// Whether `ty` is the named primitive, however it is spelled.
fn is_named(ty: &Type, name: &str) -> bool {
last_path_segment(ty).is_some_and(|segment| segment == name)
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
use syn::parse_quote;
use WasmValType::{I32, I64};
/// Every declared parameter type and everything it decides: what it costs on
/// the wire, what a body is handed for it, and which trait builds that.
///
/// The names are asserted by length rather than spelling, since a type
/// answering fewer names than value types is the one way these answers can
/// contradict each other.
#[test]
fn lowers_every_declared_parameter_type() {
let mapping: [(Type, &[WasmValType], &str, Option<&str>); 7] = [
(parse_quote!(i32), &[I32], "i32", None),
(parse_quote!(i64), &[I64], "i64", None),
(
parse_quote!(TraceDataType),
&[I32],
"vm :: TraceCode",
Some("FromWasmScalar"),
),
(
parse_quote!(&[u8]),
&[I32, I32],
"vm :: InBytes",
Some("FromWasmRegion"),
),
(
parse_quote!(&str),
&[I32, I32],
"vm :: InStr",
Some("FromWasmRegion"),
),
(
parse_quote!(u32),
&[I32, I32],
"vm :: InU32",
Some("FromWasmRegion"),
),
(
parse_quote!(&mut [u8]),
&[I32, I32],
"vm :: OutBytes",
Some("FromWasmRegion"),
),
];
let declared_name = format_ident!("seq");
let vm = quote!(vm);
for (declared, wasm, argument, argument_trait) in mapping {
let param = ParamType::parse(&declared)
.unwrap_or_else(|_| panic!("`{}` should be a parameter type", quoted(&declared)));
assert_eq!(param.as_wasm_params(), wasm, "`{}`", quoted(&declared));
assert_eq!(
param.argument_type(&vm).to_string(),
argument,
"`{}`",
quoted(&declared)
);
assert_eq!(
param
.argument_trait()
.map(|name| name.to_string())
.as_deref(),
argument_trait,
"`{}`",
quoted(&declared)
);
assert_eq!(
param.wasm_names(&declared_name).len(),
wasm.len(),
"one name per wasm parameter: `{}`",
quoted(&declared)
);
}
}
/// A region's two wasm parameters are named off the declaration, so the
/// generated closure reads as the declaration does.
#[test]
fn names_a_region_s_pair_after_the_declared_parameter() {
let seq = format_ident!("seq");
let names = |declared: Type| {
ParamType::parse(&declared)
.expect("a parameter type")
.wasm_names(&seq)
.iter()
.map(Ident::to_string)
.collect::<Vec<_>>()
};
assert_eq!(names(parse_quote!(u32)), ["seq_ptr", "seq_len"]);
assert_eq!(names(parse_quote!(i32)), ["seq"]);
}
/// The two `(ptr, len)` pairs lower alike but are told apart, since only the
/// direction says who may write to the region.
#[test]
fn keeps_the_regions_apart() {
let input: Type = parse_quote!(&[u8]);
let output: Type = parse_quote!(&mut [u8]);
assert!(!ParamType::parse(&input).unwrap().is_out_region());
assert!(ParamType::parse(&output).unwrap().is_out_region());
}
/// A type outside the mapping is refused rather than lowered to a guess.
#[test]
fn refuses_parameter_types_outside_the_mapping() {
let outside: [Type; 11] = [
parse_quote!(u64),
parse_quote!(u8),
parse_quote!(usize),
parse_quote!(bool),
parse_quote!(Vec<u8>),
parse_quote!([u8; 4]),
parse_quote!(&mut str),
parse_quote!(&i32),
parse_quote!(&[i32]),
parse_quote!(&Foo),
parse_quote!(()),
];
for declared in outside {
let Err(error) = ParamType::parse(&declared) else {
panic!("`{}` should not be a parameter type", quoted(&declared));
};
assert!(
error.to_string().contains("must be `i32`"),
"`{}`: {error}",
quoted(&declared)
);
}
}
/// The three success types, and the wasm result each becomes. `usize` and
/// `i32` agree on the wire and are separate rows.
#[test]
fn lowers_every_success_type() {
let mapping: [(Type, ResultType, Option<WasmValType>); 3] = [
(parse_quote!(usize), ResultType::BufferLength, Some(I32)),
(parse_quote!(i32), ResultType::Value, Some(I32)),
(parse_quote!(()), ResultType::Nothing, None),
];
for (declared, expected, wasm_result) in mapping {
let result = ResultType::parse(&declared)
.unwrap_or_else(|_| panic!("`{}` should be a success type", quoted(&declared)));
assert_eq!(result, expected, "`{}`", quoted(&declared));
assert_eq!(
result.as_wasm_result(),
wasm_result,
"`{}`",
quoted(&declared)
);
}
}
/// The distinction the wasm result loses: which of the two `i32` results was
/// declared decides how the value reaches the guest.
#[test]
fn tells_a_length_from_a_value() {
assert!(ResultType::BufferLength.is_buffer_length());
assert!(!ResultType::Value.is_buffer_length());
assert!(!ResultType::Nothing.is_buffer_length());
}
#[test]
fn refuses_success_types_outside_the_mapping() {
let outside: [Type; 6] = [
parse_quote!(u32),
parse_quote!(i64),
parse_quote!(bool),
parse_quote!([u8; 32]),
parse_quote!(Vec<u8>),
parse_quote!((usize, i32)),
];
for declared in outside {
let Err(error) = ResultType::parse(&declared) else {
panic!("`{}` should not be a success type", quoted(&declared));
};
assert!(
error
.to_string()
.contains("must return `HostResult<usize>`"),
"`{}`: {error}",
quoted(&declared)
);
}
}
/// A qualified spelling is the same type, matching how the return type finds
/// `HostResult`.
#[test]
fn accepts_qualified_spellings() {
let qualified: Type = parse_quote!(core::primitive::i32);
assert_eq!(ParamType::parse(&qualified).unwrap(), ParamType::I32);
let qualified: Type = parse_quote!(xrpl_host_functions::TraceDataType);
assert_eq!(
ParamType::parse(&qualified).unwrap(),
ParamType::TraceDataType
);
}
/// The emitted tokens name the ABI crate's variants, which is the whole of
/// what crosses out of this crate. Pinned here so a break in the mirror is a
/// failure with a span rather than a rustc error at the call site.
#[test]
fn emits_references_to_the_hand_written_variants() {
assert_eq!(I32.to_token_stream().to_string(), "WasmValType :: I32");
assert_eq!(I64.to_token_stream().to_string(), "WasmValType :: I64");
assert_eq!(
ResultType::BufferLength.wasm_result_tokens().to_string(),
"Some (WasmValType :: I32)"
);
assert_eq!(ResultType::Nothing.wasm_result_tokens().to_string(), "None");
}
fn quoted(ty: &Type) -> String {
ty.to_token_stream().to_string()
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
[package]
name = "xrpl-host-functions"
version = "0.1.0"
edition.workspace = true
[dependencies]
xrpl-host-functions-macros.path = "../xrpl-host-functions-macros"
# `wasmi_glue!` and the two argument traits it marshals through are a host's
# business, and this crate is what a contract links against — so the feature is
# what keeps `cargo doc` here showing a contract developer only the ABI. It costs
# nothing either way: a `macro_rules!` is inert and a trait with no impls emits
# nothing.
#
# `xrpl-wasm-vm` enables it and features unify across the graph, so the off
# configuration is only ever checked by `cargo check -p xrpl-host-functions`,
# which is in the loop in `tmp/notes/wasm-vm/testing.md` for that reason.
[features]
default = []
wasmi_glue = []
[lints]
workspace = true

View File

@@ -0,0 +1,572 @@
//! 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.
//!
//! Three items cross that split the other way, named by the expansion but by no
//! declaration: [`WasmValType`], which the derived wasm signatures are spelled in,
//! and `FromWasmRegion`/`FromWasmScalar`, which `wasmi_glue!` builds a marshalled
//! argument through.
//!
//! 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]
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
#[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,
}
/// The wasm module name a guest imports these functions under:
/// `(import "host_lib" "ldgr_index" …)`.
pub const HOST_MODULE: &str = "host_lib";
/// A wasm value type, as many of them as this ABI uses — the vocabulary the
/// generated wasm signatures are spelled in, which an engine maps to its own value
/// types once.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WasmValType {
I32,
I64,
}
/// Builds the argument an engine marshals a `(ptr, len)` region into: a declared
/// `&[u8]`, `&str`, `u32` or `&mut [u8]`. Nothing is checked here — the pair
/// arrives as the guest sent it, and refusing a malformed region is the engine's
/// business.
///
/// **Kept apart from [`FromWasmScalar`]** rather than folded into one trait with
/// an associated wasm type, for the diagnostic: the mistake worth catching is an
/// arity one — a declared `u32` is a region, not a code — and as two traits that
/// lands as an unsatisfied bound at the offending argument type rather than an
/// `i32`-against-`(i32, i32)` mismatch at the macro call.
#[cfg(feature = "wasmi_glue")]
pub trait FromWasmRegion {
fn from_wasm(ptr: i32, len: i32) -> Self;
}
/// Builds the argument an engine marshals a single `i32` code into: the declared
/// `TraceDataType`. [`FromWasmRegion`] says why the two are separate traits.
#[cfg(feature = "wasmi_glue")]
pub trait FromWasmScalar {
fn from_wasm(code: i32) -> Self;
}
// Two rules hold over every declaration below, and neither is visible at any one of
// them. They are what lets the wasm signature be read off the declaration.
//
// **Declaration order is wasm parameter order.** So `mode` comes after `out` in the
// float functions, and `data_type` between `trace`'s two regions: the wire's order,
// not the one a Rust signature would choose.
//
// **`i32` and `i64` are the wasm scalars, spelled as themselves; every other type is
// marshalled.** `&[u8]`/`&str` and `&mut [u8]` are `(ptr, len)` pairs, `TraceDataType`
// is an `i32` code the engine names before a host sees it, and **`u32` is four
// little-endian bytes in a region**, not a scalar, which is how the guest SDK passes a
// sequence number.
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.
#[gas = 350]
#[wasm_name = "check_id"]
fn check_keylet(&self, account: &[u8], seq: u32, 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.
#[gas = 350]
#[wasm_name = "escrow_id"]
fn escrow_keylet(&self, account: &[u8], seq: u32, 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.
#[gas = 350]
#[wasm_name = "mpt_issuance_id"]
fn mptoken_issuance_keylet(
&self,
issuer: &[u8],
seq: u32,
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.
#[gas = 350]
#[wasm_name = "nft_offer_id"]
fn nftoken_offer_keylet(
&self,
account: &[u8],
seq: u32,
out: &mut [u8],
) -> HostResult<usize>;
/// The 32-byte keylet of an `Offer`, computed from the 20-byte owner account and
/// its sequence number.
#[gas = 350]
#[wasm_name = "offer_id"]
fn offer_keylet(&self, account: &[u8], seq: u32, out: &mut [u8]) -> HostResult<usize>;
/// The 32-byte keylet of an `Oracle`, computed from the 20-byte owner account and
/// its document id.
#[gas = 350]
#[wasm_name = "oracle_id"]
fn oracle_keylet(&self, account: &[u8], doc_id: u32, 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.
#[gas = 350]
#[wasm_name = "paychan_id"]
fn paychannel_keylet(
&self,
account: &[u8],
destination: &[u8],
seq: u32,
out: &mut [u8],
) -> HostResult<usize>;
/// The 32-byte keylet of a `PermissionedDomain`, computed from the 20-byte owner
/// account and its sequence number.
#[gas = 350]
#[wasm_name = "permissioned_domain_id"]
fn permissioned_domain_keylet(
&self,
account: &[u8],
seq: u32,
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.
#[gas = 350]
#[wasm_name = "ticket_id"]
fn ticket_keylet(&self, account: &[u8], seq: u32, out: &mut [u8]) -> HostResult<usize>;
/// The 32-byte keylet of a `Vault`, computed from the 20-byte owner account and its
/// sequence number.
#[gas = 350]
#[wasm_name = "vault_id"]
fn vault_keylet(&self, account: &[u8], seq: u32, out: &mut [u8]) -> HostResult<usize>;
/// The 32-byte keylet of a `Sponsorship`, computed from the 20-byte sponsor account
/// and the 20-byte sponsee account.
#[gas = 350]
#[wasm_name = "sponsorship_id"]
fn sponsorship_keylet(
&self,
sponsor: &[u8],
sponsee: &[u8],
out: &mut [u8],
) -> HostResult<usize>;
/// The 32-byte keylet of a `LoanBroker`, computed from the 20-byte owner account and
/// its sequence number.
#[gas = 350]
#[wasm_name = "loan_broker_id"]
fn loan_broker_keylet(&self, owner: &[u8], seq: u32, out: &mut [u8]) -> HostResult<usize>;
/// The 32-byte keylet of a `Loan`, computed from the 32-byte id of its `LoanBroker`
/// and the loan's sequence number.
#[gas = 350]
#[wasm_name = "loan_id"]
fn loan_keylet(
&self,
loan_broker_id: &[u8],
loan_seq: u32,
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.
#[gas = 30]
#[wasm_name = "trace"]
fn trace(&self, msg: &str, data_type: TraceDataType, data: &[u8]) -> 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, out: &mut [u8], mode: i32) -> 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], out: &mut [u8], mode: i32) -> 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], out: &mut [u8], mode: i32) -> 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], out: &mut [u8], mode: i32) -> 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], out: &mut [u8], mode: i32) -> 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,
out: &mut [u8],
mode: i32,
) -> 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], out: &mut [u8], mode: i32) -> HostResult<usize>;
/// The float difference `x - y` under rounding `mode`.
#[gas = 160]
#[wasm_name = "float_sub"]
fn float_subtract(&self, x: &[u8], y: &[u8], out: &mut [u8], mode: i32) -> HostResult<usize>;
/// The float product `x * y` under rounding `mode`.
#[gas = 300]
#[wasm_name = "float_mult"]
fn float_multiply(&self, x: &[u8], y: &[u8], out: &mut [u8], mode: i32) -> HostResult<usize>;
/// The float quotient `x / y` under rounding `mode`.
#[gas = 300]
#[wasm_name = "float_div"]
fn float_divide(&self, x: &[u8], y: &[u8], out: &mut [u8], mode: i32) -> 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, out: &mut [u8], mode: i32) -> 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,41 @@
//! `host_functions!` must work outside the crate that declares the ABI: the only
//! names its expansion needs are `WasmValType` and the ones the declarations
//! themselves spell.
//!
//! That this crate compiles is also what shows the emitted `wasmi_glue!` costs
//! nothing to carry: its body names an engine throughout, there is no engine
//! here, and nobody here expands it.
use xrpl_host_functions::{HostResult, WasmValType};
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);
assert_eq!(HostFunctionSpec::Ping.wasm_params(), &[WasmValType::I32]);
assert_eq!(HostFunctionSpec::Ping.wasm_result(), Some(WasmValType::I32));
}
/// 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,15 @@
[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" }
[lints]
workspace = true

View File

@@ -0,0 +1,109 @@
//! 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)]
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
#[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 host_function_gas(wasm_name: &str) -> Result<u64>;
}
}
fn compile_wat(wat: &str) -> Result<Vec<u8>, wat::Error> {
wat::parse_str(wat)
}
fn host_function_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)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
#[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!(
host_function_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!(
host_function_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() {
host_function_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,15 @@
[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" }
[lints]
workspace = true

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
[package]
name = "xrpl-wasm-vm"
version = "0.1.0"
edition.workspace = true
[dependencies]
wasmi = { version = "2.0.0", default-features = false, features = ["std", "validate", "portable-dispatch"] }
xrpl-host-functions = { path = "../xrpl-host-functions", features = ["wasmi_glue"] }
[dev-dependencies]
wat = "1"
[lints]
workspace = true

View File

@@ -0,0 +1,838 @@
use crate::args::OutBytes;
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))
}
/// The guest's memory, for a call that reads its inputs and writes nothing back.
/// An argument read out of the slice is borrowed rather than copied.
///
/// A call that also writes takes both borrows at once, so [`write_buffered`] and
/// [`write_mant_exp`] hand over the same slice themselves.
pub(crate) fn guest_memory<'a>(caller: &'a Caller<'_, VmState<'_>>) -> CallResult<&'a [u8]> {
let mem = memory(caller)?;
Ok(mem.data(caller))
}
/// 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: OutBytes,
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 read any number of input
/// arguments out of it — 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: OutBytes,
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: OutBytes,
exponent_out: OutBytes,
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)]
#[cfg_attr(coverage_nightly, coverage(off))]
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: u32, _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: u32, _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: u32,
_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: u32,
_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn offer_keylet(&self, _account: &[u8], _seq: u32, _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn oracle_keylet(
&self,
_account: &[u8],
_doc_id: u32,
_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn paychannel_keylet(
&self,
_account: &[u8],
_destination: &[u8],
_seq: u32,
_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn permissioned_domain_keylet(
&self,
_account: &[u8],
_seq: u32,
_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: u32, _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn vault_keylet(&self, _account: &[u8], _seq: u32, _out: &mut [u8]) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn sponsorship_keylet(
&self,
_sponsor: &[u8],
_sponsee: &[u8],
_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn loan_broker_keylet(
&self,
_owner: &[u8],
_seq: u32,
_out: &mut [u8],
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn loan_keylet(
&self,
_loan_broker_id: &[u8],
_loan_seq: u32,
_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_type: TraceDataType, _data: &[u8]) -> 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, _out: &mut [u8], _mode: i32) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn float_from_uint(&self, _x: &[u8], _out: &mut [u8], _mode: i32) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn float_from_stamount(
&self,
_amount: &[u8],
_out: &mut [u8],
_mode: i32,
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn float_from_stnumber(
&self,
_number: &[u8],
_out: &mut [u8],
_mode: i32,
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn float_to_int(&self, _x: &[u8], _out: &mut [u8], _mode: i32) -> 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,
_out: &mut [u8],
_mode: i32,
) -> 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],
_out: &mut [u8],
_mode: i32,
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn float_subtract(
&self,
_x: &[u8],
_y: &[u8],
_out: &mut [u8],
_mode: i32,
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn float_multiply(
&self,
_x: &[u8],
_y: &[u8],
_out: &mut [u8],
_mode: i32,
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn float_divide(
&self,
_x: &[u8],
_y: &[u8],
_out: &mut [u8],
_mode: i32,
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
fn float_power(
&self,
_x: &[u8],
_n: i32,
_out: &mut [u8],
_mode: i32,
) -> HostResult<usize> {
unreachable!("no unit test in this module calls the host")
}
}
fn state(budget: u64) -> VmState<'static> {
VmState {
host: &UncalledHost,
mem_limits: StoreLimitsBuilder::new().build(),
transfer_budget: Cell::new(budget),
memory: None,
out_buffer: [0u8; MAX_FIELD_BYTES],
}
}
/// `wasmi::Error` is not `PartialEq`, so a test expecting the guest-visible
/// channel says so by going through here.
fn wire(result: CallResult<i32>) -> i32 {
to_wire(result)
.unwrap_or_else(|trap| panic!("expected a guest-visible status, got a trap: {trap}"))
}
#[test]
fn a_success_becomes_the_value_and_an_error_becomes_its_code() {
assert_eq!(wire(Ok(0)), 0);
assert_eq!(wire(Ok(32)), 32);
assert_eq!(wire(Err(HostError::BufferTooSmall.into())), -3);
}
/// The codes a host may answer that a contract must not see, and the fault each
/// becomes. Written out rather than derived from `From<HostError>`, which is what
/// they are asserting.
const STOPS_THE_RUN: [(HostError, Fault); 3] = [
(HostError::InternalFatal, Fault::Internal),
(HostError::Unimplemented, Fault::Internal),
(HostError::NoMemExported, Fault::NoMemory),
];
/// Every fault, so the two tests below are the whole set and not a sample.
/// `From<Fault> for RunError` is what forces a fault added later to be
/// considered; this is what forces it to be tested.
const ALL_FAULTS: [Fault; 3] = [Fault::OutOfGas, Fault::Internal, Fault::NoMemory];
#[test]
fn a_code_that_stops_the_run_converts_to_its_fault() {
for (error, fault) in STOPS_THE_RUN {
assert_eq!(CallError::from(error), CallError::Fatal(fault), "{error:?}");
}
}
/// Over `HostError::ALL`, so it is the whole ABI and not a sample: a code added
/// to the ABI arrives already asserted to reach the guest as itself, and stopping
/// the run on it is then a change someone has to come and make.
///
/// `OutOfTransferLimit` is the row worth reading twice: the one budget a
/// contract can be expected to handle, so it is told no rather than killed.
#[test]
fn every_other_code_reaches_the_guest_as_itself() {
for &error in HostError::ALL {
if STOPS_THE_RUN.iter().any(|&(stops, _)| stops == error) {
continue;
}
assert_eq!(CallError::from(error), CallError::Code(error), "{error:?}");
assert_eq!(wire(Err(error.into())), error.code(), "{error:?}");
}
}
/// The trap carries the fault, so `run` can name the outcome without parsing a
/// message.
#[test]
fn a_fault_becomes_a_trap_carrying_it() {
for fault in ALL_FAULTS {
let trap = to_wire(Err(CallError::Fatal(fault)))
.expect_err("a fault must not reach the guest as a code");
let payload = trap.downcast_ref::<FatalHostError>().unwrap_or_else(|| {
panic!("{fault:?}: expected a FatalHostError payload, got: {trap}")
});
assert_eq!(*payload, FatalHostError(fault));
}
}
/// The result-less path splits the same two channels differently: a fault still
/// stops the run, and every code is dropped, since `trace` has no return value to
/// carry it. Over `HostError::ALL` for the reason above — a code added to the ABI
/// arrives asserted against both paths.
#[test]
fn a_call_with_no_result_drops_a_code_and_traps_on_a_fault() {
assert!(dropped(Ok(())).is_ok());
for &error in HostError::ALL {
if let CallError::Code(code) = CallError::from(error) {
assert!(
dropped(Err(CallError::Code(code))).is_ok(),
"{error:?} has no channel to the guest and must be dropped"
);
}
}
for fault in ALL_FAULTS {
let trap =
dropped(Err(CallError::Fatal(fault))).expect_err("a fault must stop the run");
let payload = trap.downcast_ref::<FatalHostError>().unwrap_or_else(|| {
panic!("{fault:?}: expected a FatalHostError payload, got: {trap}")
});
assert_eq!(*payload, FatalHostError(fault));
}
}
#[test]
fn a_transfer_spends_the_budget() {
let state = state(100);
assert_eq!(charge_transfer(&state, 30), Ok(()));
assert_eq!(state.transfer_budget.get(), 70);
assert_eq!(charge_transfer(&state, 70), Ok(()));
assert_eq!(state.transfer_budget.get(), 0);
}
/// The budget bounds the total, so the transfer that would overrun it is
/// refused whole rather than partially charged.
#[test]
fn a_transfer_past_the_budget_is_refused_and_charges_nothing() {
let state = state(100);
assert_eq!(
charge_transfer(&state, 101),
Err(HostError::OutOfTransferLimit)
);
assert_eq!(
state.transfer_budget.get(),
100,
"a refusal must not charge"
);
assert_eq!(charge_transfer(&state, 100), Ok(()));
assert_eq!(
charge_transfer(&state, 1),
Err(HostError::OutOfTransferLimit)
);
}
#[test]
fn transferring_nothing_costs_nothing() {
let state = state(0);
assert_eq!(charge_transfer(&state, 0), Ok(()));
assert_eq!(state.transfer_budget.get(), 0);
}
/// The field cap holds one call to a small share of the run's budget, so the
/// budget bounds a run rather than a call. An inequality, not the two values:
/// those are pinned in `vm.rs`.
#[test]
fn no_single_value_can_exhaust_the_run_budget() {
assert!(
(MAX_FIELD_BYTES as u64) * 64 <= TRANSFER_LIMIT_BYTES,
"one {MAX_FIELD_BYTES}-byte value against a {TRANSFER_LIMIT_BYTES}-byte budget"
);
}
}

View File

@@ -0,0 +1,244 @@
//! A host call's arguments as they arrive: one type per declared parameter the
//! ABI marshals, built by `register.rs`'s generated closures and read by its
//! bodies. A wasm scalar (`i32`, `i64`) is passed through as itself and has no
//! type here.
//!
//! What the types buy is that **a body cannot mistake one argument for another**:
//! an input region offered where an output one belongs is a compile error naming
//! both, where two loose `i32`s would let a rounding mode be read as a buffer
//! length. A derived signature cannot catch that, every one of these being
//! `i32, i32` on the wire.
//!
//! The arguments arrive unchecked and are judged where they are read — `InU32`'s
//! region must hold exactly four bytes, `InStr`'s must be UTF-8, a [`TraceCode`]
//! must name a rendering — so they are refused in the order the body reads them.
//!
//! **The `from_wasm` impls below are half of `wasmi_glue!`'s contract** and the
//! only construction these types have; `register.rs`'s `glue_env` is where the
//! macro is told which type marshals which declared one. Which of the two traits
//! a type takes is the ABI's decision, so `InU32` is a region rather than the
//! scalar its declared `u32` reads like.
use crate::vm::MAX_FIELD_BYTES;
use core::ops::Range;
use xrpl_host_functions::{FromWasmRegion, FromWasmScalar, HostError, HostResult, TraceDataType};
/// A byte region as the guest declared it: the `(ptr, len)` pair off the wire, not
/// yet checked.
///
/// The shared half of the four region types below, which differ in what reading
/// one means. The fields being out of reach makes [`range`](Region::range) the
/// only way to indices, so the check can be deferred but not skipped — and
/// construction is infallible so that a malformed region is refused in the order
/// the call's own helper chooses.
#[derive(Copy, Clone)]
struct Region {
ptr: i32,
len: i32,
}
impl Region {
fn new(ptr: i32, len: i32) -> Region {
Region { ptr, len }
}
/// `start..end` as indices. The conversion is the negativity check, and the
/// checked addition guards a 32-bit `usize`, where two `i32`s can sum past the
/// end.
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. The slice aliases `data`.
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)
}
}
/// A declared `&[u8]`: an input region the host borrows.
#[derive(Copy, Clone)]
pub(crate) struct InBytes(Region);
impl FromWasmRegion for InBytes {
fn from_wasm(ptr: i32, len: i32) -> InBytes {
InBytes(Region::new(ptr, len))
}
}
impl InBytes {
/// The region's bytes, aliasing the guest's memory rather than copied out of it.
pub(crate) fn read(self, data: &[u8]) -> HostResult<&[u8]> {
self.0.read(data)
}
}
/// A declared `&str`: an input region whose bytes are text.
#[derive(Copy, Clone)]
pub(crate) struct InStr(Region);
impl FromWasmRegion for InStr {
fn from_wasm(ptr: i32, len: i32) -> InStr {
InStr(Region::new(ptr, len))
}
}
impl InStr {
/// The region's bytes as text. The read is also the UTF-8 check, so a host is
/// never the one to validate them.
pub(crate) fn read(self, data: &[u8]) -> HostResult<&str> {
core::str::from_utf8(self.0.read(data)?).map_err(|_| HostError::InvalidParams)
}
}
/// A declared `u32`: an input region holding the number as four little-endian
/// bytes, which is how the guest SDK passes a sequence number.
#[derive(Copy, Clone)]
pub(crate) struct InU32(Region);
impl FromWasmRegion for InU32 {
fn from_wasm(ptr: i32, len: i32) -> InU32 {
InU32(Region::new(ptr, len))
}
}
impl InU32 {
/// The number the region holds. The width is the ABI's, so any length but four
/// is `InvalidParams`.
pub(crate) fn read(self, data: &[u8]) -> HostResult<u32> {
let bytes: [u8; 4] = self
.0
.read(data)?
.try_into()
.map_err(|_| HostError::InvalidParams)?;
Ok(u32::from_le_bytes(bytes))
}
}
/// A declared `&mut [u8]`: the region the host's answer is written to.
///
/// It has no `read`: what a call may put here is decided by the `abi.rs` helper
/// serving it, against the value's length and the run's budget, and the host is
/// never handed the guest's capacity.
#[derive(Copy, Clone)]
pub(crate) struct OutBytes(Region);
impl FromWasmRegion for OutBytes {
fn from_wasm(ptr: i32, len: i32) -> OutBytes {
OutBytes(Region::new(ptr, len))
}
}
impl OutBytes {
pub(crate) fn range(self) -> HostResult<Range<usize>> {
self.0.range()
}
}
/// A declared `TraceDataType`: the `i32` code naming how `trace` is to render its
/// data. The one marshalled argument that is not a region, so reading it needs no
/// guest memory.
#[derive(Copy, Clone)]
pub(crate) struct TraceCode(i32);
impl FromWasmScalar for TraceCode {
fn from_wasm(code: i32) -> TraceCode {
TraceCode(code)
}
}
impl TraceCode {
/// The type the code names, or `InvalidParams`: a rendering the guest did not
/// ask for is not one to guess at.
pub(crate) fn read(self) -> HostResult<TraceDataType> {
TraceDataType::from_code(self.0).ok_or(HostError::InvalidParams)
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
/// The guest memory these tests read out of: sixteen bytes at index 0.
const MEMORY: [u8; 16] = [
0x78, 0x56, 0x34, 0x12, b'h', b'i', 0xff, 0, 0, 0, 0, 0, 0, 0, 0, 0,
];
#[test]
fn a_u32_argument_is_four_little_endian_bytes() {
assert_eq!(InU32::from_wasm(0, 4).read(&MEMORY), Ok(0x1234_5678));
}
/// A longer region is refused too, rather than its first four bytes read as
/// the answer.
#[test]
fn a_u32_argument_of_any_other_width_is_refused() {
for len in [0, 1, 3, 5, 8] {
assert_eq!(
InU32::from_wasm(0, len).read(&MEMORY),
Err(HostError::InvalidParams),
"{len} bytes"
);
}
}
/// The read is the UTF-8 check, so a host implementing `trace` has nothing
/// left to validate.
#[test]
fn a_str_argument_is_checked_where_it_is_read() {
assert_eq!(InStr::from_wasm(4, 2).read(&MEMORY), Ok("hi"));
assert_eq!(
InStr::from_wasm(6, 1).read(&MEMORY),
Err(HostError::InvalidParams),
"0xff is not UTF-8"
);
}
/// A region past the end of guest memory is refused rather than clamped, and
/// one past the field cap is refused before the memory is consulted at all.
#[test]
fn a_region_is_held_to_the_memory_and_to_the_field_cap() {
assert_eq!(
InBytes::from_wasm(8, 16).read(&MEMORY),
Err(HostError::PointerOutOfBounds)
);
let past_the_cap = i32::try_from(MAX_FIELD_BYTES).expect("the cap is a small constant") + 1;
assert_eq!(
InBytes::from_wasm(0, past_the_cap).read(&MEMORY),
Err(HostError::DataFieldTooLarge)
);
assert_eq!(
InBytes::from_wasm(-1, 4).read(&MEMORY),
Err(HostError::InvalidParams)
);
}
/// Every code the ABI has, and nothing else: an unknown one is refused rather
/// than rendered some other way.
#[test]
fn a_trace_code_names_a_rendering_or_none() {
for &data_type in TraceDataType::ALL {
assert_eq!(TraceCode::from_wasm(data_type.code()).read(), Ok(data_type));
}
for code in [0, -1, i32::MAX] {
assert_eq!(
TraceCode::from_wasm(code).read(),
Err(HostError::InvalidParams),
"{code}"
);
}
}
}

View File

@@ -0,0 +1,29 @@
//! 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
)]
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
mod abi;
mod args;
mod preflight;
mod register;
mod vm;
pub use preflight::{CheckError, check, check_all};
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,504 @@
//! 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 entry points over one pass: [`check`] stops at the first refusal, which is
//! all a consensus path can act on, and [`check_all`] reports every one. Both draw
//! from [`check_error_iter`], so they cannot disagree about which refusal is first.
//!
//! One thing it deliberately does not screen: a module exporting **no** linear
//! memory passes, since 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 needs
//! no rule of its own — the engine forbids one, so such a module fails to compile.
//!
//! 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_iter`] is one pass — see it for what stays invisible,
//! and why the table case leaves much more of it there.
//!
//! Every rule is here but one: [`signature`] holds the comparison of an import's
//! type against the ABI's, which needs machinery the rest of the stage does not.
mod signature;
use std::fmt;
use wasmi::{ExternType, FuncType, Module, ValType};
use xrpl_host_functions::{HOST_MODULE, HostFunctionSpec};
use crate::vm::{MAX_MEMORY_PAGES, MAX_TABLE_ELEMENTS, compile};
use signature::check_signature;
/// 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),
/// An import of a host function typed as something other than what the engine
/// registers it as. Apart from [`CheckError::Import`] because the ABI does
/// have the function the guest asked for.
Signature(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}"),
CheckError::Signature(detail) => write!(f, "signature: {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.
pub fn check(wasm: &[u8], function_name: &str) -> Result<(), CheckError> {
let module = compile(wasm).map_err(CheckError::Compile)?;
check_error_iter(&module, function_name)
.next()
.map_or(Ok(()), Err)
}
/// [`check`], reporting every error found rather than stopping at the first.
pub fn check_all(wasm: &[u8], function_name: &str) -> Result<(), Vec<CheckError>> {
let module = compile(wasm).map_err(|detail| vec![CheckError::Compile(detail)])?;
let refusals: Vec<CheckError> = check_error_iter(&module, function_name).collect();
if refusals.is_empty() {
return Ok(());
}
Err(refusals)
}
fn check_error_iter<'a>(
module: &'a Module,
function_name: &'a str,
) -> impl Iterator<Item = CheckError> + 'a {
check_imports_iter(module)
.chain(check_entry_point_iter(module, function_name))
.chain(check_exported_resources_iter(module))
}
fn check_imports_iter(module: &Module) -> impl Iterator<Item = CheckError> + '_ {
module
.imports()
.filter_map(|import| check_import(import.module(), import.name(), import.ty()).err())
}
/// Whether the engine defines this one import, as the guest declares it.
///
/// Names and signatures both come from the declarations
/// [`crate::register::register_host_functions`] registers from, so a check and a
/// run cannot disagree about which imports exist or what they look like.
///
/// The rules are ordered, each presuming the ones before it held: a guest
/// importing `env::malloc` is told about the namespace, which explains every other
/// import it has too, and only an import that names a real host function as a
/// function has a signature worth comparing.
fn check_import(module: &str, name: &str, ty: &ExternType) -> Result<(), CheckError> {
let function = host_function(module, name).map_err(CheckError::Import)?;
let imported = imported_function(name, ty).map_err(CheckError::Import)?;
check_signature(function, imported).map_err(CheckError::Signature)
}
/// Which host function this import names: its namespace must be the engine's, and
/// its name one the ABI declares.
fn host_function(module: &str, name: &str) -> Result<HostFunctionSpec, String> {
if module != HOST_MODULE {
return Err(format!("'{module}::{name}' is not from '{HOST_MODULE}'"));
}
HostFunctionSpec::ALL
.iter()
.find(|function| function.wasm_name() == name)
.copied()
.ok_or_else(|| format!("no host function '{name}'"))
}
/// The function type the guest declared. The engine defines these names as
/// functions and as nothing else, so an import of any other kind does not link.
fn imported_function<'ty>(name: &str, ty: &'ty ExternType) -> Result<&'ty FuncType, String> {
match ty {
ExternType::Func(ty) => Ok(ty),
_ => Err(format!("'{HOST_MODULE}::{name}' is not a function")),
}
}
fn check_entry_point_iter<'a>(
module: &'a Module,
name: &'a str,
) -> impl Iterator<Item = CheckError> + 'a {
std::iter::once_with(move || match module.get_export(name) {
Some(ExternType::Func(ty)) if is_entry_point(&ty) => None,
found => Some(CheckError::EntryPoint(entry_point_fault(found, name))),
})
.flatten()
}
/// 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 yields both, in export order. 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_iter(module: &Module) -> impl Iterator<Item = CheckError> + '_ {
module.exports().filter_map(|export| match export.ty() {
ExternType::Memory(ty) => check_initial_pages(ty.minimum())
.err()
.map(CheckError::Memory),
ExternType::Table(ty) => check_initial_elements(ty.minimum())
.err()
.map(CheckError::Table),
_ => None,
})
}
/// 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`] and
/// [`check_all`]; what is here is what a module cannot state precisely — which rule
/// fires and in what words the caller logs it. The signature rule's derivation is
/// tested beside it, in [`signature`].
///
/// `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)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::signature::registered_type;
use super::*;
use wasmi::{GlobalType, MemoryType, Mutability};
/// The type an import of `function` must declare.
fn registered(function: HostFunctionSpec) -> ExternType {
ExternType::Func(registered_type(function))
}
/// 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, at the signature derived from its
/// declaration. Derived from `ALL` rather than listed, so a host function added
/// to the ABI is covered the day it lands.
///
/// Both sides come from the table, so this pins that no declaration is refused,
/// not that the table is right. `tests/preflight.rs`'s
/// `every_declared_host_function_may_be_imported` and
/// `the_derived_signatures_are_what_the_linker_registers` are what compare it
/// against hand-written imports and against the real linker.
#[test]
fn every_declared_host_function_is_served() {
for &function in HostFunctionSpec::ALL {
let name = function.wasm_name();
if let Err(refusal) = check_import(HOST_MODULE, name, &registered(function)) {
panic!("'{name}' is declared but not served: {refusal}");
}
}
}
#[test]
fn an_import_from_another_namespace_is_refused() {
for namespace in ["env", "host", "host_lib2", ""] {
let refusal = host_function(namespace, a_host_function_name()).expect_err(namespace);
assert!(
refusal.contains("is not from 'host_lib'"),
"{namespace}: {refusal}"
);
}
}
#[test]
fn an_unknown_name_is_refused() {
let refusal = host_function(HOST_MODULE, "no_such_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 = imported_function(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 = host_function("env", "no_such_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}"
);
}
/// The signature is the last rule, so an import wrong about the namespace, the
/// name or the kind is not told about a signature instead, and the two kinds of
/// fault reach the caller as different stages.
#[test]
fn the_signature_is_the_last_rule() {
let name = a_host_function_name();
let mistyped = ExternType::Func(FuncType::new([ValType::F32], []));
let not_a_function = ExternType::Global(GlobalType::new(ValType::I32, Mutability::Const));
for (rule, refusal) in [
("the namespace", check_import("env", name, &mistyped)),
(
"the name",
check_import(HOST_MODULE, "no_such_function", &mistyped),
),
("the kind", check_import(HOST_MODULE, name, &not_a_function)),
] {
assert!(
matches!(refusal, Err(CheckError::Import(_))),
"{rule} explains this import, not its signature: {refusal:?}"
);
}
assert!(
matches!(
check_import(HOST_MODULE, name, &mistyped),
Err(CheckError::Signature(_))
),
"an import that breaks nothing but the signature is a signature fault"
);
}
/// 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(ExternType::Func(FuncType::new(
[ValType::I32],
[ValType::I32]
))),
"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 stage is the prefix, so the detail must not say "signature" again.
assert_eq!(
CheckError::Signature(
"'ldgr_index' expected '(i32, i32) -> i32', found '(i64, i64) -> i32'".to_string()
)
.to_string(),
"signature: 'ldgr_index' expected '(i32, i32) -> i32', found '(i64, i64) -> i32'"
);
// 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"
);
}
/// Compiling is the one stage that ends the walk for [`check_all`] too: there is
/// no module to read the other rules off.
#[test]
fn a_failed_compile_is_reported_alone() {
let refusals = check_all(b"not wasm", "finish").expect_err("not a module");
assert!(
matches!(refusals.as_slice(), [CheckError::Compile(_)]),
"{refusals:?}"
);
}
}

View File

@@ -0,0 +1,154 @@
//! The last rule on an import: an import that names a host function must also
//! declare the type the engine registers it as.
//!
//! A file of its own because it is the one import rule with machinery to carry:
//! the ABI's derived signature, its map into wasmi's value types, and a rendering
//! of a function type for the refusal.
//!
//! **Arity and value types are the whole of it** — an `i64` in an `i32`'s place, or
//! a `u32` parameter read as one wasm parameter rather than two. **Parameter order
//! is invisible**: nearly everything lowers to `i32`, so two swapped parameters of
//! the same type leave the function type identical.
use wasmi::{FuncType, ValType};
use xrpl_host_functions::{HostFunctionSpec, WasmValType};
/// Whether this import declares the type the engine registers — one that does not
/// is what a module parts from the linker over at instantiation.
pub(super) fn check_signature(
function: HostFunctionSpec,
imported: &FuncType,
) -> Result<(), String> {
let registered = registered_type(function);
if *imported == registered {
return Ok(());
}
Err(format!(
"'{}' expected '{}', found '{}'",
function.wasm_name(),
signature(&registered),
signature(imported)
))
}
/// The type the engine registers `function` as: the wasm signature derived from its
/// declaration, in wasmi's own vocabulary.
///
/// Building one to compare against costs nothing — `FuncType` holds up to 21 value
/// types inline on a 64-bit target and the ABI's widest signature is nine, so this
/// is a stack value and the comparison above is one `==`.
pub(super) fn registered_type(function: HostFunctionSpec) -> FuncType {
FuncType::new(
function.wasm_params().iter().copied().map(val_type),
function.wasm_result().map(val_type),
)
}
/// The one place the ABI's value types become the engine's.
fn val_type(declared: WasmValType) -> ValType {
match declared {
WasmValType::I32 => ValType::I32,
WasmValType::I64 => ValType::I64,
}
}
/// A function type as `(i32, i32) -> i32`, and as `(i32, i32)` for a function
/// answering nothing — the spelling [`super::entry_point_fault`] uses, so the two
/// stages describe a signature the same way.
fn signature(ty: &FuncType) -> String {
let params = to_string(ty.params());
match ty.results() {
[] => format!("({params})"),
results => format!("({params}) -> {}", to_string(results)),
}
}
/// The types of one position, as a signature lists them.
fn to_string(types: &[ValType]) -> String {
types
.iter()
.copied()
.map(as_str)
.collect::<Vec<_>>()
.join(", ")
}
/// A wasm value type as the text format spells it. Total over [`ValType`] because a
/// refusal renders the found side too, which is whatever the module declared.
fn as_str(val_type: ValType) -> &'static str {
match val_type {
ValType::I32 => "i32",
ValType::I64 => "i64",
ValType::F32 => "f32",
ValType::F64 => "f64",
ValType::V128 => "v128",
ValType::FuncRef => "funcref",
ValType::ExternRef => "externref",
}
}
/// The rule and the derivation under it, on function types built directly. Which
/// `CheckError` a refusal becomes and where this rule sits among the other three
/// are the parent's tests.
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
/// The three ways an import's type can differ from the one registered, each
/// against a real declaration: a wrong value type, a wrong arity, and a result
/// where the ABI answers nothing.
///
/// The arity case is the one that matters in practice — a guest that reads
/// `check_keylet`'s `seq: u32` as a scalar writes exactly that signature.
#[test]
fn an_import_of_the_wrong_type_is_refused() {
let refusal = check_signature(
HostFunctionSpec::GetLedgerSqn,
&FuncType::new([ValType::I64, ValType::I64], [ValType::I32]),
)
.expect_err("i64 where i32 belongs");
assert_eq!(
refusal,
"'ldgr_index' expected '(i32, i32) -> i32', found '(i64, i64) -> i32'"
);
let refusal = check_signature(
HostFunctionSpec::CheckKeylet,
&FuncType::new([ValType::I32; 5], [ValType::I32]),
)
.expect_err("a u32 read as one parameter rather than two");
assert_eq!(
refusal,
"'check_id' expected '(i32, i32, i32, i32, i32, i32) -> i32', \
found '(i32, i32, i32, i32, i32) -> i32'"
);
let refusal = check_signature(
HostFunctionSpec::Trace,
&FuncType::new([ValType::I32; 5], [ValType::I32]),
)
.expect_err("a result from the one function that answers nothing");
assert_eq!(
refusal,
"'trace' expected '(i32, i32, i32, i32, i32)', \
found '(i32, i32, i32, i32, i32) -> i32'"
);
}
/// Both of the ABI's value types survive the map to the engine's vocabulary:
/// an `i64` collapsed to an `i32` would make the check accept what the linker
/// refuses, and a result invented for `trace` would make it refuse what the
/// linker accepts.
#[test]
fn the_derived_type_keeps_i64_and_the_absent_result() {
assert_eq!(
signature(&registered_type(HostFunctionSpec::FloatFromInt)),
"(i64, i32, i32, i32) -> i32"
);
assert_eq!(
signature(&registered_type(HostFunctionSpec::Trace)),
"(i32, i32, i32, i32, i32)"
);
}
}

View File

@@ -0,0 +1,712 @@
//! What this engine does with each host call, once the ABI's own machinery has
//! taken the call apart.
//!
//! `wasmi_glue!` expands to the [`HostFunctionBodies`] trait and
//! [`register_host_functions`], both generated from the declarations in
//! `xrpl-host-functions` — so the wasm signature every closure is registered at is
//! the one [`crate::check`] screens an import by. Hand-written here is one body per
//! declaration, and the compiler will not accept the `impl` without all of them.
//! [`glue_env`] is this engine's side of that macro's contract.
//!
//! **A body charges no gas and touches no wire encoding.** The generated closure
//! does both, around the call, so a body says only what the call *is*.
//!
//! Four shapes cover 59 of the 60, each decided by the declaration's own types:
//!
//! - a value the host answers directly — read the arguments, call the host;
//! - [`write_into`], for a value written straight to the guest's output region:
//! the call reads no guest memory, so the host can be handed a `&mut` view of it;
//! - [`write_buffered`], for one that also reads: the host fills the run's scratch
//! buffer, and it is copied out once every rule has passed, which is what lets
//! the inputs stay borrowed rather than copied;
//! - [`write_mant_exp`], for the one call that writes two regions.
//!
//! `trace` is the sixtieth: its declared `HostResult<()>` gives it a
//! `CallResult<()>` body and the `charged_unreported` helper.
use crate::abi::{CallResult, guest_memory, write_buffered, write_into, write_mant_exp};
use crate::args::{InBytes, InStr, InU32, OutBytes, TraceCode};
use crate::vm::VmState;
use wasmi::Caller;
/// Everything `wasmi_glue!` names on this side, gathered where the macro can be
/// handed it — so a rename in `abi.rs` or `args.rs` is an unresolved import here
/// rather than a name resolved against whatever the call site has in scope.
///
/// The shapes are elsewhere and cannot be stated here: `args.rs` implements
/// `FromWasmRegion`/`FromWasmScalar`, and the expansion pins each charging
/// helper's signature itself.
mod glue_env {
pub(crate) use crate::abi::{CallResult, charged, charged_unreported};
pub(crate) use crate::args::{InBytes, InStr, InU32, OutBytes, TraceCode};
pub(crate) use crate::vm::VmState;
}
xrpl_host_functions::wasmi_glue!(glue_env);
/// The bodies this engine registers, named as one type so
/// [`register_host_functions`] can be given them. Never built: every body is an
/// associated function and the host it calls comes from the store.
pub(crate) struct Bodies {}
impl HostFunctionBodies for Bodies {
fn get_ledger_sqn(caller: &mut Caller<'_, VmState<'_>>, out: OutBytes) -> CallResult<i32> {
write_into(caller, out, |host, out| host.get_ledger_sqn(out))
}
fn get_parent_ledger_time(
caller: &mut Caller<'_, VmState<'_>>,
out: OutBytes,
) -> CallResult<i32> {
write_into(caller, out, |host, out| host.get_parent_ledger_time(out))
}
fn get_parent_ledger_hash(
caller: &mut Caller<'_, VmState<'_>>,
out: OutBytes,
) -> CallResult<i32> {
write_into(caller, out, |host, out| host.get_parent_ledger_hash(out))
}
fn get_base_fee(caller: &mut Caller<'_, VmState<'_>>, out: OutBytes) -> CallResult<i32> {
write_into(caller, out, |host, out| host.get_base_fee(out))
}
fn is_amendment_enabled(
caller: &mut Caller<'_, VmState<'_>>,
amendment: InBytes,
) -> CallResult<i32> {
let memory = guest_memory(caller)?;
let host = caller.data().host;
Ok(host.is_amendment_enabled(amendment.read(memory)?)?)
}
fn cache_ledger_obj(
caller: &mut Caller<'_, VmState<'_>>,
obj_id: InBytes,
cache_idx: i32,
) -> CallResult<i32> {
let memory = guest_memory(caller)?;
let host = caller.data().host;
Ok(host.cache_ledger_obj(obj_id.read(memory)?, cache_idx)?)
}
fn get_tx_field(
caller: &mut Caller<'_, VmState<'_>>,
field: i32,
out: OutBytes,
) -> CallResult<i32> {
write_into(caller, out, |host, out| host.get_tx_field(field, out))
}
fn get_current_ledger_obj_field(
caller: &mut Caller<'_, VmState<'_>>,
field: i32,
out: OutBytes,
) -> CallResult<i32> {
write_into(caller, out, |host, out| {
host.get_current_ledger_obj_field(field, out)
})
}
fn get_ledger_obj_field(
caller: &mut Caller<'_, VmState<'_>>,
cache_idx: i32,
field: i32,
out: OutBytes,
) -> CallResult<i32> {
write_into(caller, out, |host, out| {
host.get_ledger_obj_field(cache_idx, field, out)
})
}
fn get_tx_nested_field(
caller: &mut Caller<'_, VmState<'_>>,
locator: InBytes,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.get_tx_nested_field(locator.read(memory)?, buf)
})
}
fn get_current_ledger_obj_nested_field(
caller: &mut Caller<'_, VmState<'_>>,
locator: InBytes,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.get_current_ledger_obj_nested_field(locator.read(memory)?, buf)
})
}
fn get_ledger_obj_nested_field(
caller: &mut Caller<'_, VmState<'_>>,
cache_idx: i32,
locator: InBytes,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.get_ledger_obj_nested_field(cache_idx, locator.read(memory)?, buf)
})
}
fn get_tx_array_len(caller: &mut Caller<'_, VmState<'_>>, field: i32) -> CallResult<i32> {
Ok(caller.data().host.get_tx_array_len(field)?)
}
fn get_current_ledger_obj_array_len(
caller: &mut Caller<'_, VmState<'_>>,
field: i32,
) -> CallResult<i32> {
Ok(caller.data().host.get_current_ledger_obj_array_len(field)?)
}
fn get_ledger_obj_array_len(
caller: &mut Caller<'_, VmState<'_>>,
cache_idx: i32,
field: i32,
) -> CallResult<i32> {
Ok(caller
.data()
.host
.get_ledger_obj_array_len(cache_idx, field)?)
}
fn get_tx_nested_array_len(
caller: &mut Caller<'_, VmState<'_>>,
locator: InBytes,
) -> CallResult<i32> {
let memory = guest_memory(caller)?;
let host = caller.data().host;
Ok(host.get_tx_nested_array_len(locator.read(memory)?)?)
}
fn get_current_ledger_obj_nested_array_len(
caller: &mut Caller<'_, VmState<'_>>,
locator: InBytes,
) -> CallResult<i32> {
let memory = guest_memory(caller)?;
let host = caller.data().host;
Ok(host.get_current_ledger_obj_nested_array_len(locator.read(memory)?)?)
}
fn get_ledger_obj_nested_array_len(
caller: &mut Caller<'_, VmState<'_>>,
cache_idx: i32,
locator: InBytes,
) -> CallResult<i32> {
let memory = guest_memory(caller)?;
let host = caller.data().host;
Ok(host.get_ledger_obj_nested_array_len(cache_idx, locator.read(memory)?)?)
}
fn check_signature(
caller: &mut Caller<'_, VmState<'_>>,
message: InBytes,
signature: InBytes,
pubkey: InBytes,
) -> CallResult<i32> {
let memory = guest_memory(caller)?;
let host = caller.data().host;
Ok(host.check_signature(
message.read(memory)?,
signature.read(memory)?,
pubkey.read(memory)?,
)?)
}
fn account_keylet(
caller: &mut Caller<'_, VmState<'_>>,
account: InBytes,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.account_keylet(account.read(memory)?, buf)
})
}
fn amm_keylet(
caller: &mut Caller<'_, VmState<'_>>,
asset1: InBytes,
asset2: InBytes,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.amm_keylet(asset1.read(memory)?, asset2.read(memory)?, buf)
})
}
fn check_keylet(
caller: &mut Caller<'_, VmState<'_>>,
account: InBytes,
seq: InU32,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.check_keylet(account.read(memory)?, seq.read(memory)?, buf)
})
}
fn credential_keylet(
caller: &mut Caller<'_, VmState<'_>>,
subject: InBytes,
issuer: InBytes,
credential_type: InBytes,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.credential_keylet(
subject.read(memory)?,
issuer.read(memory)?,
credential_type.read(memory)?,
buf,
)
})
}
fn delegate_keylet(
caller: &mut Caller<'_, VmState<'_>>,
account: InBytes,
authorize: InBytes,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.delegate_keylet(account.read(memory)?, authorize.read(memory)?, buf)
})
}
fn deposit_preauth_keylet(
caller: &mut Caller<'_, VmState<'_>>,
account: InBytes,
authorize: InBytes,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.deposit_preauth_keylet(account.read(memory)?, authorize.read(memory)?, buf)
})
}
fn did_keylet(
caller: &mut Caller<'_, VmState<'_>>,
account: InBytes,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.did_keylet(account.read(memory)?, buf)
})
}
fn escrow_keylet(
caller: &mut Caller<'_, VmState<'_>>,
account: InBytes,
seq: InU32,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.escrow_keylet(account.read(memory)?, seq.read(memory)?, buf)
})
}
fn trust_line_keylet(
caller: &mut Caller<'_, VmState<'_>>,
account1: InBytes,
account2: InBytes,
currency: InBytes,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.trust_line_keylet(
account1.read(memory)?,
account2.read(memory)?,
currency.read(memory)?,
buf,
)
})
}
fn mptoken_issuance_keylet(
caller: &mut Caller<'_, VmState<'_>>,
issuer: InBytes,
seq: InU32,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.mptoken_issuance_keylet(issuer.read(memory)?, seq.read(memory)?, buf)
})
}
fn mptoken_keylet(
caller: &mut Caller<'_, VmState<'_>>,
mptid: InBytes,
holder: InBytes,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.mptoken_keylet(mptid.read(memory)?, holder.read(memory)?, buf)
})
}
fn nftoken_offer_keylet(
caller: &mut Caller<'_, VmState<'_>>,
account: InBytes,
seq: InU32,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.nftoken_offer_keylet(account.read(memory)?, seq.read(memory)?, buf)
})
}
fn offer_keylet(
caller: &mut Caller<'_, VmState<'_>>,
account: InBytes,
seq: InU32,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.offer_keylet(account.read(memory)?, seq.read(memory)?, buf)
})
}
fn oracle_keylet(
caller: &mut Caller<'_, VmState<'_>>,
account: InBytes,
doc_id: InU32,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.oracle_keylet(account.read(memory)?, doc_id.read(memory)?, buf)
})
}
fn paychannel_keylet(
caller: &mut Caller<'_, VmState<'_>>,
account: InBytes,
destination: InBytes,
seq: InU32,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.paychannel_keylet(
account.read(memory)?,
destination.read(memory)?,
seq.read(memory)?,
buf,
)
})
}
fn permissioned_domain_keylet(
caller: &mut Caller<'_, VmState<'_>>,
account: InBytes,
seq: InU32,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.permissioned_domain_keylet(account.read(memory)?, seq.read(memory)?, buf)
})
}
fn signer_list_keylet(
caller: &mut Caller<'_, VmState<'_>>,
account: InBytes,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.signer_list_keylet(account.read(memory)?, buf)
})
}
fn ticket_keylet(
caller: &mut Caller<'_, VmState<'_>>,
account: InBytes,
seq: InU32,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.ticket_keylet(account.read(memory)?, seq.read(memory)?, buf)
})
}
fn vault_keylet(
caller: &mut Caller<'_, VmState<'_>>,
account: InBytes,
seq: InU32,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.vault_keylet(account.read(memory)?, seq.read(memory)?, buf)
})
}
fn sponsorship_keylet(
caller: &mut Caller<'_, VmState<'_>>,
sponsor: InBytes,
sponsee: InBytes,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.sponsorship_keylet(sponsor.read(memory)?, sponsee.read(memory)?, buf)
})
}
fn loan_broker_keylet(
caller: &mut Caller<'_, VmState<'_>>,
owner: InBytes,
seq: InU32,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.loan_broker_keylet(owner.read(memory)?, seq.read(memory)?, buf)
})
}
fn loan_keylet(
caller: &mut Caller<'_, VmState<'_>>,
loan_broker_id: InBytes,
loan_seq: InU32,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.loan_keylet(loan_broker_id.read(memory)?, loan_seq.read(memory)?, buf)
})
}
fn sha512_half(
caller: &mut Caller<'_, VmState<'_>>,
data: InBytes,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.sha512_half(data.read(memory)?, buf)
})
}
/// The one body with nothing to answer: the wasm function has no result to
/// carry a code, so a malformed argument leaves the guest none the wiser and
/// the host uncalled.
fn trace(
caller: &mut Caller<'_, VmState<'_>>,
msg: InStr,
data_type: TraceCode,
data: InBytes,
) -> CallResult<()> {
let memory = guest_memory(caller)?;
let host = caller.data().host;
Ok(host.trace(msg.read(memory)?, data_type.read()?, data.read(memory)?)?)
}
fn update_data(caller: &mut Caller<'_, VmState<'_>>, data: InBytes) -> CallResult<i32> {
let memory = guest_memory(caller)?;
let host = caller.data().host;
Ok(host.update_data(data.read(memory)?)?)
}
fn get_nft(
caller: &mut Caller<'_, VmState<'_>>,
account: InBytes,
nft_id: InBytes,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.get_nft(account.read(memory)?, nft_id.read(memory)?, buf)
})
}
fn get_nft_issuer(
caller: &mut Caller<'_, VmState<'_>>,
nft_id: InBytes,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.get_nft_issuer(nft_id.read(memory)?, buf)
})
}
fn get_nft_taxon(
caller: &mut Caller<'_, VmState<'_>>,
nft_id: InBytes,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.get_nft_taxon(nft_id.read(memory)?, buf)
})
}
fn get_nft_flags(caller: &mut Caller<'_, VmState<'_>>, nft_id: InBytes) -> CallResult<i32> {
let memory = guest_memory(caller)?;
let host = caller.data().host;
Ok(host.get_nft_flags(nft_id.read(memory)?)?)
}
fn get_nft_transfer_fee(
caller: &mut Caller<'_, VmState<'_>>,
nft_id: InBytes,
) -> CallResult<i32> {
let memory = guest_memory(caller)?;
let host = caller.data().host;
Ok(host.get_nft_transfer_fee(nft_id.read(memory)?)?)
}
fn get_nft_sequence(
caller: &mut Caller<'_, VmState<'_>>,
nft_id: InBytes,
out: OutBytes,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.get_nft_sequence(nft_id.read(memory)?, buf)
})
}
fn float_from_int(
caller: &mut Caller<'_, VmState<'_>>,
x: i64,
out: OutBytes,
mode: i32,
) -> CallResult<i32> {
write_into(caller, out, |host, out| host.float_from_int(x, out, mode))
}
fn float_from_uint(
caller: &mut Caller<'_, VmState<'_>>,
x: InBytes,
out: OutBytes,
mode: i32,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.float_from_uint(x.read(memory)?, buf, mode)
})
}
fn float_from_stamount(
caller: &mut Caller<'_, VmState<'_>>,
amount: InBytes,
out: OutBytes,
mode: i32,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.float_from_stamount(amount.read(memory)?, buf, mode)
})
}
fn float_from_stnumber(
caller: &mut Caller<'_, VmState<'_>>,
number: InBytes,
out: OutBytes,
mode: i32,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.float_from_stnumber(number.read(memory)?, buf, mode)
})
}
fn float_to_int(
caller: &mut Caller<'_, VmState<'_>>,
x: InBytes,
out: OutBytes,
mode: i32,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.float_to_int(x.read(memory)?, buf, mode)
})
}
fn float_to_mant_exp(
caller: &mut Caller<'_, VmState<'_>>,
x: InBytes,
mantissa_out: OutBytes,
exponent_out: OutBytes,
) -> CallResult<i32> {
write_mant_exp(
caller,
mantissa_out,
exponent_out,
|host, memory, mantissa, exponent| {
host.float_to_mant_exp(x.read(memory)?, mantissa, exponent)
},
)
}
fn float_from_mant_exp(
caller: &mut Caller<'_, VmState<'_>>,
mantissa: i64,
exponent: i32,
out: OutBytes,
mode: i32,
) -> CallResult<i32> {
write_into(caller, out, |host, out| {
host.float_from_mant_exp(mantissa, exponent, out, mode)
})
}
fn float_compare(
caller: &mut Caller<'_, VmState<'_>>,
x: InBytes,
y: InBytes,
) -> CallResult<i32> {
let memory = guest_memory(caller)?;
let host = caller.data().host;
Ok(host.float_compare(x.read(memory)?, y.read(memory)?)?)
}
fn float_add(
caller: &mut Caller<'_, VmState<'_>>,
x: InBytes,
y: InBytes,
out: OutBytes,
mode: i32,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.float_add(x.read(memory)?, y.read(memory)?, buf, mode)
})
}
fn float_subtract(
caller: &mut Caller<'_, VmState<'_>>,
x: InBytes,
y: InBytes,
out: OutBytes,
mode: i32,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.float_subtract(x.read(memory)?, y.read(memory)?, buf, mode)
})
}
fn float_multiply(
caller: &mut Caller<'_, VmState<'_>>,
x: InBytes,
y: InBytes,
out: OutBytes,
mode: i32,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.float_multiply(x.read(memory)?, y.read(memory)?, buf, mode)
})
}
fn float_divide(
caller: &mut Caller<'_, VmState<'_>>,
x: InBytes,
y: InBytes,
out: OutBytes,
mode: i32,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.float_divide(x.read(memory)?, y.read(memory)?, buf, mode)
})
}
fn float_power(
caller: &mut Caller<'_, VmState<'_>>,
x: InBytes,
n: i32,
out: OutBytes,
mode: i32,
) -> CallResult<i32> {
write_buffered(caller, out, |host, memory, buf| {
host.float_power(x.read(memory)?, n, buf, mode)
})
}
}

View File

@@ -0,0 +1,406 @@
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::{Bodies, 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::<Bodies>(&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)]
#[cfg_attr(coverage_nightly, coverage(off))]
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");
}
}

File diff suppressed because it is too large Load Diff

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,803 @@
//! 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.
//!
//! These screen with `check`, which reports the earliest refusal; the last section
//! is what `check_all` adds.
mod support;
use support::{ENTRY, FakeHost, ONE_PAGE, PLENTY_OF_GAS, assemble, import, module};
use xrpl_host_functions::{HostFunctionSpec, WasmValType};
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, full
/// signatures and hand-written. The count is asserted against the ABI so a
/// function added to it cannot be left out here.
///
/// Hand-written is the point: these are a statement of the wire the ABI's derived
/// table did not produce, so putting them through `check` compares the two rather
/// than comparing the table with itself.
const ALL_IMPORTS: [&str; 63] = [
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::SPONSORSHIP_ID,
import::LOAN_BROKER_ID,
import::LOAN_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_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(_));
}
/// An import naming a real host function with the wrong type is refused, at a
/// stage of its own since the ABI does have the function the guest asked for.
///
/// The run half is what the refusal is worth: without it this module reaches the
/// engine and parts from the linker at instantiation, which is a fault a node
/// discovers rather than one a transaction is turned away for.
#[test]
fn an_import_with_the_wrong_signature_does_not_pass() {
let wat = module(
&[
r#"(import "host_lib" "ldgr_index" (func $f (param i64 i64) (result i32)))"#,
ONE_PAGE,
],
"(i32.const 0)",
);
let refusal = assert_stage!(refusal(&wat), CheckError::Signature(_)).to_string();
assert_eq!(
refusal,
"signature: 'ldgr_index' expected '(i32, i32) -> i32', found '(i64, i64) -> i32'"
);
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)",
),
),
(
"an import with the wrong signature",
module(
&[
r#"(import "host_lib" "ldgr_index" (func $f (param i64 i64) (result i32)))"#,
ONE_PAGE,
],
"(i32.const 0)",
),
),
(
"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}"
);
}
}
/// The signatures screening derives are the ones the linker registers: a module
/// importing all 60 host functions at the type `HostFunctionSpec` derives must
/// instantiate.
///
/// Unlike [`ALL_IMPORTS`], the other side of this is live code — the registration
/// as it is rather than a description of it — so it is what a changed engine has to
/// answer to. **What it cannot see is the table and the linker being wrong the same
/// way**, the closures being generated from this very table; that is what
/// [`ALL_IMPORTS`] and `generated_abi.rs`'s 60 literals are for.
#[test]
fn the_derived_signatures_are_what_the_linker_registers() {
let declarations: Vec<String> = HostFunctionSpec::ALL
.iter()
.copied()
.map(derived_import)
.collect();
let mut parts: Vec<&str> = declarations.iter().map(String::as_str).collect();
parts.push(ONE_PAGE);
let host = FakeHost::new();
let wasm = assemble(&module(&parts, "(i32.const 0)"));
let outcome = xrpl_wasm_vm::run(&wasm, PLENTY_OF_GAS, &host, ENTRY)
.expect("every import built from the ABI's table must link");
assert_eq!(outcome.result, 0);
}
/// One `(import …)` declaration, spelled out of the ABI's derived signature rather
/// than by hand — the opposite of [`ALL_IMPORTS`].
fn derived_import(function: HostFunctionSpec) -> String {
let types: Vec<&str> = function
.wasm_params()
.iter()
.copied()
.map(spelled)
.collect();
let params = match types.as_slice() {
[] => String::new(),
types => format!(" (param {})", types.join(" ")),
};
let result = match function.wasm_result() {
Some(result) => format!(" (result {})", spelled(result)),
None => String::new(),
};
format!(
r#"(import "host_lib" "{}" (func{params}{result}))"#,
function.wasm_name()
)
}
fn spelled(declared: WasmValType) -> &'static str {
match declared {
WasmValType::I32 => "i32",
WasmValType::I64 => "i64",
}
}
/// 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}"
);
}
/// The corruption fixtures below are written as hex strings, which is how the old Beast suite
/// carried them — the bytes are deliberately malformed, so there is nothing to assemble them
/// from.
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()
}
/// 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() {
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() {
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(_));
}
}
// ---------------------------------------------------------------------------
// Reporting every refusal
// ---------------------------------------------------------------------------
/// A module that breaks every rule past compiling, once each.
fn a_module_faulting_at_every_stage() -> String {
format!(
r#"(module
(import "host_lib" "no_such_function" (func (param i32) (result i32)))
(import "host_lib" "ldgr_index" (func (param i64 i64) (result i32)))
(memory (export "memory") {pages})
(table (export "t") {elements} funcref)
(func (export "{ENTRY}") (result i64) (i64.const 0)))"#,
pages = MAX_MEMORY_PAGES + 1,
elements = MAX_TABLE_ELEMENTS + 1,
)
}
#[test]
fn check_all_reports_a_refusal_from_every_stage() {
let refusals = xrpl_wasm_vm::check_all(&assemble(&a_module_faulting_at_every_stage()), ENTRY)
.expect_err("this module breaks every rule past compiling");
assert!(
matches!(
refusals.as_slice(),
[
CheckError::Import(_),
CheckError::Signature(_),
CheckError::EntryPoint(_),
CheckError::Memory(_),
CheckError::Table(_),
]
),
"{refusals:?}"
);
}
/// What lets the consensus path keep fail-fast without a second implementation of
/// the stage order to drift from.
#[test]
fn check_reports_what_check_all_reports_first() {
let wasm = assemble(&a_module_faulting_at_every_stage());
let first = xrpl_wasm_vm::check(&wasm, ENTRY).expect_err("five faults");
let all = xrpl_wasm_vm::check_all(&wasm, ENTRY).expect_err("five faults");
assert_eq!(first.to_string(), all[0].to_string());
}
/// Nothing to report is `Ok`, never an empty `Vec`.
#[test]
fn check_all_passes_a_runnable_contract() {
let wat = module(
&[import::LDGR_INDEX, ONE_PAGE],
"(call $ldgr_index (i32.const 0) (i32.const 4))",
);
if let Err(refusals) = xrpl_wasm_vm::check_all(&assemble(&wat), ENTRY) {
panic!("expected this module to pass, but: {refusals:?}\n{wat}");
}
}

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,25 +0,0 @@
# Runtime image for the perf/test xrpld build with all amendments Supported::Yes.
# Installs the .deb into ubuntu:jammy (matching rippleci/xrpld): gives
# /usr/bin/xrpld, /etc/xrpld/xrpld.cfg, and the xrpld user.
# NOT for production validators.
ARG BASE_IMAGE=ubuntu:jammy
FROM ${BASE_IMAGE}
# Build context must contain the supported package as xrpld.deb.
COPY xrpld.deb /tmp/xrpld.deb
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends ca-certificates jq /tmp/xrpld.deb; \
rm -rf /var/lib/apt/lists/* /tmp/xrpld.deb; \
id -u xrpld >/dev/null 2>&1 || \
useradd --system --home-dir /var/lib/xrpld --shell /sbin/nologin --user-group xrpld; \
mkdir -p /var/log/xrpld /var/lib/xrpld; \
chown -R xrpld:xrpld /var/log/xrpld /var/lib/xrpld; \
# Symlink for consumers that exec /opt/xrpld/bin/xrpld.
mkdir -p /opt/xrpld/bin; \
ln -sf /usr/bin/xrpld /opt/xrpld/bin/xrpld
EXPOSE 2459/tcp 5005/tcp 6006/tcp
USER xrpld
ENTRYPOINT ["/usr/bin/xrpld"]

View File

@@ -1,279 +0,0 @@
# AMM Curve Implementation Template
Guide for adding a new curve type to the XRPL pluggable AMM curve framework.
## Architecture Overview
Each token pair can have **multiple AMM pools**, one per curve type. The keylet
hash includes `curveType`, so `RLUSD/USD ConstantProduct` and `RLUSD/USD
StableSwap` are separate ledger entries with separate pseudo-accounts and LP
tokens. BookStep automatically routes through the deepest-liquidity pool.
### Key Components
| Component | File | Purpose |
| --------------------- | --------------------------------------------------- | --------------------------------------- |
| CurveInterface | `include/xrpl/ledger/helpers/AMMCurve.h` | Abstract base for all curves |
| Curve implementations | `src/libxrpl/ledger/helpers/AMMCurve.cpp` | CP, CL, StableSwap, Weighted |
| Tick math | `src/libxrpl/ledger/helpers/AMMTickMath.cpp` | CL tick/sqrt price conversions |
| Fee collection | `src/libxrpl/tx/transactors/dex/AMMCollectFees.cpp` | CL position fee harvesting |
| Multi-curve routing | `src/libxrpl/tx/paths/BookStep.cpp` | Picks best pool per pair |
| Keylet hashing | `src/libxrpl/protocol/Indexes.cpp` | `amm(asset1, asset2, curveType)` |
| LP token identity | `src/libxrpl/protocol/AMMCore.cpp` | `ammLPTCurrency(cur1, cur2, curveType)` |
## Step 1: Define the Curve Type
Add to `include/xrpl/protocol/AMMCore.h`:
```cpp
enum CurveType : std::uint8_t
{
ctCONSTANT_PRODUCT = 0,
ctCONCENTRATED_LIQUIDITY = 1,
ctSTABLE_SWAP = 2,
ctWEIGHTED = 3,
ctYOUR_CURVE = N, // next available ID
};
```
## Step 2: Define SFields for Curve Parameters
Add to `include/xrpl/protocol/detail/sfields.macro`:
```cpp
// Use the appropriate type (UINT8, UINT16, UINT32, UINT64, UINT256, AMOUNT, etc.)
// Check existing field codes to avoid collisions.
TYPED_SFIELD(sfYourParam, UINT32, <next_available_code>)
```
## Step 3: Implement the CurveInterface
Add a new class in `src/libxrpl/ledger/helpers/AMMCurve.cpp`:
```cpp
class YourCurve final : public CurveInterface
{
public:
Expected<STAmount, TER>
swapIn(
STAmount const& poolIn,
STAmount const& poolOut,
STAmount const& assetIn,
std::uint16_t tfee,
STObject const* curveParams) const override
{
if (!curveParams)
return Unexpected(tecINTERNAL);
auto const param = curveParams->getFieldU32(sfYourParam);
auto const f = feeMult(tfee); // fee multiplier (1 - fee)
Number const x = poolIn; // STAmount -> Number via implicit conversion
Number const y = poolOut;
Number const dx = Number(assetIn) * f;
// --- YOUR INVARIANT MATH HERE ---
// Compute output amount `dy` from your invariant
// F(x, y) = k => F(x + dx, y - dy) = k => solve for dy
Number const dy = /* ... */;
if (dy <= Number{0})
return Unexpected(tecAMM_FAILED);
// Round output DOWN (favorable to pool)
NumberRoundModeGuard const mg(Number::downward);
return toSTAmount(poolOut.issue(), dy);
}
Expected<STAmount, TER>
swapOut(
STAmount const& poolIn,
STAmount const& poolOut,
STAmount const& assetOut,
std::uint16_t tfee,
STObject const* curveParams) const override
{
if (!curveParams)
return Unexpected(tecINTERNAL);
auto const param = curveParams->getFieldU32(sfYourParam);
auto const f = feeMult(tfee);
Number const x = poolIn;
Number const y = poolOut;
// --- YOUR INVARIANT MATH (INVERSE) ---
// Given desired output, compute required input
// F(x, y) = k => F(x + dx, y - assetOut) = k => solve for dx
Number const dx = /* ... */ / f;
if (dx <= Number{0})
return Unexpected(tecAMM_FAILED);
// Round input UP (favorable to pool)
NumberRoundModeGuard const mg(Number::upward);
return toSTAmount(poolIn.issue(), dx);
}
Expected<Number, TER>
spotPrice(
STAmount const& poolIn,
STAmount const& poolOut,
std::uint16_t tfee,
STObject const* curveParams) const override
{
if (!curveParams)
return Unexpected(tecINTERNAL);
auto const param = curveParams->getFieldU32(sfYourParam);
auto const f = feeMult(tfee);
// Marginal price: -dF/dx / dF/dy evaluated at current reserves
// Divided by (1 - fee) for the taker-facing price
Number const price = /* partial derivatives of your invariant */;
return price / f;
}
TER
validateParams(STObject const& curveParams) const override
{
// Validate curve-specific parameters at pool creation
if (!curveParams.isFieldPresent(sfYourParam))
return temMALFORMED;
auto const param = curveParams.getFieldU32(sfYourParam);
if (param < MIN_YOUR_PARAM || param > MAX_YOUR_PARAM)
return temMALFORMED;
return tesSUCCESS;
}
Expected<STAmount, TER>
initialLPTokens(
STAmount const& asset1,
STAmount const& asset2,
Issue const& lptIssue,
STObject const* curveParams) const override
{
if (!curveParams)
return Unexpected(tecINTERNAL);
// Compute initial LP token amount from deposits
// For many curves: geometric mean sqrt(asset1 * asset2)
// Or curve-specific: D for StableSwap, weighted geometric mean, etc.
Number const lp = /* ... */;
return toSTAmount(lptIssue, lp);
}
};
```
## Step 4: Register the Singleton and Dispatch
In `AMMCurve.cpp`, add the singleton and switch case:
```cpp
// At file scope (inside anonymous namespace)
static YourCurve const yourCurve_;
// In getCurve():
case ctYOUR_CURVE:
if (rules.enabled(featureAMMCurves))
return &yourCurve_;
return nullptr;
```
## Step 5: Update AMMCreate Validation
In `src/libxrpl/tx/transactors/dex/AMMCreate.cpp`:
1. Update the max curve type check: `if (curveType > ctYOUR_CURVE)`
2. Add params setup in the `applyGuts` section:
```cpp
else if (curveType == ctYOUR_CURVE)
{
ammSle->setFieldU32(
sfYourParam, ctx_.tx.getFieldU32(sfYourParam));
}
```
## Step 6: Multi-Curve Routing (BookStep)
BookStep automatically discovers and routes through the best pool for each
token pair. When a new curve type is added, update the loop upper bound in
`src/libxrpl/tx/paths/BookStep.cpp`:
```cpp
for (std::uint8_t ct = 0; ct <= ctYOUR_CURVE; ++ct)
{
auto const ammSle = ctx.view.read(keylet::amm(in, out, ct));
if (!ammSle || ammSle->getFieldAmount(sfLPTokenBalance) == beast::zero)
continue;
if (!bestAmm ||
ammSle->getFieldAmount(sfLPTokenBalance) >
bestAmm->getFieldAmount(sfLPTokenBalance))
bestAmm = ammSle;
}
```
The pool with the highest LP token balance wins. Curve-specific swap dispatch
happens automatically via `getCurve()` in `AMMLiquidity`/`AMMOffer`.
## Step 7: Keylet and LP Token Identity
Each curve type for the same token pair gets a unique ledger key and LP token:
- **Keylet**: `keylet::amm(asset1, asset2, curveType)` hashes `curveType` into
the AMM's ledger key (when curveType != 0, for backward compatibility)
- **LP token**: `ammLPTCurrency(cur1, cur2, curveType)` hashes `curveType` into
the LP token currency code
No changes needed here when adding a new curve — the default parameter
propagates automatically.
## Concentrated Liquidity Extras
The CL curve type uses additional infrastructure not needed by other curves:
- **AMMTickMath** (`AMMTickMath.h/cpp`): `tickToSqrtPrice()`, `sqrtPriceToTick()`,
`isValidTick()` for tick-based price representation
- **AMMCollectFees** (`AMMCollectFees.h/cpp`): Transactor for position owners to
collect accumulated swap fees using the Uniswap V3 fee growth formula
- **Ledger entries**: `ltAMM_POSITION` (per-user tick range + liquidity) and
`ltAMM_TICK` (per-tick fee growth and liquidity tracking)
- **SFields**: `sfFeeGrowthGlobal0/1`, `sfFeeGrowthOutside0/1`,
`sfFeeGrowthInsideLast0/1` (UINT256, Q128.128 fixed-point),
`sfActiveLiquidity`, `sfPositionLiquidity`, `sfLiquidityGross/Net` (UINT64)
## Math Utilities Available
- `Number`: arbitrary-precision decimal arithmetic (see `include/xrpl/basics/Number.h`)
- `power(f, n)`: f^n (integer exponent)
- `power(f, n, d)`: f^(n/d) (rational exponent)
- `root(f, d)`: f^(1/d)
- `root2(f)`: sqrt(f)
- `feeMult(tfee)`: returns `1 - fee` as Number
- `feeMultHalf(tfee)`: returns `1 - fee/2` as Number
- `toSTAmount(issue, number)`: convert Number to STAmount
- `NumberRoundModeGuard`: RAII guard for rounding direction
## Rounding Convention
- `swapIn` output: round DOWN (pool keeps the rounding dust)
- `swapOut` input: round UP (taker pays the rounding dust)
- Use `NumberRoundModeGuard` to set the rounding mode before `toSTAmount()`
## Testing
Add tests in `src/test/app/AMMCurves_test.cpp`:
1. `swapIn` and `swapOut` are inverses (within rounding tolerance)
2. Invariant is preserved: `F(reserves_new) >= F(reserves_old)` after every swap
3. `spotPrice` matches actual swap rate at infinitesimal amounts
4. Edge cases: zero input, max input, min reserves
5. Parameter validation: `validateParams` rejects out-of-range values
6. Integration: create pool, deposit, swap, withdraw full cycle
E2E tests go in `src/test/app/AMMCurvesE2E_test.cpp` for full transaction
lifecycle tests (AMMCreate with curve params, swap through payment engine,
deposit/withdraw).

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

@@ -82,10 +82,4 @@ base64Encode(std::string_view s)
std::string
base64Decode(std::string_view data);
/** Decode a base64url-encoded string (RFC 4648 S5).
Converts '-' to '+' and '_' to '/', adds padding, then decodes.
*/
std::string
base64urlDecode(std::string_view data);
} // namespace xrpl

View File

@@ -11,7 +11,6 @@ struct Sections
static constexpr auto kCompression = "compression";
static constexpr auto kCrawl = "crawl";
static constexpr auto kDatabasePath = "database_path";
static constexpr auto kDatagramMonitor = "datagram_monitor";
static constexpr auto kDebugLogfile = "debug_logfile";
static constexpr auto kElbSupport = "elb_support";
static constexpr auto kFeatures = "features";
@@ -68,7 +67,6 @@ struct Sections
static constexpr auto kValidationSeed = "validation_seed";
static constexpr auto kValidatorKeys = "validator_keys";
static constexpr auto kValidatorKeyRevocation = "validator_key_revocation";
static constexpr auto kValidatorKeyType = "validator_key_type";
static constexpr auto kValidatorListKeys = "validator_list_keys";
static constexpr auto kValidatorListSites = "validator_list_sites";
static constexpr auto kValidatorListThreshold = "validator_list_threshold";

View File

@@ -528,7 +528,6 @@ public:
using iterator_category = std::bidirectional_iterator_tag;
using size_t = unsigned int;
using difference_type = int;
using value_type = Value;
using SelfType = ValueIteratorBase;
ValueIteratorBase();

View File

@@ -78,12 +78,6 @@ public:
return base_.succ(key, last);
}
std::optional<key_type>
pred(key_type const& key, std::optional<key_type> const& first = std::nullopt) const override
{
return base_.pred(key, first);
}
std::unique_ptr<SlesType::iter_base>
slesBegin() const override
{

View File

@@ -186,9 +186,6 @@ public:
std::optional<uint256>
succ(uint256 const& key, std::optional<uint256> const& last = std::nullopt) const override;
std::optional<uint256>
pred(uint256 const& key, std::optional<uint256> const& first = std::nullopt) const override;
SLE::const_pointer
read(Keylet const& k) const override;

View File

@@ -221,9 +221,6 @@ public:
std::optional<key_type>
succ(key_type const& key, std::optional<key_type> const& last = std::nullopt) const override;
std::optional<key_type>
pred(key_type const& key, std::optional<key_type> const& first = std::nullopt) const override;
SLE::const_pointer
read(Keylet const& k) const override;

View File

@@ -1,82 +0,0 @@
#pragma once
#include <xrpl/ledger/OpenView.h>
#include <memory>
namespace xrpl {
class OpenViewSandbox
{
private:
OpenView& parent_;
std::unique_ptr<OpenView> sandbox_;
public:
using key_type = ReadView::key_type;
OpenViewSandbox(OpenView& parent)
: parent_(parent), sandbox_(std::make_unique<OpenView>(kBatchView, parent))
{
}
void
rawErase(std::shared_ptr<SLE> const& sle)
{
sandbox_->rawErase(sle);
}
void
rawInsert(std::shared_ptr<SLE> const& sle)
{
sandbox_->rawInsert(sle);
}
void
rawReplace(std::shared_ptr<SLE> const& sle)
{
sandbox_->rawReplace(sle);
}
void
rawDestroyXRP(XRPAmount const& fee)
{
sandbox_->rawDestroyXRP(fee);
}
void
rawTxInsert(
key_type const& key,
std::shared_ptr<Serializer const> const& txn,
std::shared_ptr<Serializer const> const& metaData)
{
sandbox_->rawTxInsert(key, txn, metaData);
}
void
commit()
{
sandbox_->apply(parent_);
sandbox_ = std::make_unique<OpenView>(kBatchView, parent_);
}
void
discard()
{
sandbox_ = std::make_unique<OpenView>(kBatchView, parent_);
}
OpenView const&
view() const
{
return *sandbox_;
}
OpenView&
view()
{
return *sandbox_;
}
};
} // namespace xrpl

View File

@@ -154,19 +154,6 @@ public:
[[nodiscard]] virtual std::optional<key_type>
succ(key_type const& key, std::optional<key_type> const& last = std::nullopt) const = 0;
/** Return the key of the previous state item.
This returns the key of the first state item
whose key is less than the specified key. If
no such key is present, std::nullopt is returned.
If `first` is engaged, returns std::nullopt when
the key returned would be outside the open
interval (first, key).
*/
[[nodiscard]] virtual std::optional<key_type>
pred(key_type const& key, std::optional<key_type> const& first = std::nullopt) const = 0;
/**
* Return the state item associated with a key.
*

View File

@@ -5,10 +5,6 @@
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/MPTokenHelpers.h>
#include <xrpl/ledger/helpers/OfferHelpers.h>
#include <xrpl/ledger/helpers/RippleStateHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Keylet.h>
@@ -279,27 +275,6 @@ doWithdraw(
STAmount const& amount,
beast::Journal j);
enum class SendIssuerHandling { ihSENDER_NOT_ALLOWED, ihRECEIVER_NOT_ALLOWED, ihIGNORE };
enum class SendEscrowHandling { ehIGNORE, ehCHECK };
enum class SendAuthHandling { ahCHECK_SENDER, ahCHECK_RECEIVER, ahBOTH, ahNEITHER };
enum class SendFreezeHandling { fhCHECK_SENDER, fhCHECK_RECEIVER, fhBOTH, fhNEITHER };
enum class SendTransferHandling { thIGNORE, thCHECK };
enum class SendBalanceHandling { bhIGNORE, bhCHECK };
TER
canTransferFT(
ReadView const& view,
AccountID const& sender,
AccountID const& receiver,
STAmount const& amount,
beast::Journal j,
SendIssuerHandling issuerHandling,
SendEscrowHandling escrowHandling,
SendAuthHandling authHandling,
SendFreezeHandling freezeHandling,
SendTransferHandling transferHandling,
SendBalanceHandling balanceHandling);
/**
* Deleter function prototype. Returns the status of the entry deletion
* (if should not be skipped) and if the entry should be skipped. The status

View File

@@ -74,9 +74,6 @@ public:
[[nodiscard]] std::optional<key_type>
succ(ReadView const& base, key_type const& key, std::optional<key_type> const& last) const;
[[nodiscard]] std::optional<key_type>
pred(ReadView const& base, key_type const& key, std::optional<key_type> const& first) const;
[[nodiscard]] SLE::const_pointer
read(ReadView const& base, Keylet const& k) const;

View File

@@ -50,9 +50,6 @@ public:
[[nodiscard]] std::optional<key_type>
succ(key_type const& key, std::optional<key_type> const& last = std::nullopt) const override;
[[nodiscard]] std::optional<key_type>
pred(key_type const& key, std::optional<key_type> const& first = std::nullopt) const override;
[[nodiscard]] SLE::const_pointer
read(Keylet const& k) const override;

View File

@@ -56,9 +56,6 @@ public:
[[nodiscard]] std::optional<key_type>
succ(ReadView const& base, key_type const& key, std::optional<key_type> const& last) const;
[[nodiscard]] std::optional<key_type>
pred(ReadView const& base, key_type const& key, std::optional<key_type> const& first) const;
void
erase(SLE::ref sle);

View File

@@ -1,249 +0,0 @@
// Pluggable AMM curve architecture.
// Concentrated liquidity (CurveType 1) based on XRPL-Standards Discussion #427
// by Roman Thpt (@RomThpt), which adapted Uniswap v3 tick math, fee tier
// structure, and fee accounting to the XRPL. This implementation extends that
// work with a pluggable curve interface, StableSwap, and Smart AMM.
// See: https://github.com/XRPLF/XRPL-Standards/discussions/427
#pragma once
#include <expected>
#include <xrpl/basics/Number.h>
#include <xrpl/ledger/helpers/AMMHelpers.h>
#include <xrpl/protocol/AMMCore.h>
#include <xrpl/protocol/AmountConversions.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Quality.h>
#include <xrpl/protocol/Rules.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/TER.h>
namespace xrpl {
class ReadView;
class ApplyView;
struct CurveContext
{
ReadView const* view = nullptr;
uint256 const* ammID = nullptr;
// Optional out: set to true if a CL swap-walk terminated because it hit
// maxTickCrossings. Callers pass a pointer when they want to detect
// the cap (e.g. to emit tecAMM_TICK_CAP_HIT); pass nullptr to ignore.
bool* tickCapHit = nullptr;
};
class CurveInterface
{
public:
virtual ~CurveInterface() = default;
virtual std::expected<STAmount, TER>
swapIn(
STAmount const& poolIn,
STAmount const& poolOut,
STAmount const& assetIn,
std::uint16_t tfee,
STObject const* ammSle,
CurveContext const& ctx = {}) const = 0;
virtual std::expected<STAmount, TER>
swapOut(
STAmount const& poolIn,
STAmount const& poolOut,
STAmount const& assetOut,
std::uint16_t tfee,
STObject const* ammSle,
CurveContext const& ctx = {}) const = 0;
virtual std::expected<Number, TER>
spotPrice(
STAmount const& poolIn,
STAmount const& poolOut,
std::uint16_t tfee,
STObject const* ammSle,
CurveContext const& ctx = {}) const = 0;
[[nodiscard]] virtual TER
validateParams(STObject const& tx) const = 0;
virtual std::expected<STAmount, TER>
initialLPTokens(
STAmount const& asset1,
STAmount const& asset2,
Issue const& lptIssue,
STObject const* txParams) const = 0;
virtual bool
checkInvariant(
STAmount const& oldIn,
STAmount const& oldOut,
STAmount const& newIn,
STAmount const& newOut,
STObject const* ammSle) const = 0;
// Apply a realized swap to the AMM SLE. Trustline balances are updated
// by the caller (BookStep). For CP and StableSwap the pool state is fully
// implicit in trustline balances, so the default is a no-op. CL must
// mutate currentTick/activeLiquidity/feeGrowthGlobal and flip
// feeGrowthOutside on crossed ticks, because none of that state is
// derivable from the trustlines alone.
virtual TER
applySwap(
ApplyView& /*view*/,
uint256 const& /*ammID*/,
STAmount const& /*assetIn*/,
STAmount const& /*assetOut*/,
std::uint16_t /*tfee*/,
STObject const* /*curveParams*/) const
{
return tesSUCCESS;
}
};
CurveInterface const*
getCurve(std::uint8_t curveType, Rules const& rules);
// Max output the pool can deliver before crossing the next initialised
// tick boundary in the swap direction. Audit #19: caller (AMMLiquidity's
// offer generation) uses this to cap an advertised AMM offer at the
// current tick range, so the offer's quality reflects only the marginal
// range — not a blended average across multiple tick crossings.
//
// Returns std::nullopt if there is no further tick in the swap direction
// (no cap; offer is bounded only by reserves) or if the pool has no
// active liquidity. Returns 0 if the swap is already at the boundary.
// Caller should treat nullopt as "no cap".
std::optional<Number>
maxClOutputWithinCurrentRange(
ReadView const& view,
uint256 const& ammID,
STObject const& ammSle,
bool zeroForOne);
// Equivalent for CtBinned: cap the advertised AMMOffer output at the
// active bin's reserve. Without this cap, AMMLiquidity quotes against
// the pool's aggregate balance — which spans multiple bins at different
// prices — and BookStep mispricesthe offer's quality vs CLOB. Capping
// per active bin lets BookStep iterate naturally, getting each bin's
// marginal price one offer at a time.
//
// Returns std::nullopt if no active bin exists (empty pool) or the
// active bin lacks the output asset.
std::optional<Number>
maxBinnedOutputAtActiveBin(
ReadView const& view,
uint256 const& ammID,
STObject const& ammSle,
bool inIsAsset0);
// ─── CL tick bitmap ─────────────────────────────────────────────────────
//
// Sparse 256-tick-per-word presence bitmap. See spec §3.6.
// Convert a tick to its (wordIndex, bitInWord) position. Offset-binary so
// arithmetic stays in unsigned domain. Uses kTickBitmapOffset (= -minTick)
// from AMMCore.h — single source of truth, do NOT duplicate inline.
inline std::pair<std::uint16_t, std::uint8_t>
tickToBitmapPos(std::int32_t tick) noexcept
{
auto const offsetT = static_cast<std::uint32_t>(
tick + static_cast<std::int32_t>(kTickBitmapOffset));
return {static_cast<std::uint16_t>(offsetT >> 8),
static_cast<std::uint8_t>(offsetT & 0xFFu)};
}
inline std::int32_t
bitmapPosToTick(std::uint16_t wordIndex, std::uint8_t bitInWord) noexcept
{
auto const offsetT =
(static_cast<std::uint32_t>(wordIndex) << 8) | bitInWord;
return static_cast<std::int32_t>(offsetT) -
static_cast<std::int32_t>(kTickBitmapOffset);
}
// Bit-test for the bitmap word storage. `bits` is the raw `sfBitmapBits`
// value read off the SLE. Convention: bit i = LSB of byte (i/8), little-
// endian within bytes. The convention is internal — callers that write
// bits must use the same scheme (and do, via the maintenance helpers).
inline bool
bitmapBitIsSet(uint256 const& bits, std::uint8_t pos) noexcept
{
return ((bits.data()[pos / 8]) >> (pos % 8)) & 1u;
}
// AMMDeposit and AMMWithdraw call these when a tick crosses the
// "initialised / uninitialised" boundary (sfLiquidityGross transitioning
// 0↔>0). Each pool has a sparse set of `ltAMM_TICK_BITMAP` SLEs covering
// 256 ticks each; setting / clearing creates and deletes those SLEs on
// demand. Idempotent — calling set on an already-set bit is a no-op.
//
// Returns tesSUCCESS on the happy path. The current implementation has
// no failure path beyond the AMM SLE being missing; reserved as TER for
// forward compatibility.
TER
setTickBitmap(ApplyView& view, uint256 const& ammID, std::int32_t tick, beast::Journal j);
TER
clearTickBitmap(ApplyView& view, uint256 const& ammID, std::int32_t tick, beast::Journal j);
inline std::uint8_t
getCurveType(SLE const& ammSle)
{
if (ammSle.isFieldPresent(sfCurveType))
return ammSle.getFieldU8(sfCurveType);
return CtConstantProduct;
}
template <typename TIn, typename TOut>
TOut
curveSwapIn(
TAmounts<TIn, TOut> const& pool,
TIn const& assetIn,
std::uint16_t tfee,
std::uint8_t curveType,
STObject const* ammSle,
CurveContext const& cctx = {})
{
if (curveType == CtConstantProduct)
return swapAssetIn(pool, assetIn, tfee);
if (auto const* curve = getCurve(curveType, *getCurrentTransactionRules()))
{
auto const stPoolIn = toSTAmount(pool.in);
auto const stPoolOut = toSTAmount(pool.out);
auto const stAssetIn = toSTAmount(assetIn);
if (auto const result = curve->swapIn(stPoolIn, stPoolOut, stAssetIn, tfee, ammSle, cctx))
return get<TOut>(*result);
}
return toAmount<TOut>(getAsset(pool.out), 0);
}
template <typename TIn, typename TOut>
TIn
curveSwapOut(
TAmounts<TIn, TOut> const& pool,
TOut const& assetOut,
std::uint16_t tfee,
std::uint8_t curveType,
STObject const* ammSle,
CurveContext const& cctx = {})
{
if (curveType == CtConstantProduct)
return swapAssetOut(pool, assetOut, tfee);
if (auto const* curve = getCurve(curveType, *getCurrentTransactionRules()))
{
auto const stPoolIn = toSTAmount(pool.in);
auto const stPoolOut = toSTAmount(pool.out);
auto const stAssetOut = toSTAmount(assetOut);
if (auto const result = curve->swapOut(stPoolIn, stPoolOut, stAssetOut, tfee, ammSle, cctx))
return get<TIn>(*result);
}
return toMaxAmount<TIn>(getAsset(pool.in));
}
} // namespace xrpl

View File

@@ -837,8 +837,7 @@ ammLPHolds(
Asset const& asset2,
AccountID const& ammAccount,
AccountID const& lpAccount,
beast::Journal const j,
std::uint8_t curveType = CtConstantProduct);
beast::Journal const j);
STAmount
ammLPHolds(
@@ -866,12 +865,7 @@ ammAccountHolds(ReadView const& view, AccountID const& ammAccountID, Asset const
* AMM object and account are deleted. Otherwise tecINCOMPLETE is returned.
*/
TER
deleteAMMAccount(
Sandbox& view,
Asset const& asset,
Asset const& asset2,
beast::Journal j,
std::uint8_t curveType = 0);
deleteAMMAccount(Sandbox& view, Asset const& asset, Asset const& asset2, beast::Journal j);
/**
* Initialize Auction and Voting slots and set the trading/discounted fee.

View File

@@ -1,19 +0,0 @@
#pragma once
#include <xrpl/basics/Number.h>
#include <xrpl/protocol/AMMCore.h>
#include <cstdint>
namespace xrpl {
Number
tickToSqrtPrice(std::int32_t tick);
std::int32_t
sqrtPriceToTick(Number const& sqrtPrice);
bool
isValidTick(std::int32_t tick, std::int32_t tickSpacing);
} // namespace xrpl

View File

@@ -338,17 +338,6 @@ adjustLoanBrokerOwnerCount(
[[nodiscard]] Rate
transferRate(ReadView const& view, AccountID const& issuer);
/**
* Returns the transfer fee charged for a specific currency of the issuer.
* A per-currency TransferFee on the currency's TokenIssuance overrides the
* account-wide TransferRate.
*/
[[nodiscard]] Rate
transferRate(ReadView const& view, AccountID const& issuer, Currency const& currency);
[[nodiscard]] Rate
transferRate(ReadView const& view, Issue const& issue);
/**
* Generate a pseudo-account address from a pseudo owner key.
* @param pseudoOwnerKey The key to generate the address from

View File

@@ -1,102 +0,0 @@
#pragma once
#include <xrpl/basics/Log.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/STData.h>
#include <xrpl/protocol/STJson.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <map>
namespace xrpl {
class ContractDataMap : public std::map<xrpl::AccountID, std::pair<bool, STJson>>
{
public:
uint32_t modifiedCount = 0;
};
class ContractEventMap : public std::map<std::string, STJson>
{
};
namespace contract {
/** The maximum number of data modifications in a single function. */
int64_t constexpr maxDataModifications = 1000;
/** The maximum number of bytes the data can occupy. */
int64_t constexpr maxContractDataSize = 1024;
/** The multiplier for contract data size calculations. */
int64_t constexpr dataByteMultiplier = 512;
/** The cost multiplier of creating a contract in bytes. */
int64_t constexpr createByteMultiplier = 500ULL;
/** The value to return when the fee calculation failed. */
int64_t constexpr feeCalculationFailed = 0x7FFFFFFFFFFFFFFFLL;
/** The maximum number of contract parameters that can be in a transaction. */
std::size_t constexpr maxContractParams = 8;
/** The maximum number of contract functions that can be in a transaction. */
std::size_t constexpr maxContractFunctions = 32;
int64_t
contractCreateFee(uint64_t byteCount);
NotTEC
preflightFunctions(STTx const& tx, beast::Journal j);
NotTEC
preflightInstanceParameters(STTx const& tx, beast::Journal j);
bool
validateParameterMapping(STArray const& params, STArray const& values, beast::Journal j);
NotTEC
preflightInstanceParameterValues(STTx const& tx, beast::Journal j);
NotTEC
preflightFlagParameters(STArray const& parameters, beast::Journal j);
bool
isValidParameterFlag(std::uint32_t flags);
TER
preclaimFlagParameters(
ReadView const& view,
AccountID const& sourceAccount,
AccountID const& contractAccount,
STArray const& parameters,
beast::Journal j);
TER
doApplyFlagParameters(
ApplyView& view,
STTx const& tx,
AccountID const& sourceAccount,
AccountID const& contractAccount,
STArray const& parameters,
XRPAmount const& priorBalance,
beast::Journal j);
TER
finalizeContractData(
ServiceRegistry& registry,
ApplyView& view,
AccountID const& contractAccount,
ContractDataMap const& dataMap,
ContractEventMap const& eventMap,
uint256 const& txnID);
} // namespace contract
} // namespace xrpl

View File

@@ -1,49 +0,0 @@
#pragma once
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/TER.h>
#include <cstdint>
namespace xrpl {
/**
* Settle a holder's accrued coupons on their MPToken.
*
* Adds units * (AccruedPerUnit - CouponIndex) to CouponAccrued, rounded
* once downward to the coupon asset, then advances CouponIndex to
* AccruedPerUnit. Units are MPTAmount + LockedAmount. Increments the
* schedule's ClaimantCount when CouponAccrued becomes non-zero on a
* holder that had none. The caller must view.update() both entries on
* tesSUCCESS.
*/
[[nodiscard]] TER
couponSettleMPToken(SLE::ref schedule, SLE::ref mptoken, beast::Journal j);
/**
* Settle a holder against the schedule of the issuance their MPToken
* belongs to, if that issuance carries lsfMPTCouponSchedule. Does
* nothing when the flag is clear, which is the case for every issuance
* without a coupon schedule.
*
* This is the entry point called from the MPT unit-change helpers.
*/
[[nodiscard]] TER
couponSettleIfScheduled(
ApplyView& view,
SLE::const_ref issuance,
SLE::ref mptoken,
beast::Journal j);
/**
* The units a holder is credited for: MPTAmount + LockedAmount.
*/
[[nodiscard]] std::uint64_t
couponHolderUnits(SLE::const_ref mptoken);
} // namespace xrpl

View File

@@ -2,10 +2,8 @@
#include <xrpl/basics/Log.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/MPTokenHelpers.h>
#include <xrpl/ledger/helpers/RippleStateHelpers.h>
@@ -31,291 +29,6 @@
namespace xrpl {
/**
* Validate that @p account may lock @p amount of a token for later delivery
* to @p dest.
*
* The lock-side counterpart of escrowUnlockPreclaimHelper: every issuer
* control (locking opt-in, authorization, freeze/lock, transferability,
* spendable balance) that gates locking token value lives here, so any
* transactor that locks funds applies the same rules. The signature is
* view-based rather than PreclaimContext-based so it can also run from
* doApply.
*/
template <ValidIssueType T>
TER
escrowLockPreclaimHelper(
ReadView const& view,
AccountID const& account,
AccountID const& dest,
STAmount const& amount,
beast::Journal j);
template <>
inline TER
escrowLockPreclaimHelper<Issue>(
ReadView const& view,
AccountID const& account,
AccountID const& dest,
STAmount const& amount,
beast::Journal j)
{
auto const& issue = amount.get<Issue>();
auto const& issuer = amount.getIssuer();
// If the issuer is the same as the account, return tecNO_PERMISSION
if (issuer == account)
return tecNO_PERMISSION;
// If the lsfAllowTrustLineLocking is not enabled, return tecNO_PERMISSION
auto const sleIssuer = view.read(keylet::account(issuer));
if (!sleIssuer)
return tecNO_ISSUER;
if (!sleIssuer->isFlag(lsfAllowTrustLineLocking))
return tecNO_PERMISSION;
// If the account does not have a trustline to the issuer, return tecNO_LINE
auto const sleRippleState = view.read(keylet::trustLine(account, issuer, issue.currency));
if (!sleRippleState)
return tecNO_LINE;
STAmount const balance = (*sleRippleState)[sfBalance];
// If balance is positive, issuer must have higher address than account
if (balance > beast::kZero && issuer < account)
return tecNO_PERMISSION; // LCOV_EXCL_LINE
// If balance is negative, issuer must have lower address than account
if (balance < beast::kZero && issuer > account)
return tecNO_PERMISSION; // LCOV_EXCL_LINE
// If the issuer has requireAuth set, check if the account is authorized
if (auto const ter = requireAuth(view, issue, account); !isTesSuccess(ter))
return ter;
// If the issuer has requireAuth set, check if the destination is authorized
if (auto const ter = requireAuth(view, issue, dest); !isTesSuccess(ter))
return ter;
// If the issuer has frozen the account, return tecFROZEN
if (isFrozen(view, account, issue))
return tecFROZEN;
// If the issuer has frozen the destination, return tecFROZEN
if (isFrozen(view, dest, issue))
return tecFROZEN;
STAmount const spendableAmount =
accountHolds(view, account, issue.currency, issuer, FreezeHandling::IgnoreFreeze, j);
// If the balance is less than or equal to 0, return tecINSUFFICIENT_FUNDS
if (spendableAmount <= beast::kZero)
return tecINSUFFICIENT_FUNDS;
// If the spendable amount is less than the amount, return
// tecINSUFFICIENT_FUNDS
if (spendableAmount < amount)
return tecINSUFFICIENT_FUNDS;
// If the amount is not addable to the balance, return tecPRECISION_LOSS
if (!canAdd(spendableAmount, amount))
return tecPRECISION_LOSS;
return tesSUCCESS;
}
template <>
inline TER
escrowLockPreclaimHelper<MPTIssue>(
ReadView const& view,
AccountID const& account,
AccountID const& dest,
STAmount const& amount,
beast::Journal j)
{
AccountID const issuer = amount.getIssuer();
// If the issuer is the same as the account, return tecNO_PERMISSION
if (issuer == account)
return tecNO_PERMISSION;
// If the mpt does not exist, return tecOBJECT_NOT_FOUND
auto const issuanceKey = keylet::mptokenIssuance(amount.get<MPTIssue>().getMptID());
auto const sleIssuance = view.read(issuanceKey);
if (!sleIssuance)
return tecOBJECT_NOT_FOUND;
// If the lsfMPTCanEscrow is not enabled, return tecNO_PERMISSION
if (!sleIssuance->isFlag(lsfMPTCanEscrow))
return tecNO_PERMISSION;
// If the issuer is not the same as the issuer of the mpt, return
// tecNO_PERMISSION
if (sleIssuance->getAccountID(sfIssuer) != issuer)
return tecNO_PERMISSION; // LCOV_EXCL_LINE
// If the account does not have the mpt, return tecOBJECT_NOT_FOUND
if (!view.exists(keylet::mptoken(issuanceKey.key, account)))
return tecOBJECT_NOT_FOUND;
// If the issuer has requireAuth set, check if the account is
// authorized
auto const& mptIssue = amount.get<MPTIssue>();
if (auto const ter = requireAuth(view, mptIssue, account, AuthType::WeakAuth);
!isTesSuccess(ter))
return ter;
// If the issuer has requireAuth set, check if the destination is
// authorized
if (auto const ter = requireAuth(view, mptIssue, dest, AuthType::WeakAuth); !isTesSuccess(ter))
return ter;
// If the issuer has frozen the account, return tecLOCKED
if (isFrozen(view, account, *sleIssuance))
return tecLOCKED;
// If the issuer has frozen the destination, return tecLOCKED
if (isFrozen(view, dest, *sleIssuance))
return tecLOCKED;
// If the mpt cannot be transferred, return tecNO_AUTH
if (auto const ter = canTransfer(view, mptIssue, account, dest); !isTesSuccess(ter))
return ter;
STAmount const spendableAmount = accountHolds(
view,
account,
amount.get<MPTIssue>(),
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
j);
// If the balance is less than or equal to 0, return tecINSUFFICIENT_FUNDS
if (spendableAmount <= beast::kZero)
return tecINSUFFICIENT_FUNDS;
// If the spendable amount is less than the amount, return
// tecINSUFFICIENT_FUNDS
if (spendableAmount < amount)
return tecINSUFFICIENT_FUNDS;
return tesSUCCESS;
}
template <ValidIssueType T>
TER
escrowLockApplyHelper(
ApplyView& view,
AccountID const& issuer,
AccountID const& sender,
STAmount const& amount,
beast::Journal journal);
template <>
inline TER
escrowLockApplyHelper<Issue>(
ApplyView& view,
AccountID const& issuer,
AccountID const& sender,
STAmount const& amount,
beast::Journal journal)
{
// Defensive: Issuer cannot create an escrow
if (issuer == sender)
return tecINTERNAL; // LCOV_EXCL_LINE
auto const ter =
directSendNoFee(view, sender, issuer, amount, !amount.holds<MPTIssue>(), journal);
if (!isTesSuccess(ter))
return ter; // LCOV_EXCL_LINE
return tesSUCCESS;
}
template <>
inline TER
escrowLockApplyHelper<MPTIssue>(
ApplyView& view,
AccountID const& issuer,
AccountID const& sender,
STAmount const& amount,
beast::Journal journal)
{
// Defensive: Issuer cannot create an escrow
if (issuer == sender)
return tecINTERNAL; // LCOV_EXCL_LINE
auto const ter = lockEscrowMPT(view, sender, amount, journal);
if (!isTesSuccess(ter))
return ter; // LCOV_EXCL_LINE
return tesSUCCESS;
}
template <ValidIssueType T>
TER
escrowUnlockPreclaimHelper(
ReadView const& view,
AccountID const& account,
STAmount const& amount,
bool checkFreeze = true);
template <>
inline TER
escrowUnlockPreclaimHelper<Issue>(
ReadView const& view,
AccountID const& account,
STAmount const& amount,
bool checkFreeze)
{
AccountID const& issuer = amount.getIssuer();
// If the issuer is the same as the account, return tesSUCCESS
if (issuer == account)
return tesSUCCESS;
// If the issuer has requireAuth set, check if the destination is authorized
if (auto const ter = requireAuth(view, amount.get<Issue>(), account); !isTesSuccess(ter))
return ter;
// If the issuer has deep frozen the destination, return tecFROZEN
if (checkFreeze &&
isDeepFrozen(view, account, amount.get<Issue>().currency, amount.getIssuer()))
return tecFROZEN;
return tesSUCCESS;
}
template <>
inline TER
escrowUnlockPreclaimHelper<MPTIssue>(
ReadView const& view,
AccountID const& account,
STAmount const& amount,
bool checkFreeze)
{
AccountID const& issuer = amount.getIssuer();
// If the issuer is the same as the account, return tesSUCCESS
if (issuer == account)
return tesSUCCESS;
// If the mpt does not exist, return tecOBJECT_NOT_FOUND
auto const issuanceKey = keylet::mptokenIssuance(amount.get<MPTIssue>().getMptID());
auto const sleIssuance = view.read(issuanceKey);
if (!sleIssuance)
return tecOBJECT_NOT_FOUND;
// If the issuer has requireAuth set, check if the account is
// authorized
auto const& mptIssue = amount.get<MPTIssue>();
if (auto const ter = requireAuth(view, mptIssue, account, AuthType::WeakAuth);
!isTesSuccess(ter))
return ter;
// If the issuer has frozen the account, return tecLOCKED
if (checkFreeze && isFrozen(view, account, *sleIssuance))
return tecLOCKED;
return tesSUCCESS;
}
//------------------------------------------------------------------------------
template <ValidIssueType T>
TER
escrowUnlockApplyHelper(
@@ -344,6 +57,9 @@ escrowUnlockApplyHelper<Issue>(
bool createAsset,
beast::Journal journal)
{
auto const& issue = amount.get<Issue>();
Keylet const trustLineKey = keylet::trustLine(receiver, issue);
bool const recvLow = issuer > receiver;
bool const senderIssuer = issuer == sender;
bool const receiverIssuer = issuer == receiver;
@@ -353,10 +69,6 @@ escrowUnlockApplyHelper<Issue>(
if (receiverIssuer)
return tesSUCCESS;
auto const& issue = amount.get<Issue>();
Keylet const trustLineKey = keylet::trustLine(receiver, issue);
bool const recvLow = issuer > receiver;
if (!ctx.view.exists(trustLineKey) && createAsset)
{
// Can the account cover the trust line's reserve?
@@ -562,9 +274,6 @@ escrowUnlockApplyHelper<MPTIssue>(
journal);
}
// calculateAdditionalReserve computes the owner count impact of an Escrow.
// An escrow without a FinishFunction costs 1 reserve. With a FinishFunction,
// each additional 500 bytes beyond the first 500 adds another reserve slot.
template <class T>
static int32_t
calculateAdditionalReserve(T const& finishFunction)

View File

@@ -101,24 +101,6 @@ static constexpr std::uint32_t kSecondsInYear = 365 * 24 * 60 * 60;
Number
loanPeriodicRate(TenthBips32 interestRate, std::uint32_t paymentInterval);
/**
* Assets a loan earns per second at this principal outstanding.
*
* Equation (27) of XLS-66 is linear in elapsed time, and sfPaymentInterval
* cancels out of it, so a loan's accrual rate depends only on its principal
* and interest rate. Principal is flat between payments, which makes the rate
* piecewise-constant with breakpoints exactly at the events that update it —
* summing it across a vault's loans is therefore exact, not an approximation.
*/
inline Number
loanAccrualRate(Number const& principalOutstanding, TenthBips32 interestRate)
{
if (interestRate == TenthBips32{0} || principalOutstanding <= Number{})
return Number{};
return tenthBipsOfValue(principalOutstanding, interestRate) / Number{kSecondsInYear};
}
/**
* Ensure the periodic payment is always rounded consistently
*/

View File

@@ -132,29 +132,6 @@ authorizeMPToken(
std::uint32_t flags = 0,
std::optional<AccountID> holderID = std::nullopt);
// Authorize an AMM-issued MPT and apply the reserve-exemption rule in
// one shot: standard authorize (which increments owner count) followed
// by an immediate adjustOwnerCount(-1) so the LP doesn't pay reserve
// for the AMM-issued holding. Returns the same TER as authorizeMPToken.
//
// Callers must ensure the MPT's issuance is owned by an AMM pseudo-
// account; mis-using this helper for non-AMM-issued MPTs would let an
// LP hold an arbitrary issuer's MPT for free.
[[nodiscard]] TER
authorizeAMMIssuedMPT(
ApplyViewContext ctx,
XRPAmount const& priorBalance,
MPTID const& mptIssuanceID,
AccountID const& account,
beast::Journal journal);
// Symmetric for snapshot-style SLEs the AMM owns on behalf of an LP
// (e.g. ltAMM_BIN_HOLDING). The SLE is inserted into the LP's owner
// directory by the caller; this helper compensates the owner-count
// increment so the LP doesn't pay reserve.
void
exemptAMMOwnedSLE(ApplyView& view, AccountID const& account, beast::Journal journal);
/**
* Check if the account lacks required authorization for MPT.
*

View File

@@ -16,7 +16,6 @@
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/protocol/nft.h>
#include <cstddef>
#include <cstdint>
@@ -162,12 +161,4 @@ checkTrustlineDeepFrozen(
beast::Journal const j,
Issue const& issue);
TER
transferNFToken(
ApplyView& view,
AccountID const& buyer,
AccountID const& seller,
uint256 const& nftokenID,
beast::Journal j);
} // namespace xrpl::nft

View File

@@ -2,85 +2,28 @@
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Concepts.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/MPTAmount.h>
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/Rules.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/UintTypes.h>
#include <cstdint>
#include <optional>
namespace xrpl {
/**
* Validate the token amount of a PaymentChannelCreate or PaymentChannelFund
* transaction during preflight.
*
* @param rules The current ledger rules used to check amendment status.
* @param amount The channel or funding amount from the transaction.
* @return tesSUCCESS if the amount is valid; temBAD_AMOUNT, temBAD_CURRENCY,
* or temDISABLED otherwise.
*/
template <ValidIssueType T>
NotTEC
payChanAmountPreflightHelper(Rules const& rules, STAmount const& amount);
template <>
inline NotTEC
payChanAmountPreflightHelper<Issue>(Rules const&, STAmount const& amount)
{
if (amount.native() || amount <= beast::kZero)
return temBAD_AMOUNT;
if (badCurrency() == amount.get<Issue>().currency)
return temBAD_CURRENCY;
return tesSUCCESS;
}
template <>
inline NotTEC
payChanAmountPreflightHelper<MPTIssue>(Rules const& rules, STAmount const& amount)
{
if (!rules.enabled(fixCleanup3_2_0) && !rules.enabled(featureMPTokensV1))
return temDISABLED;
if (amount.native() || amount.mpt() > MPTAmount{kMaxMpTokenAmount} || amount <= beast::kZero)
return temBAD_AMOUNT;
return tesSUCCESS;
}
/**
* Close a payment channel and return its remaining funds to the channel owner.
*
* @param slep The SLE for the PayChannel object to close.
* @param ctx The apply view context (view and transaction) in which ledger
* state modifications are made.
* @param key The ledger key identifying the PayChannel entry.
* @param txAccount The account submitting the transaction that closes the
* channel.
* @param j Journal used for fatal-level diagnostic messages.
* @return tesSUCCESS on success; tefBAD_LEDGER if a directory removal
* fails; tefINTERNAL if the source account SLE cannot be found.
* @param slep The SLE for the PayChannel object to close.
* @param view The apply view in which ledger state modifications are made.
* @param key The ledger key identifying the PayChannel entry.
* @param j Journal used for fatal-level diagnostic messages.
* @return tesSUCCESS on success; tefBAD_LEDGER if a directory removal
* fails; tefINTERNAL if the source account SLE cannot be found.
*/
TER
closeChannel(
SLE::ref slep,
ApplyViewContext ctx,
uint256 const& key,
AccountID const& txAccount,
beast::Journal j);
closeChannel(SLE::ref slep, ApplyView& view, uint256 const& key, beast::Journal j);
/**
* Add two uint32_t values with saturation at UINT32_MAX.

View File

@@ -1,85 +0,0 @@
#pragma once
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/TER.h>
#include <cstdint>
namespace xrpl {
namespace repo {
/**
* Seconds in a year, the denominator of the annualized interest rate.
*/
/**
* A repo is active once the buyer has accepted it, which is recorded by
* sfStartDate. Before that it is a pending offer.
*/
[[nodiscard]] inline bool
isActive(SLE::const_ref sleRepo)
{
return sleRepo->isFieldPresent(sfStartDate);
}
/**
* The amount the seller owes to repurchase the collateral.
*
* PurchasePrice * (1 + InterestRate * elapsed / kSecondsInYear), where elapsed
* runs from StartDate to the close time, capped at MaturityDate so the seller
* never pays for time past maturity. Computed in Number and rounded up, so
* rounding never favours the seller.
*/
[[nodiscard]] STAmount
repurchaseAmount(SLE::const_ref sleRepo, std::uint32_t closeTime);
/**
* Lock the collateral out of the seller's spendable balance.
*
* XRP is deducted from the account balance by the caller; this handles the
* issued-asset cases the same way an escrow does.
*/
[[nodiscard]] TER
lockCollateral(
ApplyView& view,
AccountID const& issuer,
AccountID const& seller,
STAmount const& amount,
beast::Journal journal);
/**
* The XLS-85 checks that decide whether collateral may be locked at all.
*
* Both parties are checked, not just the seller: the buyer receives the
* collateral if the repo defaults, so an unauthorized or frozen buyer would
* leave the collateral unable to move at exactly the moment it must.
*/
[[nodiscard]] TER
checkCollateral(
ReadView const& view,
AccountID const& seller,
AccountID const& buyer,
STAmount const& collateral,
beast::Journal journal);
/**
* Return the locked collateral to an account and remove the entry.
*
* Used by cancel, close and default; they differ only in who receives the
* collateral and whether any cash moved first.
*/
[[nodiscard]] TER
releaseAndDelete(
ApplyViewContext ctx,
SLE::ref sleRepo,
AccountID const& receiver,
beast::Journal journal);
} // namespace repo
} // namespace xrpl

View File

@@ -1,429 +0,0 @@
#pragma once
#include <xrpl/basics/Log.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/MPTokenHelpers.h>
#include <xrpl/ledger/helpers/RippleStateHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Concepts.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/UintTypes.h>
#include <cstdint>
namespace xrpl {
template <ValidIssueType T>
TER
canTransferTokenHelper(
ReadView const& view,
AccountID const& account,
AccountID const& dest,
STAmount const& amount,
beast::Journal const& j);
template <>
inline TER
canTransferTokenHelper<Issue>(
ReadView const& view,
AccountID const& account,
AccountID const& dest,
STAmount const& amount,
beast::Journal const& j)
{
AccountID issuer = amount.getIssuer();
if (issuer == account)
{
JLOG(j.trace()) << "canTransferTokenHelper: Issuer is the same as the account.";
return tesSUCCESS;
}
// If the issuer does not exist, return tecNO_ISSUER
auto const sleIssuer = view.read(keylet::account(issuer));
if (!sleIssuer)
{
JLOG(j.trace()) << "canTransferTokenHelper: Issuer does not exist.";
return tecNO_ISSUER;
}
// If the account does not have a trustline to the issuer, return tecNO_LINE
auto const sleRippleState =
view.read(keylet::trustLine(account, issuer, amount.get<Issue>().currency));
if (!sleRippleState)
{
JLOG(j.trace()) << "canTransferTokenHelper: Trust line does not exist.";
return tecNO_LINE;
}
STAmount const balance = (*sleRippleState)[sfBalance];
// If balance is positive, issuer must have higher address than account
if (balance > beast::kZero && issuer < account)
{
JLOG(j.trace()) << "canTransferTokenHelper: Invalid trust line state.";
return tecNO_PERMISSION;
}
// If balance is negative, issuer must have lower address than account
if (balance < beast::kZero && issuer > account)
{
JLOG(j.trace()) << "canTransferTokenHelper: Invalid trust line state.";
return tecNO_PERMISSION;
}
// If the issuer has requireAuth set, check if the account is authorized
if (auto const ter = requireAuth(view, amount.get<Issue>(), account); ter != tesSUCCESS)
{
JLOG(j.trace()) << "canTransferTokenHelper: Account is not authorized";
return ter;
}
// If the issuer has requireAuth set, check if the destination is authorized
if (auto const ter = requireAuth(view, amount.get<Issue>(), dest); ter != tesSUCCESS)
{
JLOG(j.trace()) << "canTransferTokenHelper: Destination is not authorized.";
return ter;
}
// If the issuer has frozen the account, return tecFROZEN
if (isFrozen(view, account, amount.get<Issue>()) ||
isDeepFrozen(view, account, amount.get<Issue>().currency, amount.get<Issue>().account))
{
JLOG(j.trace()) << "canTransferTokenHelper: Account is frozen.";
return tecFROZEN;
}
// If the issuer has frozen the destination, return tecFROZEN
if (isFrozen(view, dest, amount.get<Issue>()) ||
isDeepFrozen(view, dest, amount.get<Issue>().currency, amount.get<Issue>().account))
{
JLOG(j.trace()) << "canTransferTokenHelper: Destination is frozen.";
return tecFROZEN;
}
STAmount const spendableAmount = accountHolds(
view, account, amount.get<Issue>().currency, issuer, FreezeHandling::IgnoreFreeze, j);
// If the balance is less than or equal to 0, return
// tecINSUFFICIENT_FUNDS
if (spendableAmount <= beast::kZero)
{
JLOG(j.trace()) << "canTransferTokenHelper: Spendable amount is less "
"than or equal to 0.";
return tecINSUFFICIENT_FUNDS;
}
// If the spendable amount is less than the amount, return
// tecINSUFFICIENT_FUNDS
if (spendableAmount < amount)
{
JLOG(j.trace()) << "canTransferTokenHelper: Spendable amount is less "
"than the amount.";
return tecINSUFFICIENT_FUNDS;
}
// If the amount is not addable to the balance, return tecPRECISION_LOSS
if (!canAdd(spendableAmount, amount))
return tecPRECISION_LOSS;
return tesSUCCESS;
}
template <>
inline TER
canTransferTokenHelper<MPTIssue>(
ReadView const& view,
AccountID const& account,
AccountID const& dest,
STAmount const& amount,
beast::Journal const& j)
{
AccountID issuer = amount.getIssuer();
if (issuer == account)
{
JLOG(j.trace()) << "canTransferTokenHelper: Issuer is the same as the account.";
return tesSUCCESS;
}
// If the mpt does not exist, return tecOBJECT_NOT_FOUND
auto const issuanceKey = keylet::mptokenIssuance(amount.get<MPTIssue>().getMptID());
auto const sleIssuance = view.read(issuanceKey);
if (!sleIssuance)
{
JLOG(j.trace()) << "canTransferTokenHelper: MPT issuance does not exist.";
return tecOBJECT_NOT_FOUND;
}
// If the issuer is not the same as the issuer of the mpt, return
// tecNO_PERMISSION
if (sleIssuance->getAccountID(sfIssuer) != issuer)
{
JLOG(j.trace()) << "canTransferTokenHelper: Issuer is not the same as "
"the issuer of the MPT.";
return tecNO_PERMISSION;
}
// If the account does not have the mpt, return tecOBJECT_NOT_FOUND
if (!view.exists(keylet::mptoken(issuanceKey.key, account)))
{
JLOG(j.trace()) << "canTransferTokenHelper: Account does not have the MPT.";
return tecOBJECT_NOT_FOUND;
}
// If the issuer has requireAuth set, check if the account is
// authorized
auto const& mptIssue = amount.get<MPTIssue>();
if (auto const ter = requireAuth(view, mptIssue, account, AuthType::WeakAuth);
ter != tesSUCCESS)
{
JLOG(j.trace()) << "canTransferTokenHelper: Account is not authorized.";
return ter;
}
// If the issuer has requireAuth set, check if the destination is
// authorized
if (auto const ter = requireAuth(view, mptIssue, dest, AuthType::WeakAuth); ter != tesSUCCESS)
{
JLOG(j.trace()) << "canTransferTokenHelper: Destination is not authorized.";
return ter;
}
// If the issuer has locked the account, return tecLOCKED
if (isFrozen(view, account, mptIssue))
{
JLOG(j.trace()) << "canTransferTokenHelper: Account is locked.";
return tecLOCKED;
}
// If the issuer has locked the destination, return tecLOCKED
if (isFrozen(view, dest, mptIssue))
{
JLOG(j.trace()) << "canTransferTokenHelper: Destination is locked.";
return tecLOCKED;
}
// If the mpt cannot be transferred, return tecNO_AUTH
if (auto const ter = canTransfer(view, mptIssue, account, dest); ter != tesSUCCESS)
{
JLOG(j.trace()) << "canTransferTokenHelper: MPT cannot be transferred.";
return ter;
}
STAmount const spendableAmount = accountHolds(
view,
account,
amount.get<MPTIssue>(),
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
j);
// If the balance is less than or equal to 0, return
// tecINSUFFICIENT_FUNDS
if (spendableAmount <= beast::kZero)
{
JLOG(j.trace()) << "canTransferTokenHelper: Spendable amount is less "
"than or equal to 0.";
return tecINSUFFICIENT_FUNDS;
}
// If the spendable amount is less than the amount, return
// tecINSUFFICIENT_FUNDS
if (spendableAmount < amount)
{
JLOG(j.trace()) << "canTransferTokenHelper: Spendable amount is less "
"than the amount.";
return tecINSUFFICIENT_FUNDS;
}
// If the amount is not addable to the balance, return tecPRECISION_LOSS
if (!canAdd(spendableAmount, amount))
return tecPRECISION_LOSS;
return tesSUCCESS;
}
template <ValidIssueType T>
TER
doTransferTokenHelper(
ApplyView& view,
SLE::ref sleDest,
STAmount const& xrpBalance,
STAmount const& amount,
AccountID const& issuer,
AccountID const& sender,
AccountID const& receiver,
bool createAsset,
beast::Journal journal);
template <>
inline TER
doTransferTokenHelper<Issue>(
ApplyView& view,
SLE::ref sleDest,
STAmount const& xrpBalance,
STAmount const& amount,
AccountID const& issuer,
AccountID const& sender,
AccountID const& receiver,
bool createAsset,
beast::Journal journal)
{
Keylet const trustLineKey = keylet::trustLine(receiver, amount.get<Issue>());
bool const recvLow = issuer > receiver;
// Review Note: We could remove this and just say to use batch to auth the
// token first
if (!view.exists(trustLineKey) && createAsset && issuer != receiver)
{
// Can the account cover the trust line's reserve?
if (xrpBalance < accountReserve(view, sleDest, journal, {.ownerCountDelta = 1}))
{
JLOG(journal.trace()) << "doTransferTokenHelper: Trust line does not exist. "
"Insufficent reserve to create line.";
return tecNO_LINE_INSUF_RESERVE;
}
Currency const currency = amount.get<Issue>().currency;
STAmount initialBalance(amount.get<Issue>());
initialBalance.get<Issue>().account = noAccount();
// clang-format off
if (TER const ter = trustCreate(
view, // payment sandbox
recvLow, // is dest low?
issuer, // source
receiver, // destination
trustLineKey.key, // ledger index
sleDest, // Account to add to
false, // authorize account
(sleDest->getFlags() & lsfDefaultRipple) == 0,
false, // freeze trust line
false, // deep freeze trust line
initialBalance, // zero initial balance
Issue(currency, receiver), // limit of zero
0, // quality in
0, // quality out
SLE::pointer(), // sponsor
journal); // journal
!isTesSuccess(ter))
{
JLOG(journal.trace()) << "doTransferTokenHelper: Failed to create trust line: " << transToken(ter);
return ter;
}
// clang-format on
view.update(sleDest);
}
if (!view.exists(trustLineKey) && issuer != receiver)
return tecNO_LINE;
auto const ter =
accountSend(view, sender, receiver, amount, journal, SLE::pointer(), WaiveTransferFee::No);
if (ter != tesSUCCESS)
{
JLOG(journal.trace()) << "doTransferTokenHelper: Failed to send token: " << transToken(ter);
return ter; // LCOV_EXCL_LINE
}
return tesSUCCESS;
}
template <>
inline TER
doTransferTokenHelper<MPTIssue>(
ApplyView& view,
SLE::ref sleDest,
STAmount const& xrpBalance,
STAmount const& amount,
AccountID const& issuer,
AccountID const& sender,
AccountID const& receiver,
bool createAsset,
beast::Journal journal)
{
auto const mptID = amount.get<MPTIssue>().getMptID();
auto const issuanceKey = keylet::mptokenIssuance(mptID);
if (!view.exists(keylet::mptoken(issuanceKey.key, receiver)) && createAsset &&
issuer != receiver)
{
if (xrpBalance < accountReserve(view, sleDest, journal, {.ownerCountDelta = 1}))
{
JLOG(journal.trace()) << "doTransferTokenHelper: MPT does not exist. "
"Insufficent reserve to create MPT.";
return tecINSUFFICIENT_RESERVE;
}
if (auto const ter = createMPToken(view, mptID, receiver, SLE::pointer(), 0);
!isTesSuccess(ter))
{
JLOG(journal.trace()) << "doTransferTokenHelper: Failed to create MPT: "
<< transToken(ter);
return ter;
}
// Update owner count.
increaseOwnerCount(view, sleDest, SLE::pointer(), 1, journal);
}
if (issuer != receiver && !view.exists(keylet::mptoken(issuanceKey.key, receiver)))
{
JLOG(journal.trace()) << "doTransferTokenHelper: MPT does not exist.";
return tecNO_PERMISSION;
}
auto const ter =
accountSend(view, sender, receiver, amount, journal, SLE::pointer(), WaiveTransferFee::No);
if (ter != tesSUCCESS)
{
JLOG(journal.trace()) << "doTransferTokenHelper: Failed to send MPT: " << transToken(ter);
return ter; // LCOV_EXCL_LINE
}
return tesSUCCESS;
}
// Remove a subscription from both owner directories, release the owner's
// reserve, and erase the object. Shared by SubscriptionCancel and the
// single-use claim path so the two never diverge.
inline TER
deleteSubscription(ApplyView& view, SLE::ref sleSub, beast::Journal journal)
{
AccountID const account{sleSub->getAccountID(sfAccount)};
AccountID const dstAcct{sleSub->getAccountID(sfDestination)};
std::uint64_t const ownerPage{(*sleSub)[sfOwnerNode]};
if (!view.dirRemove(keylet::ownerDir(account), ownerPage, sleSub->key(), true))
{
JLOG(journal.fatal()) << "deleteSubscription: Unable to delete from source.";
return tefBAD_LEDGER;
}
std::uint64_t const destPage{(*sleSub)[sfDestinationNode]};
if (!view.dirRemove(keylet::ownerDir(dstAcct), destPage, sleSub->key(), true))
{
JLOG(journal.fatal()) << "deleteSubscription: Unable to delete from destination.";
return tefBAD_LEDGER;
}
auto const sleSrc = view.peek(keylet::account(account));
decreaseOwnerCount(view, sleSrc, SLE::pointer(), 1, journal);
view.erase(sleSub);
return tesSUCCESS;
}
} // namespace xrpl

View File

@@ -4,7 +4,6 @@
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/TokenIssuanceHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Issue.h>
@@ -392,8 +391,7 @@ directSendNoFee(
AccountID const& uReceiverID,
STAmount const& saAmount,
bool bCheckIssuer,
beast::Journal j,
EnforceSupplyCap enforceSupplyCap = EnforceSupplyCap::Yes);
beast::Journal j);
/**
* Calls static accountSendIOU if saAmount represents Issue.

View File

@@ -1,109 +0,0 @@
#pragma once
#include <xrpl/basics/Number.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/UintTypes.h>
#include <cstdint>
#include <optional>
namespace xrpl {
/**
* ceil(value * 10^scale), exact.
*
* The shift is a pure exponent adjustment; the +1 for a fractional
* positive value is exact because a fractional Number is < 10^16.
* Truncation toward zero already is the ceiling for negative values.
*/
[[nodiscard]] Number
tokenScaledCeil(Number const& value, std::uint8_t scale);
/**
* floor(value * 10^scale) as integer base units; nullopt if it does not
* fit in a non-negative int64.
*/
[[nodiscard]] std::optional<std::int64_t>
tokenBaseUnits(Number const& value, std::uint8_t scale);
/**
* True if the issuance violates
* ceil(IssuedAmount * 10^TokenScale) + MPT OutstandingAmount > MaximumAmount.
* Always false for an uncapped issuance.
*/
[[nodiscard]] bool
tokenSupplyExceeded(ReadView const& view, SLE::const_ref sleIssuance);
/**
* Controls whether an IOU credit hard-fails when a capped TokenIssuance
* would exceed its MaximumAmount. Flow-engine steps pass No: their send
* results are advisory (return values ignored, reverse-pass execution can
* legitimately overshoot transiently) and the supply-cap invariant checker
* gates the final state instead.
*/
enum class EnforceSupplyCap : bool { No = false, Yes = true };
/**
* Maintain IssuedAmount on the issuer's TokenIssuance for a trust-line
* balance move of `amount` from `sender` to `receiver`. Only the amount's
* issuer is adjusted: balance moving away from the issuer increases its
* net issuance, balance returning decreases it.
*
* No-op when the amendment is disabled, neither party is the issuer, or no
* TokenIssuance exists. Returns tecSUPPLY_EXCEEDED when the cap is enforced
* and a capped issuer would exceed MaximumAmount.
*/
[[nodiscard]] TER
adjustTokenIssuance(
ApplyView& view,
AccountID const& sender,
AccountID const& receiver,
STAmount const& amount,
EnforceSupplyCap enforceCap,
beast::Journal j);
/**
* Remaining IOU the issuer can issue under the supply cap, floored to the
* representable amount; nullopt when there is no TokenIssuance or no cap.
*/
[[nodiscard]] std::optional<STAmount>
tokenIssuanceHeadroom(ReadView const& view, Issue const& issue);
/**
* True when the issue's TokenIssuance carries the per-currency lock
* (lsfTokenLocked). The per-currency analog of lsfGlobalFreeze.
*/
[[nodiscard]] bool
isTokenLocked(ReadView const& view, Issue const& issue);
/**
* For an MPT issuance bound to a capped TokenIssuance: the additional MPT
* base units that can be minted under the shared cap
* (MaximumAmount - OutstandingAmount - ceil(IssuedAmount * 10^TokenScale)).
* nullopt when unbound or uncapped.
*/
[[nodiscard]] std::optional<std::int64_t>
mptBoundHeadroom(ReadView const& view, SLE::const_ref sleMptIssuance);
/**
* Validate binding an MPTokenIssuance to a TokenIssuance: the MPT issuance
* exists, is issued by `account`, is not already bound, and its
* MaximumAmount/AssetScale equal the TokenIssuance's MaximumAmount/
* TokenScale.
*/
[[nodiscard]] TER
validateTokenBinding(
ReadView const& view,
MPTID const& mptId,
AccountID const& account,
std::optional<std::uint64_t> const& maximumAmount,
std::uint8_t tokenScale);
} // namespace xrpl

View File

@@ -1,7 +1,6 @@
#pragma once
#include <xrpl/basics/Number.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
@@ -17,111 +16,20 @@
namespace xrpl {
class STTx;
/**
* Interest the vault's loans have earned since sfLastAccrualTime, capped by
* the sfUnearnedInterest budget still left to recognize.
*
* sfAssetsTotal is only credited when a loan event settles the vault, so
* between those events it lags by this amount. Adding it back at read time
* recognizes interest continuously as it is earned, without writing to
* sfAssetsTotal outside the loan transactions.
*
* Returns zero when nothing is accruing — no rate, no budget left, or a vault
* created before featureLendingProtocolV1_1.
*/
[[nodiscard]] Number
vaultAccruedInterest(ReadView const& view, SLE::const_ref vault);
/**
* Whether a rolling vault is inside a dealing window at this close time.
*
* A rolling vault deals in [SubscriptionDate + k * DealingInterval,
* SubscriptionDate + k * DealingInterval + DealingWindow) for integer k >= 0.
* Returns true for any vault that is not rolling, which has no windows to be
* outside of, and false before the first window opens.
*/
[[nodiscard]] bool
inDealingWindow(ReadView const& view, SLE::const_ref vault);
/**
* End of the dealing window containing this close time.
*
* Only meaningful when inDealingWindow is true for a rolling vault; it is the
* sfStruckUntil written when a window's price is struck.
*/
[[nodiscard]] std::uint32_t
dealingWindowEnd(ReadView const& view, SLE::const_ref vault);
/**
* The price every deal in the current window converts at, in vault asset per
* share, or nullopt when no struck price governs this ledger.
*
* Returns a price only for a rolling vault inside a window whose sfStruckUntil
* matches that window's end. Accrual continues underneath it: the dealing price
* is frozen for the window, the accounting is not.
*/
/**
* The interest recognition method of a vault.
*
* Returns sfAccountingMethod where it is present. A vault created before
* featureVaultContinuousAccrual carries no such field, so the method is derived
* from its schema version instead: CashBasis recognizes interest as it is
* collected, and anything older is Legacy, which recognizes a loan's whole-life
* interest at origination. Every vault that exists today therefore resolves
* without being touched.
*/
[[nodiscard]] std::uint8_t
getAccountingMethod(SLE::const_ref vault);
[[nodiscard]] std::optional<Number>
struckPriceInForce(ReadView const& view, SLE::const_ref vault);
/**
* Strike the price for the current window if it has not been struck yet.
*
* Called by the first deposit or withdrawal of a window. Does nothing for a
* vault that is not rolling, outside a window, or where this window's price is
* already struck, so it is safe to call unconditionally.
*/
void
strikeWindowPrice(ApplyView& view, SLE::ref vault, SLE::const_ref issuance);
/**
* Credit interest earned since the last settlement into sfAssetsTotal, draw
* it out of the sfUnearnedInterest budget, and stamp the current close time.
*
* Must be called before adjusting sfAccrualRate, so the elapsed period is
* charged at the rate that was in effect over it. Only the ttLOAN_*
* transactions may call this: ValidVault requires sfAssetsTotal to move with
* the vault balance on deposit and withdraw, which settling would violate.
* Pricing does not need it — the conversion helpers add elapsed interest
* themselves.
*/
void
accrueVault(ApplyView& view, SLE::ref vault);
/**
* From the perspective of a vault, return the number of shares to give
* depositor when they offer a fixed amount of assets. Note, since shares are
* MPT, this number is integral and always truncated in this calculation.
*
* /**
* * From the perspective of a vault, return the number of shares to give
* * depositor when they offer a fixed amount of assets. Note, since shares are
* * MPT, this number is integral and always truncated in this calculation.
* *
* * @param vault The vault SLE.
* * @param issuance The MPTokenIssuance SLE for the vault's shares.
* * @param assets The amount of assets to convert.
* *
* * @return The number of shares, or nullopt on error.
* @param vault The vault SLE.
* @param issuance The MPTokenIssuance SLE for the vault's shares.
* @param assets The amount of assets to convert.
*
* @return The number of shares, or nullopt on error.
*/
[[nodiscard]] std::optional<STAmount>
assetsToSharesDeposit(
ReadView const& view,
SLE::const_ref vault,
SLE::const_ref issuance,
STAmount const& assets);
assetsToSharesDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& assets);
/**
* From the perspective of a vault, return the number of assets to take from
@@ -135,11 +43,7 @@ assetsToSharesDeposit(
* @return The number of assets, or nullopt on error.
*/
[[nodiscard]] std::optional<STAmount>
sharesToAssetsDeposit(
ReadView const& view,
SLE::const_ref vault,
SLE::const_ref issuance,
STAmount const& shares);
sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& shares);
/**
* Adjusts a requested asset change (`delta`) to match the decimal scale of the
@@ -187,12 +91,11 @@ enum class WaiveUnrealizedLoss : bool { No = false, Yes = true };
* unrealized loss is waived. Used by assetsToSharesWithdraw and
* sharesToAssetsWithdraw as the numerator of the share/asset exchange rate.
*
* @param view The ledger view, for interest accrued since the last settlement.
* @param vault The vault SLE.
* @param waive Whether to skip subtracting the unrealized loss.
*/
[[nodiscard]] Number
assetsTotalForWithdrawal(ReadView const& view, SLE::const_ref vault, WaiveUnrealizedLoss waive);
assetsTotalForWithdrawal(SLE::const_ref vault, WaiveUnrealizedLoss waive);
/**
* Returns true if debiting `amount` from `total` (the current value of a
@@ -228,7 +131,6 @@ debitIsNonZeroDust(Asset const& asset, Number const& total, Number const& amount
*/
[[nodiscard]] std::optional<STAmount>
assetsToSharesWithdraw(
ReadView const& view,
SLE::const_ref vault,
SLE::const_ref issuance,
STAmount const& assets,
@@ -250,7 +152,6 @@ assetsToSharesWithdraw(
*/
[[nodiscard]] std::optional<STAmount>
sharesToAssetsWithdraw(
ReadView const& view,
SLE::const_ref vault,
SLE::const_ref issuance,
STAmount const& shares,

View File

@@ -29,78 +29,6 @@ constexpr std::uint32_t kAuctionSlotIntervalDuration =
constexpr std::uint16_t kVoteMaxSlots = 8;
constexpr std::uint32_t kVoteWeightScaleFactor = 100000;
// Curve type identifiers
enum CurveType : std::uint8_t {
CtConstantProduct = 0,
CtConcentratedLiquidity = 1,
CtStableSwap = 2,
CtBinned = 3,
};
inline constexpr CurveType protocolCurveTypes[] = {
CtConstantProduct,
CtConcentratedLiquidity,
CtStableSwap,
CtBinned,
};
// Fee tier definitions for concentrated liquidity
enum FeeTier : std::uint8_t {
FtStable = 0, // 1 bp, tick spacing 1
FtLow = 1, // 5 bp, tick spacing 10
FtMedium = 2, // 30 bp, tick spacing 60
FtHigh = 3, // 100 bp, tick spacing 200
};
inline constexpr std::uint16_t feeTierToFee[] = {1, 5, 30, 100};
inline constexpr std::int32_t feeTierToTickSpacing[] = {1, 10, 60, 200};
inline constexpr std::uint8_t feeTierCount = 4;
// Tick bounds
inline constexpr std::int32_t minTick = -887272;
inline constexpr std::int32_t maxTick = 887272;
// Offset-binary scaling applied wherever a tick is hashed or bit-packed
// to keep arithmetic in unsigned domain (avoids signed-division surprises
// near zero). Used by:
// - keylet::ammTick (offset-encoded into the low 64 keylet bits)
// - keylet::ammTickBitmapWord (wordIndex = (tick + offset) >> 8)
// - Validthe AMM bitmap-consistency invariant
// One constant, one source of fragility — DO NOT duplicate inline.
inline constexpr std::uint32_t kTickBitmapOffset =
static_cast<std::uint32_t>(-static_cast<std::int64_t>(minTick));
// StableSwap limits
inline constexpr std::uint32_t minAmplification = 1;
inline constexpr std::uint32_t maxAmplification = 5000;
inline constexpr std::uint32_t maxAmpChangePct = 10;
inline constexpr std::uint32_t ampRampDuration = 86400;
// Binned-curve limits. Bin step in basis points; bin price grows as
// (1 + binStep/10000)^binID. Range bound keeps state size finite and
// price range close to v3's effective range at default tick spacing.
inline constexpr std::int32_t minBinID = -221818;
inline constexpr std::int32_t maxBinID = 221818;
inline constexpr std::uint16_t validBinSteps[] = {1, 5, 10, 25, 100};
inline constexpr std::uint8_t binStepCount = 5;
// Newton's method convergence
inline constexpr int newtonMaxIterations = 256;
// Concentrated-liquidity per-swap tick-crossing cap. Bounds the
// per-swap work done by the curve's iterative tick traversal (each
// crossing does one SHAMap lookup for the next initialised tick + one
// SLE read). With FtStable's tickSpacing=1, 1000 crossings = ~10%
// price range; with FtMedium's tickSpacing=60, 1000 crossings spans
// the equivalent of a ~4x price move — both comfortably larger than
// any reasonable swap requires. Hitting the cap produces a silent
// partial fill: the curve returns the output realized over the first
// `maxTickCrossings` boundaries, and the caller infers the cap from
// the (smaller-than-requested) result. Uniswap v3 has no protocol
// cap (only block gas); we cap here because XRPL has no metered
// execution and the cap is the only fairness bound on per-swap work.
inline constexpr int maxTickCrossings = 1000;
class STObject;
class STAmount;
class Rules;
@@ -109,20 +37,13 @@ class Rules;
* Calculate Liquidity Provider Token (LPT) Currency.
*/
Currency
ammLPTCurrency(
Asset const& asset1,
Asset const& asset2,
std::uint8_t curveType = CtConstantProduct);
ammLPTCurrency(Asset const& asset1, Asset const& asset2);
/**
* Calculate LPT Issue from AMM asset pair.
*/
Issue
ammLPTIssue(
Asset const& asset1,
Asset const& asset2,
AccountID const& ammAccountID,
std::uint8_t curveType = CtConstantProduct);
ammLPTIssue(Asset const& asset1, Asset const& asset2, AccountID const& ammAccountID);
/**
* Validate the amount.

View File

@@ -16,7 +16,6 @@
#include <cstdint>
#include <limits>
#include <optional>
#include <vector>
namespace xrpl {
@@ -434,88 +433,4 @@ verifyConvertBackProof(
uint64_t amount,
uint256 const& contextHash);
/**
* @brief Generates the context hash for a BallotCastVote transaction.
*
* Binds the cast's range proof to this specific transaction, preventing
* proof reuse across ballots or accounts.
*
* @param account The voter's account ID.
* @param ballotID The target ballot's ledger index.
* @param sequence The transaction sequence number or ticket number.
* @return A 256-bit context hash unique to this cast.
*/
uint256
getBallotCastContextHash(AccountID const& account, uint256 const& ballotID, std::uint32_t sequence);
/**
* @brief Generates the context hash for a BallotFinalize transaction.
*
* Binds each per-option decryption-correctness proof to this specific
* finalize transaction.
*
* @param account The tally authority's account ID.
* @param ballotID The target ballot's ledger index.
* @param sequence The transaction sequence number or ticket number.
* @return A 256-bit context hash unique to this finalize.
*/
uint256
getBallotFinalizeContextHash(
AccountID const& account,
uint256 const& ballotID,
std::uint32_t sequence);
/**
* @brief Verifies an aggregated Bulletproof range proof over ballot option
* commitments.
*
* Proves that every one of the N per-option values committed in
* @p commitments lies in the non-negative range, so a vote cannot subtract
* weight from a disliked option. Thin wrapper over
* mpt_verify_aggregated_bulletproof.
*
* @param proof The serialized aggregated Bulletproof.
* @param commitments One 33-byte Pedersen commitment per option.
* @param contextHash The 256-bit context hash binding the proof.
* @return tesSUCCESS if the proof is valid, or an error code otherwise.
*/
TER
verifyBallotRangeProof(
Slice const& proof,
std::vector<Slice> const& commitments,
uint256 const& contextHash);
/**
* @brief Verifies the ciphertext-commitment linkage for one ballot option.
*
* Proves that the option's ElGamal ciphertext(s) encrypt the same value that
* its Pedersen commitment commits to, and that every mirror ciphertext (tally,
* optional auditor, optional voter) encrypts that same value under shared
* randomness. Combined with the aggregated range proof over the commitment,
* this pins the tally update to a non-negative value the voter cannot forge —
* the verifiable-encryption guarantee a vote needs because it encrypts under
* the tally key, which the voter does not own.
*
* Wraps secp256k1_compact_standard_verify. The balance-linkage terms of that
* relation are neutralized with a canonical witness (sk_A = 1, rho_b = 1), so
* pk_A = G, PC_b = H and B1 = B2 = G are reconstructed identically here and by
* the prover, and only the 192-byte proof is carried on the wire.
*
* @param pubKeys The n mirror public keys, tally key first (33 bytes each).
* @param c1 The shared ElGamal C1 component (33 bytes).
* @param c2PerKey The n ElGamal C2 components, one per mirror key (33 bytes).
* @param commitment The option's Pedersen commitment PC_m (33 bytes).
* @param proof The 192-byte compact sigma linkage proof.
* @param contextHash The 256-bit context hash binding the proof.
* @return tesSUCCESS if the linkage holds, or an error code otherwise.
*/
TER
verifyBallotVoteLinkage(
std::vector<Slice> const& pubKeys,
Slice const& c1,
std::vector<Slice> const& c2PerKey,
Slice const& commitment,
Slice const& proof,
uint256 const& contextHash);
} // namespace xrpl

View File

@@ -1,77 +0,0 @@
#pragma once
#include <xrpl/protocol/Rules.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/TxSettings.h>
#include <optional>
#include <string>
#include <unordered_map>
namespace xrpl {
/**
* We have both transaction type emitables and granular type emitables.
* Since we will reuse the TransactionFormats to parse the Transaction
* Emitables, only the GranularEmitableType is defined here. To prevent
* conflicts with TxType, the GranularEmitableType is always set to a value
* greater than the maximum value of uint16.
*/
enum GranularEmitableType : std::uint32_t {
#pragma push_macro("EMITABLE")
#undef EMITABLE
#define EMITABLE(type, txType, value) type = value,
#include <xrpl/protocol/detail/emitable.macro>
#undef EMITABLE
#pragma pop_macro("EMITABLE")
};
class Emitable
{
private:
Emitable();
std::unordered_map<std::uint16_t, Emittance> emitableTx_;
std::unordered_map<std::string, GranularEmitableType> granularEmitableMap_;
std::unordered_map<GranularEmitableType, std::string> granularNameMap_;
std::unordered_map<GranularEmitableType, TxType> granularTxTypeMap_;
public:
static Emitable const&
getInstance();
Emitable(Emitable const&) = delete;
Emitable&
operator=(Emitable const&) = delete;
std::optional<std::string>
getEmitableName(std::uint32_t const value) const;
std::optional<std::uint32_t>
getGranularValue(std::string const& name) const;
std::optional<std::string>
getGranularName(GranularEmitableType const& value) const;
std::optional<TxType>
getGranularTxType(GranularEmitableType const& gpType) const;
bool
isEmitable(std::uint32_t const& emitableValue) const;
// for tx level emitable, emitable value is equal to tx type plus one
uint32_t
txToEmitableType(TxType const& type) const;
// tx type value is emitable value minus one
TxType
emitableToTxType(uint32_t const& value) const;
};
} // namespace xrpl

View File

@@ -1,17 +0,0 @@
#pragma once
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/TxSettings.h>
namespace xrpl {
/**
* How an account's firewall treats a transaction of the given type.
*
* The classification is declared per transaction in transactions.macro. A type
* the switch does not name, which means a deprecated one, is allowed.
*/
[[nodiscard]] FirewallAction
firewallAction(TxType txType) noexcept;
} // namespace xrpl

View File

@@ -15,43 +15,13 @@
#include <xrpl/protocol/SeqProxy.h>
#include <xrpl/protocol/UintTypes.h>
#include <boost/endian/conversion.hpp>
#include <array>
#include <cstdint>
#include <cstring>
#include <functional>
#include <set>
#include <utility>
namespace xrpl {
class SeqProxy;
// Structured-key helpers: read/write the low 64 bits of a uint256 keylet
// in big-endian byte order. Used by every AMM keylet scheme that
// embeds an ordered subkey (tick index, bitmap-word index, bin ID) into
// the low 64 bits so SHAMap range walks visit entries in subkey order.
//
// We type-pun via std::memcpy rather than a reinterpret_cast through
// uint64_t* — the latter violates strict aliasing and has no alignment
// guarantee on base_uint's underlying byte storage. memcpy of an
// 8-byte value compiles to a single load/store on x86_64 / ARM64 under
// any optimization level, so the safer idiom is free at runtime.
inline void
setLow64BE(uint256& key, std::uint64_t value) noexcept
{
auto const be = boost::endian::native_to_big(value);
std::memcpy(key.end() - sizeof(std::uint64_t), &be, sizeof(std::uint64_t));
}
[[nodiscard]] inline std::uint64_t
getLow64BE(uint256 const& key) noexcept
{
std::uint64_t be;
std::memcpy(&be, key.end() - sizeof(std::uint64_t), sizeof(std::uint64_t));
return boost::endian::big_to_native(be);
}
/**
* Keylet computation functions.
*
@@ -136,9 +106,7 @@ book(Book const& b);
* BTC, and Bob trusts Alice for BTC, here is only a single BTC trust line
* between them.
*/
/**
* @{.
*/
/** @{ */
Keylet
trustLine(AccountID const& id0, AccountID const& id1, Currency const& currency) noexcept;
@@ -147,16 +115,12 @@ trustLine(AccountID const& id, Issue const& issue) noexcept
{
return trustLine(id, issue.account, issue.currency);
}
/**
* @}.
*/
/** @} */
/**
* An offer from an account
*/
/**
* @{.
*/
/** @{ */
Keylet
offer(AccountID const& id, SeqProxy const& seq) noexcept;
@@ -165,9 +129,7 @@ offer(uint256 const& key) noexcept
{
return {ltOFFER, key};
}
/**
* @}.
*/
/** @} */
/**
* The initial directory page for a specific quality
@@ -184,9 +146,7 @@ next(Keylet const& k);
/**
* A ticket belonging to an account
*/
/**
* @{.
*/
/** @{ */
Keylet
ticket(AccountID const& id, SeqProxy const& ticketSeq);
@@ -195,9 +155,7 @@ ticket(uint256 const& key)
{
return {ltTICKET, key};
}
/**
* @}.
*/
/** @} */
/**
* A SignerList
@@ -211,18 +169,10 @@ signerList(AccountID const& account) noexcept;
Keylet
sponsorship(AccountID const& sponsor, AccountID const& sponsee) noexcept;
/**
* An account's beneficiary designation. One per account.
*/
Keylet
beneficiary(AccountID const& account) noexcept;
/**
* A Check
*/
/**
* @{.
*/
/** @{ */
Keylet
check(AccountID const& id, SeqProxy const& seq) noexcept;
@@ -231,16 +181,12 @@ check(uint256 const& key) noexcept
{
return {ltCHECK, key};
}
/**
* @}.
*/
/** @} */
/**
* A DepositPreauth
*/
/**
* @{.
*/
/** @{ */
Keylet
depositPreauth(AccountID const& owner, AccountID const& preauthorized) noexcept;
@@ -254,9 +200,7 @@ depositPreauth(uint256 const& key) noexcept
{
return {ltDEPOSIT_PREAUTH, key};
}
/**
* @}.
*/
/** @} */
//------------------------------------------------------------------------------
@@ -275,9 +219,7 @@ ownerDir(AccountID const& id) noexcept;
/**
* A page in a directory
*/
/**
* @{.
*/
/** @{ */
Keylet
page(uint256 const& root, std::uint64_t const index = 0) noexcept;
@@ -287,9 +229,7 @@ page(Keylet const& root, std::uint64_t const index = 0) noexcept
XRPL_ASSERT(root.type == ltDIR_NODE, "xrpl::keylet::page : valid root type");
return page(root.key, index);
}
/**
* @}.
*/
/** @} */
/**
* An escrow entry
@@ -317,9 +257,7 @@ payChannel(AccountID const& src, AccountID const& dst, SeqProxy const& seq) noex
* 160-bit AccountID, followed by a 96-bit value that determines which NFT
* tokens are candidates for that page.
*/
/**
* @{.
*/
/** @{ */
/**
* A keylet for the owner's first possible NFT page.
*/
@@ -334,9 +272,7 @@ nftokenPageMax(AccountID const& owner);
Keylet
nftokenPage(Keylet const& k, uint256 const& token);
/**
* @}.
*/
/** @} */
/**
* An offer from an account to buy or sell an NFT
@@ -366,7 +302,7 @@ nftSells(uint256 const& id) noexcept;
* AMM entry
*/
Keylet
amm(Asset const& issue1, Asset const& issue2, std::uint8_t curveType = 0) noexcept;
amm(Asset const& issue1, Asset const& issue2) noexcept;
Keylet
amm(uint256 const& amm) noexcept;
@@ -412,15 +348,6 @@ mptokenIssuance(uint256 const& issuanceKey)
return {ltMPTOKEN_ISSUANCE, issuanceKey};
}
Keylet
tokenIssuance(AccountID const& issuer, Currency const& currency) noexcept;
inline Keylet
tokenIssuance(uint256 const& key)
{
return {ltTOKEN_ISSUANCE, key};
}
Keylet
mptoken(MPTID const& issuanceID, AccountID const& holder) noexcept;
@@ -445,45 +372,6 @@ vault(uint256 const& vaultKey)
Keylet
loanBroker(AccountID const& owner, SeqProxy const& seq) noexcept;
/**
* A repurchase agreement, keyed by the seller and the creating sequence.
*/
Keylet
repo(AccountID const& seller, SeqProxy const& seq) noexcept;
inline Keylet
repo(uint256 const& repoID)
{
return {ltREPO, repoID};
}
/**
* A firewall, keyed by the account it protects.
*/
Keylet
firewall(AccountID const& account) noexcept;
inline Keylet
firewall(uint256 const& firewallID)
{
return {ltFIREWALL, firewallID};
}
/**
* A withdraw preauthorization, keyed by owner, authorized account and tag.
*/
Keylet
withdrawPreauth(
AccountID const& owner,
AccountID const& preauthorized,
std::uint32_t dtag) noexcept;
inline Keylet
withdrawPreauth(uint256 const& key)
{
return {ltWITHDRAW_PREAUTH, key};
}
inline Keylet
loanBroker(uint256 const& key)
{
@@ -499,174 +387,11 @@ loan(uint256 const& key)
return {ltLOAN, key};
}
Keylet
couponSchedule(uint192 const& mptIssuanceID) noexcept;
inline Keylet
couponSchedule(uint256 const& scheduleKey)
{
return {ltCOUPON_SCHEDULE, scheduleKey};
}
Keylet
permissionedDomain(AccountID const& account, SeqProxy const& seq) noexcept;
Keylet
permissionedDomain(uint256 const& domainID) noexcept;
Keylet
contractSource(uint256 const& contractHash) noexcept;
Keylet
contract(uint256 const& contractHash, AccountID const& owner, std::uint32_t seq) noexcept;
inline Keylet
contract(uint256 const& contractID)
{
return {ltCONTRACT, contractID};
}
Keylet
contractData(AccountID const& owner, AccountID const& contractAccount) noexcept;
Keylet
passkeyList(AccountID const& account) noexcept;
/**
* A ballot owned by `owner`, keyed by the creating transaction sequence.
*/
Keylet
ballot(AccountID const& owner, std::uint32_t seq) noexcept;
inline Keylet
ballot(uint256 const& ballotID)
{
return {ltBALLOT, ballotID};
}
/**
* A voter's cast on the ballot identified by `ballotID`.
*/
Keylet
ballotVote(uint256 const& ballotID, AccountID const& voter) noexcept;
inline Keylet
ballotVote(uint256 const& key)
{
return {ltBALLOT_VOTE, key};
}
/**
* A concentrated liquidity AMM position.
*/
Keylet
ammPosition(uint256 const& ammID, AccountID const& owner, std::uint32_t seq) noexcept;
inline Keylet
ammPosition(uint256 const& key)
{
return {ltAMM_POSITION, key};
}
/**
* A concentrated liquidity AMM tick.
* Uses structured (non-hashed) keys for ordered SHAMap traversal.
* High 192 bits: pool scope (from ammID hash).
* Low 64 bits: encoded tick index (offset binary, big-endian).
*/
Keylet
ammTick(uint256 const& ammID, std::int32_t tickIndex) noexcept;
inline Keylet
ammTick(uint256 const& key)
{
return {ltAMM_TICK, key};
}
/**
* Base key for a CL pool's tick range (low 64 bits zeroed).
*/
Keylet
ammTickBase(uint256 const& ammID) noexcept;
/**
* End key for a CL pool's tick range (low 64 bits all 1s).
*/
Keylet
ammTickEnd(uint256 const& ammID) noexcept;
/**
* A 256-tick presence bitmap window for a CL pool.
* Keylet structure mirrors `ammTick`: high 192 bits derive from a pool-scoped
* hash, low 64 bits encode the word index (big-endian) so range walks via
* SHAMap succ/pred yield the next-higher / next-lower word.
*/
Keylet
ammTickBitmapWord(uint256 const& ammID, std::uint16_t wordIndex) noexcept;
inline Keylet
ammTickBitmapWord(uint256 const& key)
{
return {ltAMM_TICK_BITMAP, key};
}
/**
* Base key for a CL pool's tick-bitmap range (low 64 bits zeroed).
*/
Keylet
ammTickBitmapBase(uint256 const& ammID) noexcept;
/**
* End key for a CL pool's tick-bitmap range (low 64 bits all 1s).
*/
Keylet
ammTickBitmapEnd(uint256 const& ammID) noexcept;
/**
* A single bin within a CtBinned AMM pool. Bins are keyed by signed
* bin ID, offset-encoded into the low 64 bits of the keylet so SHAMap
* range walks yield consecutive bins in price order.
*/
Keylet
ammBin(uint256 const& ammID, std::int32_t binID) noexcept;
/**
* Lookup a bin SLE by its raw key (used by transactors that have a
* stored issuance / bin reference).
*/
Keylet
ammBin(uint256 const& key) noexcept;
/**
* Base / end keys for a binned AMM's bin-SLE range. Bins for the
* same AMM are contiguous in SHAMap order (high 192 bits are an
* ammID-scoped hash; low 64 bits offset-encode the bin ID), so
* `view.succ(bin_at(binID).key, ammBinEnd(ammID).key)` jumps to the
* next populated bin in O(log n) regardless of gap size.
*/
Keylet
ammBinBase(uint256 const& ammID) noexcept;
Keylet
ammBinEnd(uint256 const& ammID) noexcept;
/**
* A single LP's holding record in a single bin. Phase 5 will replace
* this with a fungible MPT issuance per bin.
*/
Keylet
ammBinHolding(uint256 const& ammID, AccountID const& owner, std::int32_t binID) noexcept;
Keylet
ammBinHolding(uint256 const& key) noexcept;
Keylet
subscription(AccountID const& account, AccountID const& dest, std::uint32_t seq) noexcept;
inline Keylet
subscription(uint256 const& key) noexcept
{
return {ltSUBSCRIPTION, key};
}
} // namespace keylet
// Everything below is deprecated and should be removed in favor of keylets:

View File

@@ -8,8 +8,6 @@ namespace xrpl {
enum class KeyType {
Secp256k1 = 0,
Ed25519 = 1,
Dilithium = 2,
P256 = 3,
};
inline std::optional<KeyType>
@@ -21,12 +19,6 @@ keyTypeFromString(std::string const& s)
if (s == "ed25519")
return KeyType::Ed25519;
if (s == "dilithium")
return KeyType::Dilithium;
if (s == "p256")
return KeyType::P256;
return {};
}
@@ -39,12 +31,6 @@ to_string(KeyType type)
if (type == KeyType::Ed25519)
return "ed25519";
if (type == KeyType::Dilithium)
return "dilithium";
if (type == KeyType::P256)
return "p256";
return "INVALID";
}

View File

@@ -85,6 +85,15 @@ enum LedgerEntryType : std::uint16_t {
*/
ltNICKNAME [[deprecated("This object type is not supported and should not be used.")]] = 0x006e,
/**
* A legacy, deprecated type.
*
* @deprecated **This object type is not supported and should not be used.**
* Support for this type of object was never implemented.
* No objects of this type were ever created.
*/
ltCONTRACT [[deprecated("This object type is not supported and should not be used.")]] = 0x0063,
/**
* A legacy, deprecated type.
*
@@ -145,8 +154,7 @@ enum LedgerEntryType : std::uint16_t {
LEDGER_OBJECT(Offer, \
LSF_FLAG(lsfPassive, 0x00010000) \
LSF_FLAG(lsfSell, 0x00020000) /* True, offer was placed as a sell. */ \
LSF_FLAG(lsfHybrid, 0x00040000) /* True, offer is hybrid. */ \
LSF_FLAG(lsfAllOrNone, 0x00080000)) /* True, offer is all-or-none. */ \
LSF_FLAG(lsfHybrid, 0x00040000)) /* True, offer is hybrid. */ \
\
LEDGER_OBJECT(RippleState, \
LSF_FLAG(lsfLowReserve, 0x00010000) /* True, if entry counts toward reserve. */ \
@@ -180,8 +188,7 @@ enum LedgerEntryType : std::uint16_t {
LSF_FLAG(lsfMPTCanTrade, 0x00000010) \
LSF_FLAG(lsfMPTCanTransfer, 0x00000020) \
LSF_FLAG(lsfMPTCanClawback, 0x00000040) \
LSF_FLAG(lsfMPTCanHoldConfidentialBalance, 0x00000080) \
LSF_FLAG(lsfMPTCouponSchedule, 0x00000100)) \
LSF_FLAG(lsfMPTCanHoldConfidentialBalance, 0x00000080)) \
\
LEDGER_OBJECT(MPToken, \
LSF_FLAG2(lsfMPTLocked, 0x00000001) \
@@ -201,20 +208,7 @@ enum LedgerEntryType : std::uint16_t {
\
LEDGER_OBJECT(Sponsorship, \
LSF_FLAG(lsfSponsorshipRequireSignForFee, 0x00010000) \
LSF_FLAG(lsfSponsorshipRequireSignForReserve, 0x00020000)) \
\
LEDGER_OBJECT(TokenIssuance, \
LSF_FLAG(lsfTokenLocked, 0x00000001) /* True, per-currency global freeze */ \
LSF_FLAG(lsfTokenCannotLock, 0x00000002) /* True, issuer renounced the per-currency lock */ \
LSF_FLAG(lsfTokenWrapped, 0x00000004) /* True, owned by a pseudo-account (blackholed issuer) */ \
LSF_FLAG(lsfTokenVerifiedSupply, 0x00000008)) /* True, IssuedAmount reflects verified legacy supply */ \
\
LEDGER_OBJECT(Ballot, \
LSF_FLAG(lsfBallotFinalized, 0x00000001) /* True, results have been published */ \
LSF_FLAG(lsfVoterRecoverable, 0x00000002)) /* True, casts carry a voter self-recovery vector */ \
\
LEDGER_OBJECT(Subscription, \
LSF_FLAG(lsfSingleUse, 0x00010000)) /* True, delete on first successful claim */
LSF_FLAG(lsfSponsorshipRequireSignForReserve, 0x00020000))
// clang-format on

View File

@@ -1,20 +1,10 @@
#pragma once
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/HashPrefix.h>
#include <xrpl/protocol/IOUAmount.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/MPTAmount.h>
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/protocol/UintTypes.h>
#include <xrpl/protocol/XRPAmount.h>
#include <cstdint>
namespace xrpl {
inline void
@@ -25,70 +15,4 @@ serializePayChanAuthorization(Serializer& msg, uint256 const& key, XRPAmount con
msg.add64(amt.drops());
}
inline void
serializePayChanAuthorization(
Serializer& msg,
uint256 const& key,
IOUAmount const& amt,
Currency const& cur,
AccountID const& iss)
{
msg.add32(HashPrefix::PaymentChannelClaim);
msg.addBitString(key);
if (amt == beast::kZero)
{
msg.add64(STAmount::kIssuedCurrency);
}
else if (amt.signum() == -1)
{ // 512 = not native; the sign is encoded by omitting the 256 flag, so
// the mantissa must be serialized as its absolute value
msg.add64(
static_cast<std::uint64_t>(-amt.mantissa()) |
(static_cast<std::uint64_t>(amt.exponent() + 512 + 97) << (64 - 10)));
}
else
{ // 256 = positive
msg.add64(
amt.mantissa() |
(static_cast<std::uint64_t>(amt.exponent() + 512 + 256 + 97) << (64 - 10)));
}
msg.addBitString(cur);
msg.addBitString(iss);
}
inline void
serializePayChanAuthorization(
Serializer& msg,
uint256 const& key,
MPTAmount const& amt,
MPTID const& mptID,
AccountID const& iss)
{
msg.add32(HashPrefix::PaymentChannelClaim);
msg.addBitString(key);
msg.add64(amt.value());
msg.addBitString(mptID);
msg.addBitString(iss);
}
inline void
serializePayChanAuthorization(Serializer& msg, uint256 const& key, STAmount const& amt)
{
if (amt.native())
{
serializePayChanAuthorization(msg, key, amt.xrp());
}
else if (amt.holds<Issue>())
{
serializePayChanAuthorization(
msg, key, amt.iou(), amt.get<Issue>().currency, amt.get<Issue>().account);
}
else if (amt.holds<MPTIssue>())
{
auto const& mpt = amt.get<MPTIssue>();
auto const& mptID = mpt.getMptID();
serializePayChanAuthorization(msg, key, amt.mpt(), mptID, amt.getIssuer());
}
}
} // namespace xrpl

View File

@@ -141,13 +141,6 @@ tenthBipsOfValue(T value, TenthBips<TBips> bips)
return value * bips.value() / kTenthBipsPerUnity.value();
}
/**
* The longest inactivity period a beneficiary designation may require, ten
* years in seconds. Long enough for the intended use and short enough that the
* value still means something.
*/
constexpr std::uint32_t kMaxBeneficiaryTimeLock = 10 * 365 * 24 * 60 * 60;
namespace lending {
/**
* The maximum management fee rate allowed by a loan broker in 1/10 bips.
@@ -309,29 +302,11 @@ static_assert(Number::kMaxRep >= kMaxMpTokenAmount);
*/
constexpr std::size_t kMaxDataPayloadLength = 256;
/**
* The maximum length of a structured-data Schema
*/
constexpr std::size_t kMaxSchemaLength = 256;
/**
* Vault withdrawal policies
*/
constexpr std::uint8_t kVaultStrategyFirstComeFirstServe = 1;
/**
* Vault interest recognition methods.
*
* Legacy recognizes a loan's whole-life interest at origination and is the
* implicit method for vaults created before featureLendingProtocolV1_1. Cash
* recognizes interest as it is collected; accrual recognizes it continuously
* as it is earned. Fixed at VaultCreate — changing it would reprice every
* outstanding share in a single step.
*/
constexpr std::uint8_t kVaultAccountingLegacy = 0;
constexpr std::uint8_t kVaultAccountingCash = 1;
constexpr std::uint8_t kVaultAccountingAccrual = 2;
/**
* Default IOU scale factor for a Vault
*/
@@ -341,13 +316,7 @@ constexpr std::uint8_t kVaultDefaultIouScale = 6;
* 1 IOU can be always converted to shares.
* 10^19 > maxMPTokenAmount (2^64-1) > 10^18
*/
constexpr std::uint8_t kVaultMaximumIouScale =
18; /** Largest deposit or redemption fee a vault may charge, in 1/10 bips.
Matches kMaxTransferFee: half of what is moved is the most any fee may
retain.
*/
constexpr std::uint32_t kMaxVaultFee = 50'000;
constexpr std::uint8_t kVaultMaximumIouScale = 18;
/**
* Vault ledger-entry schema versions. Assigned to newly created
@@ -361,16 +330,12 @@ enum class VaultVersion : uint8_t {
};
/**
* Vault kind. Distinguishes closed-ended and rolling vaults from the default
* open-ended kind. Persisted as sfVaultKind (UINT8); absent means OpenEnded.
*
* A rolling vault deals in a window that reopens every sfDealingInterval
* seconds and stays open for sfDealingWindow of them.
* Vault kind. Distinguishes closed-ended vaults from the default open-ended
* kind. Persisted as sfVaultKind (UINT8); absent means OpenEnded.
*/
enum class VaultKind : std::uint8_t {
OpenEnded = 0,
ClosedEnded = 1,
Rolling = 2,
};
/**
@@ -421,20 +386,6 @@ constexpr std::size_t kMaxWasmDataLength = 1 * 1024; // 1KB
*/
constexpr std::size_t kWasmTransferLimit = 1 << 20; // 1MB
/**
* Maximum MaximumAmount of a TokenIssuance, in base units (10^15). Bounded
* to what Number/STAmount arithmetic represents exactly, so the supply-cap
* comparison can never be affected by mantissa rounding.
*/
constexpr std::uint64_t kMaxTokenIssuanceAmount = 1'000'000'000'000'000ull;
static_assert(kMaxTokenIssuanceAmount <= kMaxMpTokenAmount);
/**
* Maximum TokenScale of a TokenIssuance: 10^scale must stay in exact int64
* range. 10^19 > 2^63-1 > 10^18
*/
constexpr std::uint8_t kMaxTokenIssuanceScale = 18;
/**
* A ledger index.
*/

View File

@@ -36,12 +36,10 @@ namespace xrpl {
* information needed to determine the cryptosystem
* parameters used is stored inside the key.
*
* As of this writing three systems are supported:
* As of this writing two systems are supported:
*
* secp256k1
* ed25519
* dilithium
* p256
*
* secp256k1 public keys consist of a 33 byte
* compressed public key, with the lead byte equal
@@ -50,19 +48,14 @@ namespace xrpl {
* The ed25519 public keys consist of a 1 byte
* prefix constant 0xED, followed by 32 bytes of
* public key data.
*
* The dilithium public keys will have their own specific format.
*/
class PublicKey
{
protected:
// Minimum / standard public key size (secp256k1, ed25519).
// All the constructed public keys are valid, non-empty and contain 33
// bytes of data.
static constexpr std::size_t kSize = 33;
// Buffer sized for the largest supported key (dilithium = 1312 bytes;
// uncompressed p256 = 65 bytes). Actual length is tracked in size_.
static constexpr std::size_t kMaxSize = 1312;
std::uint8_t buf_[kMaxSize]{};
std::size_t size_ = 0;
std::uint8_t buf_[kSize]{}; // should be large enough
public:
using const_iterator = std::uint8_t const*;
@@ -88,10 +81,10 @@ public:
return buf_;
}
[[nodiscard]] std::size_t
size() const noexcept
static std::size_t
size() noexcept
{
return size_;
return kSize;
}
[[nodiscard]] const_iterator
@@ -109,19 +102,19 @@ public:
[[nodiscard]] const_iterator
end() const noexcept
{
return buf_ + size_;
return buf_ + kSize;
}
[[nodiscard]] const_iterator
cend() const noexcept
{
return buf_ + size_;
return buf_ + kSize;
}
[[nodiscard]] Slice
slice() const noexcept
{
return {buf_, size_};
return {buf_, kSize};
}
operator Slice() const noexcept
@@ -139,7 +132,7 @@ operator<<(std::ostream& os, PublicKey const& pk);
inline bool
operator==(PublicKey const& lhs, PublicKey const& rhs)
{
return lhs.size() == rhs.size() && std::memcmp(lhs.data(), rhs.data(), rhs.size()) == 0;
return std::memcmp(lhs.data(), rhs.data(), rhs.size()) == 0;
}
inline bool

View File

@@ -34,9 +34,6 @@ class STNumber;
class STXChainBridge;
class STVector256;
class STCurrency;
class STData;
class STDataType;
class STJson;
// NOLINTBEGIN(readability-identifier-naming)
#pragma push_macro("XMACRO")
@@ -76,9 +73,6 @@ class STJson;
STYPE(STI_ISSUE, 24) \
STYPE(STI_XCHAIN_BRIDGE, 25) \
STYPE(STI_CURRENCY, 26) \
STYPE(STI_DATA, 27) \
STYPE(STI_DATATYPE, 28) \
STYPE(STI_JSON, 29) \
\
/* high-level types */ \
/* cannot be serialized inside other types */ \
@@ -365,9 +359,6 @@ using SF_NUMBER = TypedField<STNumber>;
using SF_VL = TypedField<STBlob>;
using SF_VECTOR256 = TypedField<STVector256>;
using SF_XCHAIN_BRIDGE = TypedField<STXChainBridge>;
using SF_DATA = TypedField<STData>;
using SF_DATATYPE = TypedField<STDataType>;
using SF_JSON = TypedField<STJson>;
//------------------------------------------------------------------------------

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