mirror of
https://github.com/XRPLF/rippled.git
synced 2026-09-15 20:08:34 +00:00
Compare commits
747 Commits
ripple/len
...
ripple/sma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
824997df60 | ||
|
|
becfa96f64 | ||
|
|
2841b795f6 | ||
|
|
ba968988fe | ||
|
|
d6757ce1a5 | ||
|
|
b68a2276f5 | ||
|
|
4eac5dbd3b | ||
|
|
97b272be23 | ||
|
|
09c4e35c51 | ||
|
|
9403736199 | ||
|
|
646c7ed494 | ||
|
|
e1b1a010f5 | ||
|
|
2d7e92ffc5 | ||
|
|
c9b365315f | ||
|
|
c1133a7443 | ||
|
|
dd09ec2c2f | ||
|
|
a4d1bd1a55 | ||
|
|
4aa695f7d9 | ||
|
|
c9f7e39822 | ||
|
|
9867cc295e | ||
|
|
0ba8b306e3 | ||
|
|
daecad43c9 | ||
|
|
e9074726cd | ||
|
|
4208f14a91 | ||
|
|
491b930ebb | ||
|
|
422b8d0a43 | ||
|
|
a1ea8d3a6e | ||
|
|
c061cdb49e | ||
|
|
5f41471b7d | ||
|
|
2e78c260a9 | ||
|
|
05a81b3974 | ||
|
|
ae87e88690 | ||
|
|
279ef458c8 | ||
|
|
f9527b90da | ||
|
|
0ffeb86491 | ||
|
|
549d0f5757 | ||
|
|
fe38635dd0 | ||
|
|
74ec538296 | ||
|
|
b050dbba83 | ||
|
|
86c781f945 | ||
|
|
05a1497ebc | ||
|
|
5e48fe0b33 | ||
|
|
5973d60665 | ||
|
|
2787af8cd0 | ||
|
|
abc888707a | ||
|
|
e29e24dd50 | ||
|
|
37128fb8bd | ||
|
|
76aa7b1910 | ||
|
|
222ca8f3ce | ||
|
|
072c7cb501 | ||
|
|
8320dd4982 | ||
|
|
7e3ac18345 | ||
|
|
e850e10651 | ||
|
|
6b8c28fddd | ||
|
|
d1cf8c2214 | ||
|
|
20750084ab | ||
|
|
e101ef7923 | ||
|
|
3f66957fd2 | ||
|
|
b6cb3c9591 | ||
|
|
ef7f6025e4 | ||
|
|
b268a25f24 | ||
|
|
be2fbd4bf7 | ||
|
|
a8316180e4 | ||
|
|
71d0f26ed5 | ||
|
|
433955f022 | ||
|
|
3d14bce9d8 | ||
|
|
03eabd537e | ||
|
|
cabbb15c6c | ||
|
|
31b2fa5b37 | ||
|
|
135e395e8d | ||
|
|
17a00ed449 | ||
|
|
49049f9592 | ||
|
|
7898a5f402 | ||
|
|
a0794738a6 | ||
|
|
31d8267c2e | ||
|
|
bb90d20511 | ||
|
|
b57ead1a8a | ||
|
|
27859c1f6f | ||
|
|
e4baece501 | ||
|
|
86c09d8acc | ||
|
|
21f1a3e1f7 | ||
|
|
57ae858f14 | ||
|
|
e3ceab3f49 | ||
|
|
4300c5d7d6 | ||
|
|
cc0a6ef77f | ||
|
|
804b7d2dcd | ||
|
|
ddaa958754 | ||
|
|
3530a869cf | ||
|
|
9f5e08de66 | ||
|
|
2851ff46ab | ||
|
|
000fcf88bd | ||
|
|
36e6dfaf62 | ||
|
|
0c759f6cbd | ||
|
|
35fab72e97 | ||
|
|
ee7ac58ffb | ||
|
|
82876ca47c | ||
|
|
1761d83b8a | ||
|
|
579c44bb22 | ||
|
|
17d7bceadd | ||
|
|
ec64f23736 | ||
|
|
dc0326a2a0 | ||
|
|
80d8e827d7 | ||
|
|
b84d25fbed | ||
|
|
fda9c6995e | ||
|
|
c6c68090f2 | ||
|
|
6c32ca3538 | ||
|
|
7122bb8a43 | ||
|
|
c5524e4881 | ||
|
|
37e2f23ee6 | ||
|
|
e863db5061 | ||
|
|
d469fc2cdf | ||
|
|
362ea7a5d1 | ||
|
|
1b4fede15b | ||
|
|
2605b4a78b | ||
|
|
00488bf0b5 | ||
|
|
ecbfb8ea06 | ||
|
|
e91a30d004 | ||
|
|
694fbb7ce3 | ||
|
|
b67a896bff | ||
|
|
9288432da5 | ||
|
|
952450255f | ||
|
|
00dd93e77d | ||
|
|
4526a97c54 | ||
|
|
ecc2f07ea3 | ||
|
|
c5d25e3055 | ||
|
|
0749043d09 | ||
|
|
95526371b0 | ||
|
|
97f32869df | ||
|
|
454c651c44 | ||
|
|
91a23fc92c | ||
|
|
7d52867e3c | ||
|
|
98cf3a0532 | ||
|
|
98abdef208 | ||
|
|
7e7014c7ca | ||
|
|
d6a66d7249 | ||
|
|
6eab6c7c28 | ||
|
|
ca560ed6b0 | ||
|
|
58e47b0101 | ||
|
|
5f6f367b23 | ||
|
|
f52eb08d8a | ||
|
|
2821cc3e8e | ||
|
|
c1008c473c | ||
|
|
50623665c7 | ||
|
|
78c8128c98 | ||
|
|
c5605b6bcd | ||
|
|
a321a5dbfb | ||
|
|
3e113db4f5 | ||
|
|
9c423d2743 | ||
|
|
e60029d5a0 | ||
|
|
13196b839e | ||
|
|
17fb37871c | ||
|
|
3caaecff07 | ||
|
|
229377abd9 | ||
|
|
e23f8e266a | ||
|
|
1d9485c926 | ||
|
|
930ba88921 | ||
|
|
c619ae0263 | ||
|
|
93db40a25e | ||
|
|
8cae773691 | ||
|
|
fe325ea96a | ||
|
|
881d040a22 | ||
|
|
ce5e724b93 | ||
|
|
1656a19fe6 | ||
|
|
3a2cf64a69 | ||
|
|
accd0cac6c | ||
|
|
9a6efde771 | ||
|
|
6abd492ebb | ||
|
|
8da36db515 | ||
|
|
152406b698 | ||
|
|
b5a90e76ee | ||
|
|
5ae09518b8 | ||
|
|
3ab800bf47 | ||
|
|
fe1436fb3e | ||
|
|
abfa572370 | ||
|
|
d8e44687bf | ||
|
|
8a3363752f | ||
|
|
226ec160bb | ||
|
|
ef438aec26 | ||
|
|
6bc7a9a858 | ||
|
|
ba7bf92725 | ||
|
|
8f0eff4dc4 | ||
|
|
7f9ece3891 | ||
|
|
ffb2d37d17 | ||
|
|
6c02c45cbe | ||
|
|
eb946d23af | ||
|
|
994967521e | ||
|
|
52d2008797 | ||
|
|
4d685d4bad | ||
|
|
3f159d624b | ||
|
|
f8f8a6e059 | ||
|
|
5b2fc952d3 | ||
|
|
8ecb77dcfb | ||
|
|
f2271ecc03 | ||
|
|
0bf4739efa | ||
|
|
0df034a685 | ||
|
|
047a3f5cb8 | ||
|
|
e484a2902c | ||
|
|
ef0b5dd1ac | ||
|
|
041869ff3d | ||
|
|
25afc04420 | ||
|
|
ed6f0f3019 | ||
|
|
b61b18a92c | ||
|
|
011235f9a7 | ||
|
|
be7532e3f7 | ||
|
|
14e7dea7ed | ||
|
|
9e28519e56 | ||
|
|
2cc8b87c87 | ||
|
|
d8d1ec46dc | ||
|
|
641ecb47bd | ||
|
|
94ed8e2f49 | ||
|
|
c4ce52c810 | ||
|
|
75fac65e27 | ||
|
|
be3d98e8ff | ||
|
|
7915202cbc | ||
|
|
7ebd92ad3e | ||
|
|
f458c0f981 | ||
|
|
abcdeab11e | ||
|
|
55911cf526 | ||
|
|
ecae6b6346 | ||
|
|
6f4fe178dd | ||
|
|
083986ab82 | ||
|
|
c145201538 | ||
|
|
4fbbd6ff59 | ||
|
|
b7059deb9f | ||
|
|
11fd30b02a | ||
|
|
39c95fee3b | ||
|
|
1b52b6519a | ||
|
|
db6fe77959 | ||
|
|
45e3d3f73e | ||
|
|
fcf376d3a9 | ||
|
|
ab97f4be20 | ||
|
|
be58ec0cd5 | ||
|
|
4073838f66 | ||
|
|
e227e2ce8b | ||
|
|
383a2f8819 | ||
|
|
e64703b151 | ||
|
|
c91640e3ac | ||
|
|
0ceee3a013 | ||
|
|
974b17f427 | ||
|
|
91de196312 | ||
|
|
ac71553abe | ||
|
|
9a8ec56d42 | ||
|
|
7a3bd8ace2 | ||
|
|
012f894b0a | ||
|
|
cb6dad0bc4 | ||
|
|
d54ca4cd3b | ||
|
|
5a0269aca7 | ||
|
|
0774ca32a9 | ||
|
|
8feb75011e | ||
|
|
088c5b3b03 | ||
|
|
1c4abe8c5e | ||
|
|
91a5747bf9 | ||
|
|
bc892f0bd3 | ||
|
|
8950530b04 | ||
|
|
5cbebf3c58 | ||
|
|
c634c75aee | ||
|
|
9801c23bad | ||
|
|
f6472347e9 | ||
|
|
a41889df31 | ||
|
|
aeee77f6ec | ||
|
|
35583fcf97 | ||
|
|
4277c806e4 | ||
|
|
2e48bb4b7f | ||
|
|
9e427ae3ba | ||
|
|
8022fc33cf | ||
|
|
465fa8ec4b | ||
|
|
b5990be624 | ||
|
|
9767d86de4 | ||
|
|
ca2d999618 | ||
|
|
4a01fe5aa6 | ||
|
|
f60a9f1c64 | ||
|
|
5ee903befc | ||
|
|
68f0dcae53 | ||
|
|
d582ae7990 | ||
|
|
0dbe51c740 | ||
|
|
63fff4b518 | ||
|
|
d3550fac60 | ||
|
|
10ef15fff1 | ||
|
|
2307cfdc5a | ||
|
|
3b328cfc45 | ||
|
|
e20b2430ba | ||
|
|
99072fbddf | ||
|
|
059cefa35a | ||
|
|
a2205190a6 | ||
|
|
4c657bc92e | ||
|
|
ac481a0bd7 | ||
|
|
7eacd9d735 | ||
|
|
1f9675d22f | ||
|
|
029fa435ee | ||
|
|
c92ca426bf | ||
|
|
ff896b226a | ||
|
|
3c53c94e7a | ||
|
|
d85bf722ea | ||
|
|
b664989cfb | ||
|
|
e77934302a | ||
|
|
ef7aeca6bf | ||
|
|
eec1d29b92 | ||
|
|
971ba2281e | ||
|
|
90357eeae1 | ||
|
|
1dce793acc | ||
|
|
597202a6f0 | ||
|
|
1600b3e7f3 | ||
|
|
5be406e2df | ||
|
|
bc483b2a1d | ||
|
|
175259df28 | ||
|
|
8bb8c3b24b | ||
|
|
abb2ef3bec | ||
|
|
d009ef221f | ||
|
|
5fdedd7e99 | ||
|
|
ffc55797b2 | ||
|
|
ecee732187 | ||
|
|
ce2586c039 | ||
|
|
36fb84926e | ||
|
|
56decaf852 | ||
|
|
d562129e7c | ||
|
|
1d9fe9dbaf | ||
|
|
8cc2169939 | ||
|
|
74beb5f80d | ||
|
|
a4dfdaf77a | ||
|
|
826f613ad8 | ||
|
|
1259c1d5ca | ||
|
|
4bae4d781f | ||
|
|
01a5e07830 | ||
|
|
34522bc668 | ||
|
|
d2641d85bd | ||
|
|
75f66bd9fe | ||
|
|
7cd71cb659 | ||
|
|
9917f96166 | ||
|
|
e1cc82587b | ||
|
|
2cc9439fde | ||
|
|
52af9582e2 | ||
|
|
46e88dc732 | ||
|
|
bc24f2e211 | ||
|
|
7a7c993b15 | ||
|
|
9733ca8f91 | ||
|
|
18d5e3e226 | ||
|
|
b30b4e1d65 | ||
|
|
d435893602 | ||
|
|
00b0cf50f6 | ||
|
|
fe2e5ce649 | ||
|
|
6afa51142d | ||
|
|
22054a573d | ||
|
|
7ef256499c | ||
|
|
1338062be7 | ||
|
|
4fc1778ec8 | ||
|
|
65322d9e78 | ||
|
|
0acfa1c4f2 | ||
|
|
309fdfc3f2 | ||
|
|
63bd5fc4ee | ||
|
|
82e7f7eeec | ||
|
|
c5598a4284 | ||
|
|
63d09f9c51 | ||
|
|
0deb6bcadf | ||
|
|
d606f88e84 | ||
|
|
9b013b559b | ||
|
|
1d4a3c00b8 | ||
|
|
4b34102e8e | ||
|
|
d006433579 | ||
|
|
a7ab8ee923 | ||
|
|
e0073a4402 | ||
|
|
2930ef217f | ||
|
|
9dbb301699 | ||
|
|
531e8b6ebd | ||
|
|
90397e1a52 | ||
|
|
888ca2e6d9 | ||
|
|
f8a149c675 | ||
|
|
51458a92e2 | ||
|
|
b6514b680f | ||
|
|
913e4b919e | ||
|
|
196e6a1b27 | ||
|
|
27468ddbcf | ||
|
|
bce5d91e45 | ||
|
|
780380da7e | ||
|
|
654338fa66 | ||
|
|
fdfdf4fceb | ||
|
|
a76bd834b6 | ||
|
|
864d88a3c2 | ||
|
|
9c25d18851 | ||
|
|
3a825a41e1 | ||
|
|
a9ebf786c6 | ||
|
|
5afe8cc321 | ||
|
|
bc5ec3c962 | ||
|
|
7004d216ed | ||
|
|
1775251e90 | ||
|
|
61bcb7621f | ||
|
|
a3f71b1774 | ||
|
|
4df7d1a4bb | ||
|
|
50a74b899d | ||
|
|
00eeab6d44 | ||
|
|
125df7a425 | ||
|
|
b08bcf5d21 | ||
|
|
dc413aef0c | ||
|
|
77dfd56ace | ||
|
|
953b9a3500 | ||
|
|
1d9ec84350 | ||
|
|
0392846a17 | ||
|
|
5148098079 | ||
|
|
1b4a564369 | ||
|
|
fd524c4be9 | ||
|
|
495dda7f58 | ||
|
|
9c3c0280b1 | ||
|
|
f73d8a6cf2 | ||
|
|
4fe508cb92 | ||
|
|
6728ab52b7 | ||
|
|
446ad36cbb | ||
|
|
eb2d44d442 | ||
|
|
77673663ca | ||
|
|
c1381f8ddd | ||
|
|
bd16f7989d | ||
|
|
fd14054f17 | ||
|
|
65f9cf80c0 | ||
|
|
4318b2ebf7 | ||
|
|
e6ee492822 | ||
|
|
d4510147d1 | ||
|
|
a9a94fbf1a | ||
|
|
719ba392db | ||
|
|
cd46b5d999 | ||
|
|
de55a5ebfc | ||
|
|
2ec4a1114e | ||
|
|
f01ac563a9 | ||
|
|
13707dda05 | ||
|
|
ba03a8a9d2 | ||
|
|
7c8279ec83 | ||
|
|
a1844086d7 | ||
|
|
f625fb993a | ||
|
|
0418ffb26a | ||
|
|
b2627039f6 | ||
|
|
8f97ec3bde | ||
|
|
e85e7b1b1a | ||
|
|
ac173b6827 | ||
|
|
69c61b2235 | ||
|
|
6ae0e860ff | ||
|
|
c077e7f073 | ||
|
|
803a344c65 | ||
|
|
4eb34f381a | ||
|
|
72fffb6e51 | ||
|
|
f7ee580f01 | ||
|
|
122d405750 | ||
|
|
c1c1b4ea67 | ||
|
|
977caea0a5 | ||
|
|
d7ed6d6512 | ||
|
|
f1f2e2629f | ||
|
|
917c610f96 | ||
|
|
317e533d81 | ||
|
|
4160677878 | ||
|
|
430696682d | ||
|
|
4621e4eda3 | ||
|
|
df98db1452 | ||
|
|
981ac7abf4 | ||
|
|
57d2a91ad5 | ||
|
|
673476ef1b | ||
|
|
8bc6f9cd70 | ||
|
|
ba5debfecd | ||
|
|
f4a27c9b6d | ||
|
|
fd1cb318e3 | ||
|
|
94b35a234e | ||
|
|
b5d0078927 | ||
|
|
43c80edaf4 | ||
|
|
8c3544a58c | ||
|
|
ed5139d4e3 | ||
|
|
42494dd4cf | ||
|
|
ce84cc8b44 | ||
|
|
9a9a7aab01 | ||
|
|
209a1a6ffa | ||
|
|
9538e9b34c | ||
|
|
384b3608d7 | ||
|
|
fc35a9f9c8 | ||
|
|
c5e50aa221 | ||
|
|
074b1f00d5 | ||
|
|
7a9d245950 | ||
|
|
1809fe07f2 | ||
|
|
409c67494a | ||
|
|
fb97f7b596 | ||
|
|
c626b6403a | ||
|
|
81cbc91927 | ||
|
|
845c503ea6 | ||
|
|
e1513570df | ||
|
|
1c812a6c4d | ||
|
|
0724927799 | ||
|
|
ff39fa59d9 | ||
|
|
d83ec96848 | ||
|
|
f0d0739528 | ||
|
|
375dd50b35 | ||
|
|
419d53ec4c | ||
|
|
d4d70d5675 | ||
|
|
6ab15f8377 | ||
|
|
91f3d51f3d | ||
|
|
9ed60b45f8 | ||
|
|
d5c53dcfd2 | ||
|
|
8015088340 | ||
|
|
103379836a | ||
|
|
e94321fb41 | ||
|
|
bbc28b3b1c | ||
|
|
843e981c8a | ||
|
|
5aab274b7a | ||
|
|
2c30e41191 | ||
|
|
8ea5106b0b | ||
|
|
f57f67a8ae | ||
|
|
61b2fe4f64 | ||
|
|
7ee964f514 | ||
|
|
397bc8781e | ||
|
|
a98269f049 | ||
|
|
8bb8c2e38b | ||
|
|
b66bc47ca9 | ||
|
|
0e9c7458bb | ||
|
|
36ecd3b52b | ||
|
|
1d89940653 | ||
|
|
1a1a6806ec | ||
|
|
1977df9c2e | ||
|
|
6ffbef09c2 | ||
|
|
e05f907788 | ||
|
|
9d1f51b01a | ||
|
|
6c95548df5 | ||
|
|
69ab39d658 | ||
|
|
b9eb66eecc | ||
|
|
e916416642 | ||
|
|
827ecc6e3a | ||
|
|
6a54ed7f14 | ||
|
|
881087dd3d | ||
|
|
90e0bbd0fc | ||
|
|
b57df290de | ||
|
|
8a403f1241 | ||
|
|
1e0741690d | ||
|
|
6d2640871d | ||
|
|
c5d178f152 | ||
|
|
5a17940e2a | ||
|
|
27ac30208d | ||
|
|
c145598ff9 | ||
|
|
50e5608d86 | ||
|
|
abfcc4ef67 | ||
|
|
e40a4df777 | ||
|
|
dba187f8c5 | ||
|
|
7a7b96107c | ||
|
|
8f2f8d53b4 | ||
|
|
49acc61961 | ||
|
|
500bb68831 | ||
|
|
95d78a8600 | ||
|
|
53eb0f60bc | ||
|
|
41205ae928 | ||
|
|
c33b0ae463 | ||
|
|
16087c9680 | ||
|
|
56bc6d58f6 | ||
|
|
ef5d335e09 | ||
|
|
25c3060fef | ||
|
|
ce9f0b38a4 | ||
|
|
35f7cbf772 | ||
|
|
def7758a23 | ||
|
|
58e5b4ad25 | ||
|
|
578413859c | ||
|
|
0db564d261 | ||
|
|
427b7ea104 | ||
|
|
fa8aa49376 | ||
|
|
3195eb16b2 | ||
|
|
7bf6878b4b | ||
|
|
a891b49c67 | ||
|
|
eed280d169 | ||
|
|
0bc1a115ff | ||
|
|
334bcfa5ef | ||
|
|
106dea4559 | ||
|
|
3ffdcf8114 | ||
|
|
4021a7eb28 | ||
|
|
0690fda0f1 | ||
|
|
d0cc48c6d3 | ||
|
|
d66e3c949e | ||
|
|
7e2e10f02c | ||
|
|
aebec5378c | ||
|
|
0c65a386b5 | ||
|
|
22ca691e75 | ||
|
|
fc6ff69752 | ||
|
|
079e251aca | ||
|
|
67e2c1b563 | ||
|
|
29f5430881 | ||
|
|
b6bd268be2 | ||
|
|
209ee25c32 | ||
|
|
101f285bcd | ||
|
|
566b85b3d6 | ||
|
|
af6beb1d7c | ||
|
|
7d22fe804d | ||
|
|
ce19c13059 | ||
|
|
286dc6322b | ||
|
|
c9346cd40d | ||
|
|
9cfb7ac340 | ||
|
|
3a0e9aab4f | ||
|
|
43caa1ef29 | ||
|
|
1c5683ec78 | ||
|
|
9bee155d59 | ||
|
|
f34b05f4de | ||
|
|
97ce25f4ce | ||
|
|
3b6cd22e32 | ||
|
|
c9c35780d2 | ||
|
|
17f401f374 | ||
|
|
c6c54b3282 | ||
|
|
91455b6860 | ||
|
|
9e14c14a26 | ||
|
|
f16f243c22 | ||
|
|
fe601308e7 | ||
|
|
c507880d8f | ||
|
|
3f8328bbf8 | ||
|
|
e41f6a71b7 | ||
|
|
51f1be7f5b | ||
|
|
c10a5f9ef6 | ||
|
|
3c141de695 | ||
|
|
db263b696c | ||
|
|
f57b855d74 | ||
|
|
da2b9455f2 | ||
|
|
86525d8583 | ||
|
|
c41e52f57a | ||
|
|
55772a0d07 | ||
|
|
965a9e89ac | ||
|
|
51ee06429b | ||
|
|
cb622488c0 | ||
|
|
32f971fec6 | ||
|
|
ca85d09f02 | ||
|
|
8dea76baa4 | ||
|
|
299fbe04c4 | ||
|
|
57fc1df7d7 | ||
|
|
8d266d3941 | ||
|
|
a865b4da1c | ||
|
|
e59f5f3b01 | ||
|
|
8729688feb | ||
|
|
c8b06e7de1 | ||
|
|
eaba76f9e6 | ||
|
|
cb702cc238 | ||
|
|
f1f798bb85 | ||
|
|
c3fd52c177 | ||
|
|
b69b4a0a4a | ||
|
|
50d6072a73 | ||
|
|
d24cd50e61 | ||
|
|
85bff20ae5 | ||
|
|
737fab5471 | ||
|
|
e6592e93a9 | ||
|
|
5a6c4e8ae0 | ||
|
|
9f5875158c | ||
|
|
c3dc33c861 | ||
|
|
7420f47658 | ||
|
|
6be8f2124c | ||
|
|
edfed06001 | ||
|
|
1c646dba91 | ||
|
|
6781068058 | ||
|
|
cfe57c1dfe | ||
|
|
c34d09a971 | ||
|
|
ebd90c4742 | ||
|
|
ba52d34828 | ||
|
|
2f869b3cfc | ||
|
|
ffa21c27a7 | ||
|
|
1b6312afb3 | ||
|
|
bf32dc2e72 | ||
|
|
a15d65f7a2 | ||
|
|
2de8488855 | ||
|
|
129aa4bfaa | ||
|
|
b1d70db63b | ||
|
|
f03c3aafe4 | ||
|
|
51a9f106d1 | ||
|
|
bfc048e3fe | ||
|
|
83418644f7 | ||
|
|
dbc9dd5bfc | ||
|
|
5c480cf883 | ||
|
|
45ab15d4b5 | ||
|
|
adc64e7866 | ||
|
|
4d4a1cfe82 | ||
|
|
f2c7da3705 | ||
|
|
3ab0a82cd3 | ||
|
|
a46d772147 | ||
|
|
f3c50318e8 | ||
|
|
e7aa924c0e | ||
|
|
5266f04970 | ||
|
|
db957cf191 | ||
|
|
8ac514363d | ||
|
|
c2ea68cca4 | ||
|
|
3d86881ce7 | ||
|
|
697d1470f4 | ||
|
|
0b5f8f4051 | ||
|
|
0fed78fbcc | ||
|
|
8c38ef726b | ||
|
|
2399d90334 | ||
|
|
6367d68d1e | ||
|
|
155a84c8a3 | ||
|
|
10558c9eff | ||
|
|
dd30d811e6 | ||
|
|
293d8e4ddb | ||
|
|
77875c9133 | ||
|
|
647b47567e | ||
|
|
b0a1ad3b06 | ||
|
|
1d141bf2e8 | ||
|
|
0d0e279ae2 | ||
|
|
5dc0cee28a | ||
|
|
c15947da56 | ||
|
|
9bc04244e7 | ||
|
|
38c7a27010 | ||
|
|
58741d2791 | ||
|
|
8426470506 | ||
|
|
ccc3280b1a | ||
|
|
2847075705 | ||
|
|
3108ca0549 | ||
|
|
3b849ff497 | ||
|
|
66776b6a85 | ||
|
|
c8c241b50d | ||
|
|
44cb588371 | ||
|
|
6f91b8f8d1 | ||
|
|
3d93379132 | ||
|
|
84fd7d0126 | ||
|
|
7f52287aae | ||
|
|
98b8986868 | ||
|
|
250f2842ee | ||
|
|
9eca1a3a0c | ||
|
|
24b7a03224 | ||
|
|
9007097d24 | ||
|
|
bc445ec6a2 | ||
|
|
4fa0ae521e | ||
|
|
7bdf5fa8b8 | ||
|
|
65b0b976d9 | ||
|
|
a0d275feec | ||
|
|
ece3a8d7be | ||
|
|
463acf51b5 | ||
|
|
1cd16fab87 | ||
|
|
add55c4f33 | ||
|
|
51a9c0ff59 | ||
|
|
6e8a5f0f4e | ||
|
|
8a33702f26 | ||
|
|
a072d49802 | ||
|
|
a0aeeb8e07 | ||
|
|
383b225690 | ||
|
|
ace2247800 | ||
|
|
6a6fed5dce | ||
|
|
1f8aece8cd | ||
|
|
6c6f8cd4f9 | ||
|
|
fb1311e013 | ||
|
|
ce31acf030 | ||
|
|
31ad5ac63b | ||
|
|
1ede0bdec4 | ||
|
|
aef32ead2c | ||
|
|
5b43ec7f73 | ||
|
|
1e9ff88a00 | ||
|
|
bb9bb5f5c5 | ||
|
|
c533abd8b6 | ||
|
|
bb9bc764bc | ||
|
|
b4b53a6cb7 | ||
|
|
9c0204906c | ||
|
|
4670b373c1 | ||
|
|
f03b5883bd | ||
|
|
f8b2fe4dd5 | ||
|
|
be4a0c9c2b | ||
|
|
f37d52d8e9 | ||
|
|
177cdaf550 | ||
|
|
1573a443b7 | ||
|
|
911c0466c0 | ||
|
|
b6a95f9970 |
@@ -68,6 +68,7 @@ words:
|
||||
- Btrfs
|
||||
- Buildx
|
||||
- canonicality
|
||||
- cdylib
|
||||
- canonicalised
|
||||
- cctools
|
||||
- changespq
|
||||
@@ -105,6 +106,7 @@ words:
|
||||
- deleteme
|
||||
- demultiplexer
|
||||
- deserializaton
|
||||
- desugars
|
||||
- desync
|
||||
- desynced
|
||||
- determ
|
||||
@@ -131,6 +133,7 @@ words:
|
||||
- gcov
|
||||
- gcovr
|
||||
- ghead
|
||||
- gmock
|
||||
- Gnutella
|
||||
- godexsoft
|
||||
- gpgcheck
|
||||
@@ -140,7 +143,9 @@ words:
|
||||
- hwaddress
|
||||
- hwrap
|
||||
- ifndef
|
||||
- impls
|
||||
- inequation
|
||||
- initialiser
|
||||
- insuf
|
||||
- insuff
|
||||
- invasively
|
||||
@@ -250,6 +255,7 @@ words:
|
||||
- pyparsing
|
||||
- qalloc
|
||||
- qbsprofile
|
||||
- qself
|
||||
- queuable
|
||||
- Raphson
|
||||
- rcflags
|
||||
@@ -307,6 +313,7 @@ words:
|
||||
- STATSDCOLLECTOR
|
||||
- stissue
|
||||
- stnum
|
||||
- stnumber
|
||||
- stobj
|
||||
- stobject
|
||||
- stpath
|
||||
@@ -347,6 +354,7 @@ words:
|
||||
- unflatten
|
||||
- unfund
|
||||
- unimpair
|
||||
- unmetered
|
||||
- unroutable
|
||||
- unscalable
|
||||
- unserviced
|
||||
@@ -367,6 +375,8 @@ words:
|
||||
- vfalco
|
||||
- vinnie
|
||||
- wasmi
|
||||
- wasmparser
|
||||
- Werror
|
||||
- wextra
|
||||
- wptr
|
||||
- writeme
|
||||
@@ -375,6 +385,7 @@ words:
|
||||
- xbridge
|
||||
- xchain
|
||||
- xcrun
|
||||
- xfloat
|
||||
- ximinez
|
||||
- XMACRO
|
||||
- xored
|
||||
|
||||
@@ -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
|
||||
|
||||
1
.github/scripts/strategy-matrix/generate.py
vendored
1
.github/scripts/strategy-matrix/generate.py
vendored
@@ -12,7 +12,6 @@ _BASE_CMAKE_ARGS = [
|
||||
"-Dwerr=ON",
|
||||
"-Dxrpld=ON",
|
||||
"-Dwextra=ON",
|
||||
"-Drust=ON",
|
||||
]
|
||||
|
||||
# Maps sanitizer names (as used in cmake) to short config-name suffixes.
|
||||
|
||||
2
.github/scripts/strategy-matrix/linux.json
vendored
2
.github/scripts/strategy-matrix/linux.json
vendored
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"image_tag": "sha-473fe44",
|
||||
"image_tag": "sha-060957e",
|
||||
"configs": {
|
||||
"ubuntu": [
|
||||
{
|
||||
|
||||
6
.github/workflows/build-nix-images.yml
vendored
6
.github/workflows/build-nix-images.yml
vendored
@@ -5,15 +5,13 @@ on:
|
||||
branches:
|
||||
- develop
|
||||
paths:
|
||||
- ".github/workflows/build-nix-images.yml"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "rust-toolchain.toml"
|
||||
- "nix/**"
|
||||
- "!nix/docker/README.md"
|
||||
- "!nix/devshell.nix"
|
||||
- "!nix/check-tools/*.txt"
|
||||
- "bin/check-tools.sh"
|
||||
- "!nix/check-tools/**"
|
||||
- "bin/default-loader-path.sh"
|
||||
- "bin/install-sanitizer-libs.sh"
|
||||
pull_request:
|
||||
@@ -25,7 +23,7 @@ on:
|
||||
- "nix/**"
|
||||
- "!nix/docker/README.md"
|
||||
- "!nix/devshell.nix"
|
||||
- "!nix/check-tools/*.txt"
|
||||
- "!nix/check-tools/**"
|
||||
- "bin/check-tools.sh"
|
||||
- "bin/default-loader-path.sh"
|
||||
- "bin/install-sanitizer-libs.sh"
|
||||
|
||||
2
.github/workflows/cargo-audit.yml
vendored
2
.github/workflows/cargo-audit.yml
vendored
@@ -34,7 +34,7 @@ permissions:
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44
|
||||
container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-060957e
|
||||
permissions:
|
||||
contents: read
|
||||
# Needed to open an issue on scheduled failures.
|
||||
|
||||
2
.github/workflows/publish-docs.yml
vendored
2
.github/workflows/publish-docs.yml
vendored
@@ -41,7 +41,7 @@ env:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44
|
||||
container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-060957e
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
@@ -372,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
|
||||
|
||||
3
.github/workflows/reusable-clang-tidy.yml
vendored
3
.github/workflows/reusable-clang-tidy.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
needs: [determine-files]
|
||||
if: ${{ needs.determine-files.outputs.cpp_changed_files != '' || needs.determine-files.outputs.need_full_run == 'true' }}
|
||||
runs-on: ["self-hosted", "Linux", "X64", "heavy"]
|
||||
container: "ghcr.io/xrplf/xrpld/nix-debian:sha-473fe44"
|
||||
container: "ghcr.io/xrplf/xrpld/nix-debian:sha-060957e"
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
@@ -86,7 +86,6 @@ jobs:
|
||||
-Dwerr=ON \
|
||||
-Dxrpld=ON \
|
||||
-Dverify_headers=ON \
|
||||
-Drust=ON \
|
||||
..
|
||||
|
||||
- name: Build clang-tidy prerequisites
|
||||
|
||||
14
.github/workflows/reusable-rust.yml
vendored
14
.github/workflows/reusable-rust.yml
vendored
@@ -1,8 +1,9 @@
|
||||
# Clippy, coverage and documentation for the Rust crates in crates/. Each runs
|
||||
# as an independent job on a GitHub-hosted runner, but inside the same container
|
||||
# image used to build the crates in the C++/Corrosion path, so the toolchain
|
||||
# (and therefore the lints, coverage instrumentation and the cargo cache) matches
|
||||
# what production builds use.
|
||||
# (and therefore the lints and the cargo cache) matches what production builds
|
||||
# use. Coverage is the exception: it needs the nightly rustc that honours
|
||||
# #[coverage(off)], which the image carries alongside the pinned stable.
|
||||
#
|
||||
# Rust unit tests are deliberately NOT run here. They run as part of the C++
|
||||
# build (reusable-build-test-config.yml), which already compiles the crates on a
|
||||
@@ -27,7 +28,7 @@ permissions:
|
||||
jobs:
|
||||
clippy:
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44
|
||||
container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-060957e
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -40,11 +41,14 @@ jobs:
|
||||
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44
|
||||
container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-060957e
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Use the nightly Rust toolchain
|
||||
run: rust-nightly path >>"${GITHUB_PATH}"
|
||||
|
||||
- name: Use cargo artifacts cache
|
||||
uses: ./.github/actions/cargo-cache
|
||||
|
||||
@@ -66,7 +70,7 @@ jobs:
|
||||
|
||||
doc:
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44
|
||||
container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-060957e
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
2
.github/workflows/reusable-upload-recipe.yml
vendored
2
.github/workflows/reusable-upload-recipe.yml
vendored
@@ -40,7 +40,7 @@ defaults:
|
||||
jobs:
|
||||
upload:
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44
|
||||
container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-060957e
|
||||
env:
|
||||
REMOTE_NAME: ${{ inputs.remote_name }}
|
||||
CONAN_LOGIN_USERNAME_XRPLF: ${{ secrets.remote_username }}
|
||||
|
||||
@@ -70,6 +70,11 @@ repos:
|
||||
language: system
|
||||
types: [rust]
|
||||
pass_filenames: false # rustfmt formats the whole workspace
|
||||
- id: check-coverage-attrs
|
||||
name: check Rust coverage attributes
|
||||
entry: ./bin/pre-commit/check_rust_coverage_attrs.py
|
||||
language: python
|
||||
files: ^crates/.*\.rs$
|
||||
|
||||
- repo: https://github.com/BlankSpruce/gersemi-pre-commit
|
||||
rev: e98930bdc210d3387007f9252d8c1694ea7e410f # frozen: 0.27.7
|
||||
|
||||
@@ -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.
|
||||
|
||||
29
BUILD.md
29
BUILD.md
@@ -1,6 +1,6 @@
|
||||
| :warning: **WARNING** :warning: |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| These instructions assume you have a C++ development environment ready with Git, Python, Conan, CMake, and a C++ compiler. For help setting one up on Linux, macOS, or Windows, [see this guide](./docs/build/environment.md).<br><br>These instructions also assume a basic familiarity with Conan and CMake. If you are unfamiliar with Conan, you can read our [crash course](./docs/build/conan.md) or the official [Getting Started][conan-getting-started] walkthrough. |
|
||||
| :warning: **WARNING** :warning: |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| These instructions assume you have a C++ development environment ready with Git, Python, Conan, CMake, Rust, and a C++ compiler. For help setting one up on Linux, macOS, or Windows, [see this guide](./docs/build/environment.md).<br><br>These instructions also assume a basic familiarity with Conan and CMake. If you are unfamiliar with Conan, you can read our [crash course](./docs/build/conan.md) or the official [Getting Started][conan-getting-started] walkthrough. |
|
||||
|
||||
## Minimum Requirements
|
||||
|
||||
@@ -304,7 +304,6 @@ See [Sanitizers docs](./docs/build/sanitizers.md) for more details.
|
||||
| ---------------- | ------------- | ----------------------------------------------------------------------------- |
|
||||
| `assert` | OFF | Force enabling assertions. |
|
||||
| `coverage` | OFF | Prepare the coverage report. |
|
||||
| `rust` | OFF | Build the Rust crates and the C++ code that depends on them. |
|
||||
| `tests` | OFF | Build tests. |
|
||||
| `unity` | OFF | Configure a unity build. |
|
||||
| `verify_headers` | ON | Make the `verify-headers` target available to compile each header on its own. |
|
||||
@@ -319,23 +318,15 @@ builds may be faster for incremental builds, and can be helpful for detecting
|
||||
|
||||
### Rust crates
|
||||
|
||||
The Rust crates in `crates/` are only part of the build when `rust` is ON. With
|
||||
`-Drust=OFF` (the default) the `crates` directory is not added to the build, no
|
||||
cxxbridge bindings are generated, and the C++ tests that exercise the Rust
|
||||
interop are not compiled — so no Rust toolchain is needed. CI builds always pass
|
||||
`-Drust=ON`.
|
||||
|
||||
With `-Drust=ON` you need one extra dependency: a Rust toolchain (`cargo`,
|
||||
`rustc`) matching the channel pinned in
|
||||
[`rust-toolchain.toml`](./rust-toolchain.toml), which compiles the crates and
|
||||
generates the cxxbridge bindings. It is provided by the
|
||||
[Nix development shell](./docs/build/nix.md), so `-Drust=ON` works there without
|
||||
any extra setup; otherwise install it as described in
|
||||
[Rust](./docs/build/environment.md#rust).
|
||||
The build compiles the Rust workspace in `crates/` and generates the cxxbridge
|
||||
bindings the C++ side includes, so it needs a Rust toolchain (`cargo`, `rustc`)
|
||||
at the channel pinned in [`rust-toolchain.toml`](./rust-toolchain.toml). The
|
||||
[Nix development shell](./docs/build/nix.md) provides one; otherwise install it
|
||||
as described in [Rust](./docs/build/environment.md#rust).
|
||||
|
||||
The crates also have their own Rust unit tests. Those are run with `cargo` and
|
||||
need only the Rust toolchain, independently of CMake and of the `rust` option
|
||||
(CI runs them with `cargo nextest`):
|
||||
need only the Rust toolchain, independently of CMake (CI runs them with
|
||||
`cargo nextest`):
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path crates/Cargo.toml --workspace
|
||||
|
||||
@@ -160,11 +160,8 @@ endif()
|
||||
|
||||
add_custom_target(tidy_prerequisites)
|
||||
|
||||
if(rust)
|
||||
add_subdirectory(crates)
|
||||
endif()
|
||||
add_subdirectory(crates)
|
||||
include(XrplCore)
|
||||
|
||||
include(XrplProtocolAutogen)
|
||||
include(XrplInstall)
|
||||
include(XrplValidatorKeys)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -158,6 +158,7 @@ if [ "${os}" = "linux" ] || [ "${os}" = "macos" ]; then
|
||||
check cargo-nextest cargo nextest --version
|
||||
check clippy-driver
|
||||
check rust-analyzer
|
||||
check rust-nightly rust-nightly run rustc --version
|
||||
check rustc
|
||||
check rustfmt
|
||||
fi
|
||||
|
||||
149
bin/pre-commit/check_rust_coverage_attrs.py
Executable file
149
bin/pre-commit/check_rust_coverage_attrs.py
Executable file
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Check that Rust unit tests stay out of the coverage report.
|
||||
|
||||
cargo-llvm-cov instruments the test code along with everything else, so a test
|
||||
module that is not excluded counts its own body as covered and inflates the
|
||||
reported number. Excluding it takes two attributes:
|
||||
|
||||
* every `#[cfg(test)]` module carries
|
||||
`#[cfg_attr(coverage_nightly, coverage(off))]`;
|
||||
* every crate root (lib.rs, main.rs) carries
|
||||
`#![cfg_attr(coverage_nightly, feature(coverage_attribute))]`, which the
|
||||
attribute above needs in order to compile.
|
||||
|
||||
Both are inert outside the coverage job: cargo-llvm-cov defines
|
||||
`coverage_nightly` only when it runs on a nightly toolchain.
|
||||
|
||||
The crate-root gate is checked even in a crate that has no tests yet, because
|
||||
that is what lets the first test module added later carry the attribute without
|
||||
a build failure. Missing it is a hard error, so it cannot go unnoticed; a
|
||||
missing `coverage(off)` fails open, which is why this check exists.
|
||||
|
||||
Matching is on exact attribute text, which works because `cargo fmt` runs over
|
||||
the whole workspace in the hook ahead of this one: rustfmt puts every attribute
|
||||
on its own line and normalizes what is inside it, turning `#[cfg( test )]`
|
||||
and `#[cfg(test,)]` alike into `#[cfg(test)]`. So there is nothing here that
|
||||
parses Rust. The price is that a cfg this file does not spell out literally --
|
||||
`all(test, ...)`, `any(test, ...)`, `not(test)` -- is reported rather than
|
||||
classified, on the grounds that guessing at coverage semantics is how a check
|
||||
like this ends up quietly wrong.
|
||||
|
||||
Usage: ./bin/pre-commit/check_rust_coverage_attrs.py <file1> <file2> ...
|
||||
|
||||
Exit status is non-zero if any violation is found.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
CRATE_ROOTS = {"lib.rs", "main.rs"}
|
||||
|
||||
FEATURE_ATTR = "#![cfg_attr(coverage_nightly, feature(coverage_attribute))]"
|
||||
COVERAGE_OFF_ATTR = "#[cfg_attr(coverage_nightly, coverage(off))]"
|
||||
CFG_TEST_ATTR = "#[cfg(test)]"
|
||||
|
||||
# Any other cfg that mentions `test`. String literals are blanked before this
|
||||
# runs, so `feature = "test"` does not read as the `test` cfg.
|
||||
RE_CFG_MENTIONS_TEST = re.compile(r"^#\[cfg\(.*\btest\b.*\)\]$")
|
||||
RE_STRING = re.compile(r'"(?:[^"\\]|\\.)*"')
|
||||
RE_MOD = re.compile(r"^(?:pub(?:\([^)]*\))?\s+)?mod\s+([A-Za-z_]\w*)")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Finding:
|
||||
line: int
|
||||
label: str
|
||||
message: str
|
||||
|
||||
|
||||
def _check_module(attrs: list[str], line: int, name: str) -> list[Finding]:
|
||||
"""Findings for one module, given the attributes attached to it."""
|
||||
if COVERAGE_OFF_ATTR in attrs:
|
||||
return [] # excluded from coverage; which cfg gates it does not matter
|
||||
if CFG_TEST_ATTR in attrs:
|
||||
return [
|
||||
Finding(
|
||||
line,
|
||||
"missing-coverage-off",
|
||||
f"`mod {name}` is #[cfg(test)] but not excluded from coverage; "
|
||||
f"add {COVERAGE_OFF_ATTR}",
|
||||
)
|
||||
]
|
||||
unclassified = [
|
||||
attr for attr in attrs if RE_CFG_MENTIONS_TEST.match(RE_STRING.sub('""', attr))
|
||||
]
|
||||
if unclassified:
|
||||
return [
|
||||
Finding(
|
||||
line,
|
||||
"unclassified-cfg",
|
||||
f"`mod {name}` is gated on {unclassified[0]}, which this check "
|
||||
f"cannot tell apart from a module that ships in the library; "
|
||||
f"add {COVERAGE_OFF_ATTR} if it is test-only, or teach this "
|
||||
f"check the cfg if it is not",
|
||||
)
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def _check_test_modules(lines: list[str]) -> list[Finding]:
|
||||
"""Findings for every test module that is not excluded from coverage."""
|
||||
findings: list[Finding] = []
|
||||
attrs: list[str] = []
|
||||
attrs_line = 0
|
||||
for number, raw in enumerate(lines, start=1):
|
||||
stripped = raw.strip()
|
||||
# Blank lines and comments are allowed between an attribute and its item.
|
||||
if not stripped or stripped.startswith("//"):
|
||||
continue
|
||||
if stripped.startswith("#["):
|
||||
if not attrs:
|
||||
attrs_line = number
|
||||
attrs.append(stripped)
|
||||
continue
|
||||
module = RE_MOD.match(stripped)
|
||||
if module is not None and attrs:
|
||||
findings += _check_module(attrs, attrs_line, module.group(1))
|
||||
attrs = []
|
||||
return findings
|
||||
|
||||
|
||||
def _check_crate_root(name: str, lines: list[str]) -> list[Finding]:
|
||||
"""A finding if a crate root is missing the coverage_attribute feature gate."""
|
||||
if name not in CRATE_ROOTS:
|
||||
return []
|
||||
if any(line.strip() == FEATURE_ATTR for line in lines):
|
||||
return []
|
||||
return [
|
||||
Finding(
|
||||
1,
|
||||
"missing-feature-gate",
|
||||
f"crate root is missing {FEATURE_ATTR}",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def check_source(name: str, text: str) -> list[Finding]:
|
||||
"""Findings for one file's contents; `name` is its base name (lib.rs, ...)."""
|
||||
lines = text.splitlines()
|
||||
return _check_crate_root(name, lines) + _check_test_modules(lines)
|
||||
|
||||
|
||||
def check_file(path: Path) -> list[Finding]:
|
||||
return check_source(path.name, path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
total = 0
|
||||
for path in (Path(name) for name in sys.argv[1:]):
|
||||
for finding in check_file(path):
|
||||
total += 1
|
||||
print(f"{path}:{finding.line}: {finding.label}: {finding.message}")
|
||||
return 1 if total else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1360,6 +1360,39 @@
|
||||
# Example:
|
||||
# owner_reserve = 200000 # 0.2 XRP
|
||||
#
|
||||
# gas_limit = <gas>
|
||||
#
|
||||
# The gas limit is the maximum amount of gas that can be
|
||||
# consumed by a single transaction. The gas limit is used to prevent
|
||||
# transactions from consuming too many resources.
|
||||
#
|
||||
# If this parameter is unspecified, xrpld will use an internal
|
||||
# default. Don't change this without understanding the consequences.
|
||||
#
|
||||
# Example:
|
||||
# gas_limit = 1000000 # 1 million gas
|
||||
#
|
||||
# bytecode_size_limit = <bytes>
|
||||
#
|
||||
# The bytecode size limit is the maximum size of a WASM extension in
|
||||
# bytes. The size limit is used to prevent extensions from consuming
|
||||
# too many resources.
|
||||
#
|
||||
# If this parameter is unspecified, xrpld will use an internal
|
||||
# default. Don't change this without understanding the consequences.
|
||||
#
|
||||
# Example:
|
||||
# bytecode_size_limit = 100000 # 100 kb
|
||||
#
|
||||
# gas_price = <micro-drops>
|
||||
#
|
||||
# The gas price is the conversion between WASM gas and its price in drops.
|
||||
#
|
||||
# If this parameter is unspecified, xrpld will use an internal
|
||||
# default. Don't change this without understanding the consequences.
|
||||
#
|
||||
# Example:
|
||||
# gas_price = 1000000 # 1 drop per gas
|
||||
#-------------------------------------------------------------------------------
|
||||
#
|
||||
# 9. Misc Settings
|
||||
|
||||
@@ -29,6 +29,14 @@ function(xrpl_add_benchmark name)
|
||||
# XrplCore.cmake. Each file compiles fine on its own.
|
||||
set_target_properties(${target} PROPERTIES UNITY_BUILD OFF)
|
||||
|
||||
# Land next to `xrpl_tests` in the build root rather than buried under
|
||||
# `src/benchmarks/libxrpl/`. A benchmark is something a person runs by hand,
|
||||
# repeatedly, and comparing two of them should not mean typing two long paths.
|
||||
set_target_properties(
|
||||
${target}
|
||||
PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}"
|
||||
)
|
||||
|
||||
isolate_headers(
|
||||
${target}
|
||||
"${CMAKE_SOURCE_DIR}/src"
|
||||
|
||||
@@ -207,7 +207,11 @@ target_link_libraries(
|
||||
)
|
||||
|
||||
add_module(xrpl tx)
|
||||
target_link_libraries(xrpl.libxrpl.tx PUBLIC xrpl.libxrpl.ledger)
|
||||
target_link_libraries(
|
||||
xrpl.libxrpl.tx
|
||||
PUBLIC xrpl.libxrpl.ledger xrpl_wasm_vm_ffi_cxxbridge
|
||||
)
|
||||
add_dependencies(xrpl.libxrpl.tx xrpl_crates)
|
||||
|
||||
add_module(xrpl consensus)
|
||||
target_link_libraries(
|
||||
|
||||
@@ -32,11 +32,6 @@ endif()
|
||||
|
||||
option(benchmark "Build benchmarks" ON)
|
||||
|
||||
# When OFF, the crates directory is not added to the build at all: no Rust
|
||||
# toolchain is required, no cxxbridge bindings are generated, and the C++ tests
|
||||
# that consume those bindings are left out of the build tree.
|
||||
option(rust "Build the Rust crates and the C++ code that depends on them" OFF)
|
||||
|
||||
# Enabled by default so every header is compiled on its own as the main file of
|
||||
# its own compile_commands.json entry - this is what lets clang-tidy (and clangd
|
||||
# and IDEs) analyse a header's own includes directly. The per-header objects are
|
||||
|
||||
@@ -152,8 +152,12 @@ class Xrpl(ConanFile):
|
||||
"CMakeLists.txt",
|
||||
"cfg/*",
|
||||
"cmake/*",
|
||||
"crates/*",
|
||||
"crates/.cargo/*",
|
||||
"!crates/target/*",
|
||||
"external/*",
|
||||
"include/*",
|
||||
"rust-toolchain.toml",
|
||||
"src/*",
|
||||
)
|
||||
|
||||
|
||||
@@ -101,4 +101,11 @@ function(add_xrpl_crate name)
|
||||
add_dependencies(xrpl_crates ${name}_cxxbridge)
|
||||
endfunction()
|
||||
|
||||
add_xrpl_crate(rs_hello_world CRATE rs_hello_world FILES lib.rs)
|
||||
add_xrpl_crate(xrpl_wasm_vm_ffi CRATE xrpl_wasm_vm_ffi FILES lib.rs)
|
||||
|
||||
add_xrpl_crate(xrpl_wasm_testkit CRATE xrpl_wasm_testkit FILES lib.rs)
|
||||
|
||||
target_include_directories(
|
||||
xrpl_wasm_vm_ffi_cxxbridge
|
||||
PRIVATE ${CMAKE_SOURCE_DIR}/include
|
||||
)
|
||||
|
||||
215
crates/Cargo.lock
generated
215
crates/Cargo.lock
generated
@@ -8,6 +8,18 @@ version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.61"
|
||||
@@ -66,7 +78,7 @@ dependencies = [
|
||||
"cxxbridge-cmd",
|
||||
"cxxbridge-flags",
|
||||
"cxxbridge-macro",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
"link-cplusplus",
|
||||
]
|
||||
|
||||
@@ -129,12 +141,27 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.0"
|
||||
@@ -148,9 +175,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"hashbrown 0.17.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "link-cplusplus"
|
||||
version = "1.0.12"
|
||||
@@ -160,6 +199,12 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
@@ -178,19 +223,18 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rs-hello_world"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cxx",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scratch"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
@@ -227,6 +271,22 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
|
||||
|
||||
[[package]]
|
||||
name = "string-interner"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -276,6 +336,99 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.254.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09480d646178e5fdd12bb06e812d0af9a3a191dbc9cd697fdc86687beade7393"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser 0.254.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmi"
|
||||
version = "2.0.0"
|
||||
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",
|
||||
]
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
[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"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unexpected_cfgs = { level = "warn", check-cfg = [ 'cfg(coverage)', 'cfg(coverage_nightly)' ] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
overflow-checks = true
|
||||
|
||||
120
crates/README.md
Normal file
120
crates/README.md
Normal 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
|
||||
```
|
||||
@@ -1,10 +0,0 @@
|
||||
[package]
|
||||
name = "rs-hello_world"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["staticlib"]
|
||||
|
||||
[dependencies]
|
||||
cxx.workspace = true
|
||||
@@ -1,10 +0,0 @@
|
||||
#[cxx::bridge(namespace = "rs::hello_world")]
|
||||
mod ffi {
|
||||
extern "Rust" {
|
||||
fn hello_world() -> String;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hello_world() -> String {
|
||||
"hello_world".to_string()
|
||||
}
|
||||
21
crates/xrpl-host-functions-macros/Cargo.toml
Normal file
21
crates/xrpl-host-functions-macros/Cargo.toml
Normal 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
|
||||
33
crates/xrpl-host-functions-macros/src/errors.rs
Normal file
33
crates/xrpl-host-functions-macros/src/errors.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
472
crates/xrpl-host-functions-macros/src/glue.rs
Normal file
472
crates/xrpl-host-functions-macros/src/glue.rs
Normal 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 = ¶m.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(¶m.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(¶m.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()
|
||||
}
|
||||
}
|
||||
496
crates/xrpl-host-functions-macros/src/lib.rs
Normal file
496
crates/xrpl-host-functions-macros/src/lib.rs
Normal 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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
488
crates/xrpl-host-functions-macros/src/lowering.rs
Normal file
488
crates/xrpl-host-functions-macros/src/lowering.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
1155
crates/xrpl-host-functions-macros/src/parsed_host_function.rs
Normal file
1155
crates/xrpl-host-functions-macros/src/parsed_host_function.rs
Normal file
File diff suppressed because it is too large
Load Diff
23
crates/xrpl-host-functions/Cargo.toml
Normal file
23
crates/xrpl-host-functions/Cargo.toml
Normal 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
|
||||
572
crates/xrpl-host-functions/src/lib.rs
Normal file
572
crates/xrpl-host-functions/src/lib.rs
Normal 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>;
|
||||
}
|
||||
102
crates/xrpl-host-functions/src/macros.rs
Normal file
102
crates/xrpl-host-functions/src/macros.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
//! The `macro_rules!` behind the two hand-listed enums, [`crate::HostError`] and
|
||||
//! [`crate::TraceDataType`].
|
||||
//!
|
||||
//! Each takes one list of `Variant = code,` and expands the enum together with the
|
||||
//! `ALL`/`code`/`from_code` set that must not fall behind it. The lists themselves stay
|
||||
//! in `lib.rs`, beside the `host_functions!` block.
|
||||
|
||||
/// Declares [`crate::HostError`] from one list: the variants, `HostError::ALL` and
|
||||
/// `HostError::from_code`'s table all expand from the codes given.
|
||||
///
|
||||
/// One list is what makes `ALL` complete. Rust cannot enumerate an enum's
|
||||
/// variants — an exhaustive `match` forces an arm per variant but gives nothing to
|
||||
/// iterate — so a hand-written `ALL` beside a hand-written enum could only be kept
|
||||
/// in step by review, and `ALL`'s whole purpose is to be the set a test can trust.
|
||||
/// A code added to the list gains its `ALL` entry and its `from_code` arm by
|
||||
/// construction. `HostFunctionSpec::ALL` is complete the same way, from the
|
||||
/// `host_functions!` block.
|
||||
macro_rules! host_errors {
|
||||
($($(#[$doc:meta])* $variant:ident = $code:literal,)+) => {
|
||||
/// Error codes a host function may return.
|
||||
///
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(i32)]
|
||||
pub enum HostError {
|
||||
$($(#[$doc])* $variant = $code,)+
|
||||
}
|
||||
|
||||
impl HostError {
|
||||
/// Every error a host function may return, in code order.
|
||||
///
|
||||
/// The complete set, and complete by construction: a wasm engine's
|
||||
/// split between the codes it hands the guest and the conditions it
|
||||
/// traps on is a decision per variant, so the test that checks the
|
||||
/// split iterates this and a code added to the ABI cannot slip past it.
|
||||
pub const ALL: &'static [HostError] = &[$(HostError::$variant,)+];
|
||||
|
||||
/// The negative wire value a failed call returns. Every code but
|
||||
/// `InternalFatal` is one a guest reads off that value.
|
||||
#[inline]
|
||||
pub const fn code(self) -> i32 {
|
||||
self as i32
|
||||
}
|
||||
|
||||
/// Reconstruct a `HostError` from its wire code.
|
||||
///
|
||||
/// A code this ABI does not define is `InternalFatal`: an answer the
|
||||
/// caller cannot act on is the call not having been served, and that is
|
||||
/// the variant which says so. Positive values are not errors at all and go
|
||||
/// the same way, since this is reached only once a negative return has
|
||||
/// been read as a failure.
|
||||
pub const fn from_code(code: i32) -> HostError {
|
||||
match code {
|
||||
$($code => HostError::$variant,)+
|
||||
_ => HostError::InternalFatal,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Declares [`crate::TraceDataType`] from one list, so `TraceDataType::ALL`,
|
||||
/// `TraceDataType::code` and `TraceDataType::from_code` cannot fall behind the
|
||||
/// variants — the reason `host_errors!` above is written this way.
|
||||
macro_rules! trace_data_types {
|
||||
($($(#[$doc:meta])* $variant:ident = $code:literal,)+) => {
|
||||
/// How [`HostFunctions::trace`] is to read its data buffer.
|
||||
///
|
||||
/// The discriminants are wire values shared with the guest stdlib: append only,
|
||||
/// never renumber. They start at 1, so a zeroed argument names no type rather
|
||||
/// than the first one.
|
||||
///
|
||||
/// This is the declaration a guest and a host both compile against. The host
|
||||
/// side needs a second one — `cxx` cannot be a dependency here, since this
|
||||
/// crate also links into the guest — so `xrpl-wasm-vm-ffi` declares a shared
|
||||
/// enum for C++ and converts, exhaustively, from this.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(i32)]
|
||||
pub enum TraceDataType {
|
||||
$($(#[$doc])* $variant = $code,)+
|
||||
}
|
||||
|
||||
impl TraceDataType {
|
||||
/// Every data type a guest may name, in code order.
|
||||
pub const ALL: &'static [TraceDataType] = &[$(TraceDataType::$variant,)+];
|
||||
|
||||
/// The wire value a guest passes to name this type.
|
||||
#[inline]
|
||||
pub const fn code(self) -> i32 {
|
||||
self as i32
|
||||
}
|
||||
|
||||
/// The type `code` names, or `None`: the engine drops a call it cannot
|
||||
/// read rather than guessing at a rendering the guest did not ask for.
|
||||
pub const fn from_code(code: i32) -> Option<TraceDataType> {
|
||||
match code {
|
||||
$($code => Some(TraceDataType::$variant),)+
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
41
crates/xrpl-host-functions/tests/expansion_hygiene.rs
Normal file
41
crates/xrpl-host-functions/tests/expansion_hygiene.rs
Normal 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));
|
||||
}
|
||||
1172
crates/xrpl-host-functions/tests/generated_abi.rs
Normal file
1172
crates/xrpl-host-functions/tests/generated_abi.rs
Normal file
File diff suppressed because it is too large
Load Diff
102
crates/xrpl-host-functions/tests/host_errors.rs
Normal file
102
crates/xrpl-host-functions/tests/host_errors.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
//! Exercises what `host_errors!` generates: the wire codes, the set
|
||||
//! [`HostError::ALL`] names, and the round trip between them.
|
||||
//!
|
||||
//! The codes are consensus input — they are what a guest reads off a failed host
|
||||
//! call — so they are pinned here as literals and derived everywhere else.
|
||||
|
||||
use xrpl_host_functions::HostError;
|
||||
|
||||
/// The whole set, written out in the order `ALL` gives it: the one place the wire
|
||||
/// codes appear as literals, and a deliberate change-detector, since a code that
|
||||
/// moves changes what every deployed guest is told.
|
||||
#[test]
|
||||
fn the_error_table_matches_the_declarations() {
|
||||
let table: Vec<(HostError, i32)> = HostError::ALL
|
||||
.iter()
|
||||
.map(|&error| (error, error.code()))
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
table,
|
||||
[
|
||||
(HostError::Unimplemented, -1),
|
||||
(HostError::FieldNotFound, -2),
|
||||
(HostError::BufferTooSmall, -3),
|
||||
(HostError::NoArray, -4),
|
||||
(HostError::NotLeafField, -5),
|
||||
(HostError::LocatorMalformed, -6),
|
||||
(HostError::SlotOutRange, -7),
|
||||
(HostError::SlotsFull, -8),
|
||||
(HostError::EmptySlot, -9),
|
||||
(HostError::LedgerObjNotFound, -10),
|
||||
(HostError::OutOfTransferLimit, -11),
|
||||
(HostError::DataFieldTooLarge, -12),
|
||||
(HostError::PointerOutOfBounds, -13),
|
||||
(HostError::NoMemExported, -14),
|
||||
(HostError::InvalidParams, -15),
|
||||
(HostError::InvalidAccount, -16),
|
||||
(HostError::InvalidField, -17),
|
||||
(HostError::IndexOutOfBounds, -18),
|
||||
(HostError::FloatInputMalformed, -19),
|
||||
(HostError::FloatComputationError, -20),
|
||||
(HostError::InternalFatal, i32::MIN),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// The guest-facing set is `-1 ..= -20` and nothing else: those entries are xrpld's
|
||||
/// `HostFunctionError`, and each is a code some contract may read.
|
||||
///
|
||||
/// `InternalFatal` is the one deliberate exception, exempted by name rather than by
|
||||
/// widening the range: a condition with no number a contract can act on needs no number
|
||||
/// in the range a contract reads, and holding it at `i32::MIN` is what keeps it from
|
||||
/// ever colliding with a code appended to xrpld's list.
|
||||
#[test]
|
||||
fn every_code_but_the_sentinel_is_in_the_shared_range() {
|
||||
let shared: Vec<HostError> = HostError::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&error| error != HostError::InternalFatal)
|
||||
.collect();
|
||||
|
||||
let outside: Vec<HostError> = shared
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|error| !(-20..=-1).contains(&error.code()))
|
||||
.collect();
|
||||
|
||||
assert!(outside.is_empty(), "outside -1..=-20: {outside:?}");
|
||||
assert_eq!(shared.len(), 20);
|
||||
assert_eq!(HostError::InternalFatal.code(), i32::MIN);
|
||||
assert_eq!(HostError::ALL.len(), 21);
|
||||
}
|
||||
|
||||
/// Every code a guest can be handed comes back as the error that produced it, so a
|
||||
/// caller reading a negative return value recovers the condition and not a
|
||||
/// neighbouring one. The table above pins the numbers; this adds only the round
|
||||
/// trip.
|
||||
#[test]
|
||||
fn every_wire_code_round_trips_back_to_its_error() {
|
||||
for &error in HostError::ALL {
|
||||
assert_eq!(HostError::from_code(error.code()), error, "{error:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A code from outside the set is `InternalFatal`: a host answering something this ABI
|
||||
/// does not define has not served the call, whatever it meant by it, and success is not
|
||||
/// an error at all.
|
||||
///
|
||||
/// `-21` is the code xrpld would append next, so it is the one that decides whether a
|
||||
/// list this crate has not caught up with reaches a guest or stops the run. `i32::MIN +
|
||||
/// 1` is next to the sentinel and unassigned, which is what makes the sentinel a value
|
||||
/// rather than a range.
|
||||
#[test]
|
||||
fn a_code_outside_the_set_is_internal_fatal() {
|
||||
for code in [-21, i32::MIN + 1, 0, 1, i32::MAX] {
|
||||
assert_eq!(
|
||||
HostError::from_code(code),
|
||||
HostError::InternalFatal,
|
||||
"{code}"
|
||||
);
|
||||
}
|
||||
}
|
||||
15
crates/xrpl-wasm-testkit/Cargo.toml
Normal file
15
crates/xrpl-wasm-testkit/Cargo.toml
Normal 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
|
||||
109
crates/xrpl-wasm-testkit/src/lib.rs
Normal file
109
crates/xrpl-wasm-testkit/src/lib.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
15
crates/xrpl-wasm-vm-ffi/Cargo.toml
Normal file
15
crates/xrpl-wasm-vm-ffi/Cargo.toml
Normal 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
|
||||
1336
crates/xrpl-wasm-vm-ffi/src/lib.rs
Normal file
1336
crates/xrpl-wasm-vm-ffi/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
14
crates/xrpl-wasm-vm/Cargo.toml
Normal file
14
crates/xrpl-wasm-vm/Cargo.toml
Normal 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
|
||||
838
crates/xrpl-wasm-vm/src/abi.rs
Normal file
838
crates/xrpl-wasm-vm/src/abi.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
244
crates/xrpl-wasm-vm/src/args.rs
Normal file
244
crates/xrpl-wasm-vm/src/args.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
29
crates/xrpl-wasm-vm/src/lib.rs
Normal file
29
crates/xrpl-wasm-vm/src/lib.rs
Normal 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};
|
||||
pub use vm::{
|
||||
MAX_FIELD_BYTES, MAX_MEMORY_BYTES, MAX_MEMORY_PAGES, MAX_TABLE_ELEMENTS, RunError, RunFailure,
|
||||
RunOutcome, TRANSFER_LIMIT_BYTES, run,
|
||||
};
|
||||
479
crates/xrpl-wasm-vm/src/preflight/mod.rs
Normal file
479
crates/xrpl-wasm-vm/src/preflight/mod.rs
Normal file
@@ -0,0 +1,479 @@
|
||||
//! Screening a contract before it reaches the ledger.
|
||||
//!
|
||||
//! [`check`] answers whether [`crate::run`] would refuse a module before the
|
||||
//! guest's first instruction — the three stages a caller maps to a malformed
|
||||
//! transaction rather than to a failed one. It needs **no host, no store and no
|
||||
//! gas**: everything it reads is a property of the compiled module. That is what
|
||||
//! makes it callable from a transaction's preflight, which has no ledger to serve
|
||||
//! host calls from.
|
||||
//!
|
||||
//! Two things it deliberately does not screen. A module exporting **no** linear
|
||||
//! memory passes: a contract that makes no host call needs none, and one that
|
||||
//! does is refused at the call and charged for what it burned. A start section
|
||||
//! passes: it is guest code, and executing it is the one thing a check must not do
|
||||
//! — a trap in one is charged to the contract like any other trap.
|
||||
//!
|
||||
//! Two things it screens that a run can only discover: an exported memory, or an
|
||||
//! exported table, larger than the engine grants. Both read the same export list, so
|
||||
//! [`check_exported_resources`] is one pass — see it for what stays invisible, and
|
||||
//! why the table case leaves much more of it there.
|
||||
//!
|
||||
//! 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.
|
||||
///
|
||||
/// The stages are ordered by how much of the module each explains. An import fault
|
||||
/// is reported before a missing entry point because the imports are what the rest of
|
||||
/// the module is built on; the resource caps come last, being a request rather than a
|
||||
/// mistake about the ABI.
|
||||
pub fn check(wasm: &[u8], function_name: &str) -> Result<(), CheckError> {
|
||||
let module = compile(wasm).map_err(CheckError::Compile)?;
|
||||
check_imports(&module)?;
|
||||
check_entry_point(&module, function_name)?;
|
||||
check_exported_resources(&module)
|
||||
}
|
||||
|
||||
/// Every import must be one the linker defines, with the type it defines it as. The
|
||||
/// first that is not ends the check, so a module with several faults reports the
|
||||
/// earliest.
|
||||
fn check_imports(module: &Module) -> Result<(), CheckError> {
|
||||
for import in module.imports() {
|
||||
check_import(import.module(), import.name(), import.ty())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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(module: &Module, name: &str) -> Result<(), CheckError> {
|
||||
match module.get_export(name) {
|
||||
Some(ExternType::Func(ty)) if is_entry_point(&ty) => Ok(()),
|
||||
found => Err(CheckError::EntryPoint(entry_point_fault(found, name))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The entry point's type: nothing in, one `i32` out — what [`crate::run`]'s
|
||||
/// `get_typed_func::<(), i32>` accepts.
|
||||
fn is_entry_point(ty: &FuncType) -> bool {
|
||||
ty.params().is_empty() && matches!(ty.results(), [ValType::I32])
|
||||
}
|
||||
|
||||
/// A module may declare no more linear memory, and no larger a table, than the
|
||||
/// engine grants. One pass over the exports, since both rules read the same list and
|
||||
/// the export table is the only place either is visible.
|
||||
///
|
||||
/// **A memory or table the module keeps to itself is therefore not screened**: it is
|
||||
/// absent from the exports, and the store's limiter is what refuses it, at
|
||||
/// instantiation. That gap is wide for tables — Rust exports
|
||||
/// `__indirect_function_table` only under `--export-table`, so unexported is the
|
||||
/// normal shape — and narrow for memories, since a contract needs an exported one to
|
||||
/// make any host call at all.
|
||||
///
|
||||
/// A module faulting on both is reported by whichever it declares first. Neither
|
||||
/// fault explains the other, so there is no precedence to preserve — only the need
|
||||
/// for every node to reach the same verdict, which export order already gives.
|
||||
fn check_exported_resources(module: &Module) -> Result<(), CheckError> {
|
||||
for export in module.exports() {
|
||||
match export.ty() {
|
||||
ExternType::Memory(ty) => {
|
||||
check_initial_pages(ty.minimum()).map_err(CheckError::Memory)?;
|
||||
}
|
||||
ExternType::Table(ty) => {
|
||||
check_initial_elements(ty.minimum()).map_err(CheckError::Table)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the engine will grant a memory of this declared initial size.
|
||||
///
|
||||
/// The *minimum* only: a declared maximum past the cap is legal and simply
|
||||
/// unreachable, which `vm_limits::a_declared_maximum_past_the_cap_is_allowed_but_
|
||||
/// unreachable` pins on the run side. Refusing it here would turn a runnable
|
||||
/// contract away.
|
||||
fn check_initial_pages(pages: u64) -> Result<(), String> {
|
||||
if pages > u64::from(MAX_MEMORY_PAGES) {
|
||||
return Err(format!(
|
||||
"initial memory of {pages} pages is past the {MAX_MEMORY_PAGES}-page cap"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the engine will grant a table of this declared initial size.
|
||||
///
|
||||
/// The *minimum* is the whole question: `table.grow` belongs to the reference-types
|
||||
/// proposal, which [`crate::vm`]'s engine turns off, so a table never becomes larger
|
||||
/// than it was declared and a declared maximum past the cap is simply unreachable.
|
||||
fn check_initial_elements(elements: u64) -> Result<(), String> {
|
||||
let cap = u64::try_from(MAX_TABLE_ELEMENTS).expect("the cap is a small constant");
|
||||
if elements > cap {
|
||||
return Err(format!(
|
||||
"initial table of {elements} elements is past the {MAX_TABLE_ELEMENTS}-element cap"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How an entry-point lookup failed, in the words both stages use: a check and a
|
||||
/// run describe the same module the same way, and "no entry point" would send a
|
||||
/// contract author looking for a function they already have.
|
||||
pub(crate) fn entry_point_fault(found: Option<ExternType>, name: &str) -> String {
|
||||
match found {
|
||||
Some(ExternType::Func(_)) => {
|
||||
format!("entry point '{name}' has the wrong signature, expected '() -> i32'")
|
||||
}
|
||||
Some(_) => format!("export '{name}' is not a function"),
|
||||
None => format!("no entry point '{name}'"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The rules, one by one, on inputs built directly rather than parsed out of a
|
||||
/// module. `tests/preflight.rs` runs real modules through [`check`]; what is here is
|
||||
/// what a module cannot state precisely — which rule fires, in which order, and in
|
||||
/// what words the caller logs it. 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, ®istered(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, ¬_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"
|
||||
);
|
||||
}
|
||||
}
|
||||
154
crates/xrpl-wasm-vm/src/preflight/signature.rs
Normal file
154
crates/xrpl-wasm-vm/src/preflight/signature.rs
Normal 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(®istered),
|
||||
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(®istered_type(HostFunctionSpec::FloatFromInt)),
|
||||
"(i64, i32, i32, i32) -> i32"
|
||||
);
|
||||
assert_eq!(
|
||||
signature(®istered_type(HostFunctionSpec::Trace)),
|
||||
"(i32, i32, i32, i32, i32)"
|
||||
);
|
||||
}
|
||||
}
|
||||
712
crates/xrpl-wasm-vm/src/register.rs
Normal file
712
crates/xrpl-wasm-vm/src/register.rs
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
406
crates/xrpl-wasm-vm/src/vm.rs
Normal file
406
crates/xrpl-wasm-vm/src/vm.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
1004
crates/xrpl-wasm-vm/tests/budgets.rs
Normal file
1004
crates/xrpl-wasm-vm/tests/budgets.rs
Normal file
File diff suppressed because it is too large
Load Diff
1449
crates/xrpl-wasm-vm/tests/host_calls.rs
Normal file
1449
crates/xrpl-wasm-vm/tests/host_calls.rs
Normal file
File diff suppressed because it is too large
Load Diff
621
crates/xrpl-wasm-vm/tests/memory_policy.rs
Normal file
621
crates/xrpl-wasm-vm/tests/memory_policy.rs
Normal file
@@ -0,0 +1,621 @@
|
||||
//! The bounds, field-cap and buffer-fit rules `abi.rs` enforces on every region
|
||||
//! crossing the boundary. This is the policy the guest observes, so each rule is
|
||||
//! pinned to the code it answers with.
|
||||
|
||||
mod support;
|
||||
|
||||
use support::{
|
||||
Answer, COMPLETED, EMPTY_REGION, FakeHost, ONE_PAGE, code, failure, import, module, status,
|
||||
traced,
|
||||
};
|
||||
use xrpl_host_functions::{HASH_LEN, HostError, TraceDataType};
|
||||
use xrpl_wasm_vm::{MAX_FIELD_BYTES, RunError};
|
||||
|
||||
/// One page, so anything at or past 65536 is out of bounds.
|
||||
const PAGE: i64 = 64 * 1024;
|
||||
|
||||
/// The per-field size cap, as a wasm operand.
|
||||
const CAP: i64 = MAX_FIELD_BYTES as i64;
|
||||
/// One byte over the cap: the smallest value the engine must refuse.
|
||||
const OVER_CAP: i64 = CAP + 1;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output regions (`write_into`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The whole output region must be in bounds, not merely its start — the engine
|
||||
/// checks `[dst, dst + cap)` before the host is allowed to write.
|
||||
#[test]
|
||||
fn an_output_region_running_past_memory_is_refused() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
for (dst, cap) in [(PAGE, 4), (PAGE - 3, 4), (PAGE + 1024, 4), (0, PAGE + 1)] {
|
||||
let wat = module(
|
||||
&[import::LDGR_INDEX, ONE_PAGE],
|
||||
&format!("(call $ldgr_index (i32.const {dst}) (i32.const {cap}))"),
|
||||
);
|
||||
assert_eq!(
|
||||
status(&wat, &host),
|
||||
code(HostError::PointerOutOfBounds),
|
||||
"dst {dst} cap {cap}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A region ending exactly at the last byte of memory is in bounds.
|
||||
#[test]
|
||||
fn an_output_region_ending_at_the_last_byte_is_allowed() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
let wat = module(
|
||||
&[import::LDGR_INDEX, ONE_PAGE],
|
||||
&format!("(call $ldgr_index (i32.const {}) (i32.const 4))", PAGE - 4),
|
||||
);
|
||||
assert_eq!(status(&wat, &host), 4);
|
||||
}
|
||||
|
||||
/// The wire carries `i32`, so a guest can present a negative pointer or length.
|
||||
#[test]
|
||||
fn a_negative_output_pointer_or_length_is_refused() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
for (dst, cap) in [(-1, 4), (0, -1), (-1, -1), (i32::MIN, 4)] {
|
||||
let wat = module(
|
||||
&[import::LDGR_INDEX, ONE_PAGE],
|
||||
&format!("(call $ldgr_index (i32.const {dst}) (i32.const {cap}))"),
|
||||
);
|
||||
assert_eq!(
|
||||
status(&wat, &host),
|
||||
code(HostError::InvalidParams),
|
||||
"dst {dst} cap {cap}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The host reports a value's true length whether or not it fitted; a value that
|
||||
/// did not fit is the guest's error, not the host's.
|
||||
#[test]
|
||||
fn a_value_larger_than_the_buffer_is_refused() {
|
||||
let host = FakeHost::new().answering_field(1, Answer::filler(64));
|
||||
|
||||
let wat = module(
|
||||
&[import::HOME_LE_FIELD, ONE_PAGE],
|
||||
"(call $home_le_field (i32.const 1) (i32.const 0) (i32.const 63))",
|
||||
);
|
||||
assert_eq!(status(&wat, &host), code(HostError::BufferTooSmall));
|
||||
|
||||
let wat = module(
|
||||
&[import::HOME_LE_FIELD, ONE_PAGE],
|
||||
"(call $home_le_field (i32.const 1) (i32.const 0) (i32.const 64))",
|
||||
);
|
||||
assert_eq!(status(&wat, &host), 64, "exactly enough room is enough");
|
||||
}
|
||||
|
||||
/// A zero-length output region is in bounds and simply cannot hold anything.
|
||||
#[test]
|
||||
fn a_zero_length_output_region_is_in_bounds_but_too_small() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
let wat = module(
|
||||
&[import::LDGR_INDEX, ONE_PAGE],
|
||||
"(call $ldgr_index (i32.const 0) (i32.const 0))",
|
||||
);
|
||||
assert_eq!(status(&wat, &host), code(HostError::BufferTooSmall));
|
||||
}
|
||||
|
||||
/// A host that reports more than the per-field cap is refused even when the
|
||||
/// guest offered room for it: the cap is the engine's rule, not the buffer's.
|
||||
#[test]
|
||||
fn a_value_past_the_field_cap_is_refused() {
|
||||
let host = FakeHost::new()
|
||||
.answering_field(1, Answer::claiming(OVER_CAP as usize))
|
||||
.answering_field(2, Answer::claiming(MAX_FIELD_BYTES));
|
||||
|
||||
let wat = module(
|
||||
&[import::HOME_LE_FIELD, ONE_PAGE],
|
||||
"(call $home_le_field (i32.const 1) (i32.const 0) (i32.const 4096))",
|
||||
);
|
||||
assert_eq!(status(&wat, &host), code(HostError::DataFieldTooLarge));
|
||||
|
||||
let wat = module(
|
||||
&[import::HOME_LE_FIELD, ONE_PAGE],
|
||||
"(call $home_le_field (i32.const 2) (i32.const 0) (i32.const 4096))",
|
||||
);
|
||||
assert_eq!(status(&wat, &host), CAP as i32, "the cap itself is allowed");
|
||||
}
|
||||
|
||||
/// A refused over-cap value leaves nothing behind. `write_into` hands the host at
|
||||
/// most [`MAX_FIELD_BYTES`] of the guest's buffer however much room the guest
|
||||
/// declared, so a value past the cap does not fit the region it is offered and no
|
||||
/// prefix of it can reach guest memory either.
|
||||
///
|
||||
/// The host answers with a real over-cap value: [`Answer::claiming`] writes
|
||||
/// nothing whatever the engine does, so it could not tell the two apart. The
|
||||
/// second module folds the *whole* declared buffer rather than one byte, so the
|
||||
/// claim is about the region and not about its first byte.
|
||||
#[test]
|
||||
fn an_over_cap_value_is_refused_without_reaching_guest_memory() {
|
||||
/// The buffer the guest declares: well over the cap, so the clamp bites.
|
||||
const BUFFER: usize = 4096;
|
||||
|
||||
let over_cap = vec![0xff; MAX_FIELD_BYTES + 1];
|
||||
let host = FakeHost::new().answering_field(1, Answer::bytes(over_cap));
|
||||
let call = format!("(call $home_le_field (i32.const 1) (i32.const 0) (i32.const {BUFFER}))");
|
||||
|
||||
// The status the guest sees, from a module that returns it directly.
|
||||
let refusing = module(&[import::HOME_LE_FIELD, ONE_PAGE], &call);
|
||||
assert_eq!(
|
||||
status(&refusing, &host),
|
||||
code(HostError::DataFieldTooLarge),
|
||||
"the value is refused"
|
||||
);
|
||||
|
||||
// Every byte of the buffer, or-ed together: guest memory starts zero-filled,
|
||||
// so any byte the host wrote shows up here.
|
||||
let reading = module(
|
||||
&[import::HOME_LE_FIELD, ONE_PAGE],
|
||||
&format!(
|
||||
"(local $i i32)
|
||||
(local $seen i32)
|
||||
(drop {call})
|
||||
(loop $l
|
||||
(local.set $seen (i32.or (local.get $seen) (i32.load8_u (local.get $i))))
|
||||
(local.set $i (i32.add (local.get $i) (i32.const 1)))
|
||||
(br_if $l (i32.lt_u (local.get $i) (i32.const {BUFFER}))))
|
||||
(local.get $seen)"
|
||||
),
|
||||
);
|
||||
assert_eq!(
|
||||
status(&reading, &host),
|
||||
0,
|
||||
"and not one of its bytes is in the guest's buffer"
|
||||
);
|
||||
}
|
||||
|
||||
/// The field cap is checked before the buffer-fit rule, so a value that breaks both
|
||||
/// is reported as over-cap. The guest branches on the code, and the two rules
|
||||
/// answer different questions, so the order is worth pinning.
|
||||
#[test]
|
||||
fn the_field_cap_precedes_the_buffer_fit_check() {
|
||||
let host = FakeHost::new().answering_field(1, Answer::claiming(MAX_FIELD_BYTES + 1));
|
||||
|
||||
// A 63-byte buffer: the value is both over the cap and far too big to fit.
|
||||
let wat = module(
|
||||
&[import::HOME_LE_FIELD, ONE_PAGE],
|
||||
"(call $home_le_field (i32.const 1) (i32.const 0) (i32.const 63))",
|
||||
);
|
||||
assert_eq!(status(&wat, &host), code(HostError::DataFieldTooLarge));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input regions (`Region::read`, via `sha512_half`)
|
||||
//
|
||||
// `sha512_half`'s first pair is an input region like any other, and it is the
|
||||
// input the guest gets a status back from: `trace`, the other reader, answers
|
||||
// nothing at all. So the codes are pinned here and the silence below.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// An input region is bounds-checked the same way an output region is. Every case
|
||||
/// here stays within the field cap, which on an input is checked first.
|
||||
#[test]
|
||||
fn an_input_region_running_past_memory_is_refused() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
for (ptr, len) in [(PAGE, 1), (PAGE - 3, 4), (PAGE - 1, CAP)] {
|
||||
let wat = module(
|
||||
&[import::SHA512_HALF, ONE_PAGE],
|
||||
&format!(
|
||||
"(call $sha512_half (i32.const {ptr}) (i32.const {len})
|
||||
(i32.const 0) (i32.const {HASH_LEN}))"
|
||||
),
|
||||
);
|
||||
assert_eq!(
|
||||
status(&wat, &host),
|
||||
code(HostError::PointerOutOfBounds),
|
||||
"ptr {ptr} len {len}"
|
||||
);
|
||||
assert!(host.digested.borrow().is_empty(), "the host is not called");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_negative_input_pointer_or_length_is_refused() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
for (ptr, len) in [(-1, 1), (0, -1), (i32::MIN, 1)] {
|
||||
let wat = module(
|
||||
&[import::SHA512_HALF, ONE_PAGE],
|
||||
&format!(
|
||||
"(call $sha512_half (i32.const {ptr}) (i32.const {len})
|
||||
(i32.const 0) (i32.const {HASH_LEN}))"
|
||||
),
|
||||
);
|
||||
assert_eq!(
|
||||
status(&wat, &host),
|
||||
code(HostError::InvalidParams),
|
||||
"ptr {ptr} len {len}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The field cap bounds what the guest may hand *in*, too.
|
||||
#[test]
|
||||
fn an_input_past_the_field_cap_is_refused() {
|
||||
let host = FakeHost::new();
|
||||
let digest = |len: i64| {
|
||||
module(
|
||||
&[import::SHA512_HALF, ONE_PAGE],
|
||||
&format!(
|
||||
"(call $sha512_half (i32.const 0) (i32.const {len})
|
||||
(i32.const 2048) (i32.const {HASH_LEN}))"
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
status(&digest(OVER_CAP), &host),
|
||||
code(HostError::DataFieldTooLarge)
|
||||
);
|
||||
assert!(host.digested.borrow().is_empty());
|
||||
|
||||
assert_eq!(
|
||||
status(&digest(CAP), &host),
|
||||
HASH_LEN as i32,
|
||||
"the cap itself is allowed"
|
||||
);
|
||||
}
|
||||
|
||||
/// The two directions check in opposite orders: an input's length is known before
|
||||
/// the read, so the cap comes first, while an output's region has to be resolved
|
||||
/// before the host can produce a value, so bounds come first there.
|
||||
#[test]
|
||||
fn the_field_cap_precedes_the_bounds_check_on_an_input() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
let reading = module(
|
||||
&[import::SHA512_HALF, ONE_PAGE],
|
||||
&format!(
|
||||
"(call $sha512_half (i32.const 0) (i32.const {})
|
||||
(i32.const 0) (i32.const {HASH_LEN}))",
|
||||
PAGE + 1
|
||||
),
|
||||
);
|
||||
assert_eq!(status(&reading, &host), code(HostError::DataFieldTooLarge));
|
||||
|
||||
let writing = module(
|
||||
&[import::LDGR_INDEX, ONE_PAGE],
|
||||
&format!("(call $ldgr_index (i32.const 0) (i32.const {}))", PAGE + 1),
|
||||
);
|
||||
assert_eq!(status(&writing, &host), code(HostError::PointerOutOfBounds));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The reader with no result (`read_borrowed`, via `trace`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `trace` reads two regions and either one being bad refuses the call. The same
|
||||
/// rule as above, and the guest is told nothing: the refusal is the host not being
|
||||
/// called, and the run carries on to the constant that follows.
|
||||
#[test]
|
||||
fn both_of_traces_regions_are_checked_silently() {
|
||||
let host = FakeHost::new();
|
||||
let regions = [
|
||||
(
|
||||
format!("(i32.const {PAGE}) (i32.const 1)"),
|
||||
EMPTY_REGION.to_owned(),
|
||||
),
|
||||
(
|
||||
EMPTY_REGION.to_owned(),
|
||||
format!("(i32.const {PAGE}) (i32.const 1)"),
|
||||
),
|
||||
(
|
||||
EMPTY_REGION.to_owned(),
|
||||
format!("(i32.const 0) (i32.const {OVER_CAP})"),
|
||||
),
|
||||
(
|
||||
"(i32.const -1) (i32.const 1)".to_owned(),
|
||||
EMPTY_REGION.to_owned(),
|
||||
),
|
||||
];
|
||||
|
||||
for (msg, data) in regions {
|
||||
let wat = module(
|
||||
&[import::TRACE, ONE_PAGE],
|
||||
&traced(TraceDataType::AsHex, &msg, &data),
|
||||
);
|
||||
assert_eq!(status(&wat, &host), COMPLETED, "msg {msg} data {data}");
|
||||
assert!(
|
||||
host.traces().is_empty(),
|
||||
"msg {msg} data {data}: the host must not be called"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Both at once (`write_buffered`, via `sha512_half`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A call with an input and an output region decides everything about the input
|
||||
/// before anything about the output, so a bad input is reported however the output
|
||||
/// region is wrong — out of bounds, or a pointer that is not one at all.
|
||||
///
|
||||
/// The whole output region, params included, is judged after the host has answered.
|
||||
/// Hoisting any part of that above the call would put the output's verdict first for
|
||||
/// these cases, and there is no half of it that can be hoisted on a principle the
|
||||
/// other half shares.
|
||||
#[test]
|
||||
fn a_read_write_checks_its_input_before_its_output() {
|
||||
let host = FakeHost::new();
|
||||
let digest = |src: i64, src_len: i64, dst: i64| {
|
||||
module(
|
||||
&[import::SHA512_HALF, ONE_PAGE],
|
||||
&format!(
|
||||
"(call $sha512_half (i32.const {src}) (i32.const {src_len})
|
||||
(i32.const {dst}) (i32.const {HASH_LEN}))"
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
let over_cap = digest(0, OVER_CAP, 0);
|
||||
assert_eq!(status(&over_cap, &host), code(HostError::DataFieldTooLarge));
|
||||
|
||||
let out_of_bounds = digest(PAGE, 4, 0);
|
||||
assert_eq!(
|
||||
status(&out_of_bounds, &host),
|
||||
code(HostError::PointerOutOfBounds)
|
||||
);
|
||||
|
||||
// A bad input against each way the output can be wrong: the input's verdict is
|
||||
// the one reported, and the host is never asked for a value nobody can take.
|
||||
for dst in [PAGE, -1] {
|
||||
let both_bad = digest(0, OVER_CAP, dst);
|
||||
assert_eq!(
|
||||
status(&both_bad, &host),
|
||||
code(HostError::DataFieldTooLarge),
|
||||
"dst {dst}"
|
||||
);
|
||||
}
|
||||
assert!(host.digested.borrow().is_empty(), "the host is not reached");
|
||||
}
|
||||
|
||||
/// The output half of a read-write call obeys the same rules as a plain write.
|
||||
#[test]
|
||||
fn a_read_write_output_obeys_the_write_rules() {
|
||||
let host = FakeHost::new().answering_digest(Answer::filler(32));
|
||||
|
||||
let wat = module(
|
||||
&[import::SHA512_HALF, ONE_PAGE],
|
||||
"(call $sha512_half (i32.const 0) (i32.const 4) (i32.const 0) (i32.const 31))",
|
||||
);
|
||||
assert_eq!(status(&wat, &host), code(HostError::BufferTooSmall));
|
||||
|
||||
let wat = module(
|
||||
&[import::SHA512_HALF, ONE_PAGE],
|
||||
&format!(
|
||||
"(call $sha512_half (i32.const 0) (i32.const 4) (i32.const {PAGE}) (i32.const 32))"
|
||||
),
|
||||
);
|
||||
assert_eq!(status(&wat, &host), code(HostError::PointerOutOfBounds));
|
||||
}
|
||||
|
||||
/// A refused value reaches guest memory in no part, however much of it the host
|
||||
/// wrote. The host answers with 32 bytes it did write and a length it did not, so
|
||||
/// the refusal happens with the value sitting in the run's output buffer — and the
|
||||
/// guest's buffer has to come back untouched.
|
||||
///
|
||||
/// Stronger than the contract asks for: a guest must not read its buffer on a
|
||||
/// negative status. It holds because the buffer is copied to the guest only after
|
||||
/// the length, the bounds, the fit and the budget have all passed, so there is no
|
||||
/// window in which a refused value is in guest memory.
|
||||
#[test]
|
||||
fn a_refused_value_leaves_nothing_in_guest_memory() {
|
||||
const MARKER: u8 = 77;
|
||||
|
||||
// The two refusals a value can meet after the host has produced it: longer
|
||||
// than the field cap, and longer than the buffer the guest offered.
|
||||
let refusals = [
|
||||
(MAX_FIELD_BYTES + 1, HASH_LEN, HostError::DataFieldTooLarge),
|
||||
(HASH_LEN, HASH_LEN - 1, HostError::BufferTooSmall),
|
||||
];
|
||||
|
||||
for (claimed, cap, expected) in refusals {
|
||||
let host =
|
||||
FakeHost::new().answering_digest(Answer::writing_but_claiming([MARKER; 32], claimed));
|
||||
let call = format!(
|
||||
"(call $sha512_half (i32.const 0) (i32.const 4) (i32.const 64) (i32.const {cap}))"
|
||||
);
|
||||
|
||||
let refused = module(&[import::SHA512_HALF, ONE_PAGE], &call);
|
||||
assert_eq!(
|
||||
status(&refused, &host),
|
||||
code(expected),
|
||||
"claiming {claimed}"
|
||||
);
|
||||
|
||||
// The same call, reporting what is at the output region afterwards.
|
||||
let inspect = module(
|
||||
&[import::SHA512_HALF, ONE_PAGE],
|
||||
&format!("(drop {call}) (i32.load8_u (i32.const 64))"),
|
||||
);
|
||||
assert_eq!(
|
||||
status(&inspect, &host),
|
||||
0,
|
||||
"claiming {claimed}: the refused value must not have been written"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An input region may overlap the output region: the host is served the input as
|
||||
/// it stands and its answer lands afterwards, so the two cannot interfere. The
|
||||
/// marker is any byte distinct from the input's first (`a`), so `finish` returning
|
||||
/// it proves the write landed.
|
||||
#[test]
|
||||
fn an_input_may_overlap_the_output() {
|
||||
const MARKER: u8 = 99;
|
||||
|
||||
let host = FakeHost::new().answering_digest(Answer::bytes([MARKER; HASH_LEN]));
|
||||
|
||||
let wat = module(
|
||||
&[
|
||||
import::SHA512_HALF,
|
||||
ONE_PAGE,
|
||||
r#"(data (i32.const 0) "abcd")"#,
|
||||
],
|
||||
&format!(
|
||||
"(drop (call $sha512_half (i32.const 0) (i32.const 4)
|
||||
(i32.const 0) (i32.const {HASH_LEN})))
|
||||
(i32.load8_u (i32.const 0))"
|
||||
),
|
||||
);
|
||||
assert_eq!(
|
||||
status(&wat, &host),
|
||||
i32::from(MARKER),
|
||||
"the output overwrote the input"
|
||||
);
|
||||
assert_eq!(
|
||||
*host.digested.borrow(),
|
||||
vec![b"abcd".to_vec()],
|
||||
"the host saw the input as it was"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The memory export itself
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A host call with no memory to work in ends the run instead of answering the
|
||||
/// guest: there is no buffer for a status to describe, and nothing the guest could
|
||||
/// do about the answer — which is what puts this beside out-of-gas on the fatal
|
||||
/// channel. What the guest burned getting there is still charged.
|
||||
fn assert_no_memory(wat: &str, host: &FakeHost) {
|
||||
let failure = failure(wat, host);
|
||||
assert!(
|
||||
matches!(failure.error, RunError::NoMemory),
|
||||
"expected the run to end for want of a memory export, got: {failure}"
|
||||
);
|
||||
assert!(failure.fuel_used > 0, "{failure}");
|
||||
}
|
||||
|
||||
/// Every region is relative to the guest's exported memory, so a module without
|
||||
/// one cannot make a host call at all.
|
||||
#[test]
|
||||
fn a_module_that_exports_no_memory_cannot_call_the_host() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
let wat = module(
|
||||
&[import::LDGR_INDEX, "(memory 1)"],
|
||||
"(call $ldgr_index (i32.const 0) (i32.const 4))",
|
||||
);
|
||||
assert_no_memory(&wat, &host);
|
||||
}
|
||||
|
||||
/// Having no memory is answered before anything about a call's arguments, so a
|
||||
/// module without one ends the run even when its arguments would have earned a
|
||||
/// guest-visible code of their own (here an input over the field cap).
|
||||
///
|
||||
/// The order is deliberate: no memory is a fact about the instance, not about this
|
||||
/// call, and a region cannot be validated against a memory that is not there. It
|
||||
/// costs the guest nothing — every call such a module makes ends the run anyway.
|
||||
#[test]
|
||||
fn no_memory_is_answered_before_a_calls_arguments_are() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
let wat = module(
|
||||
&[import::SHA512_HALF, "(memory 1)"],
|
||||
&format!(
|
||||
"(call $sha512_half (i32.const 0) (i32.const {OVER_CAP})
|
||||
(i32.const 0) (i32.const {HASH_LEN}))"
|
||||
),
|
||||
);
|
||||
assert_no_memory(&wat, &host);
|
||||
}
|
||||
|
||||
/// The memory's export *name* is not part of the contract: the engine takes the
|
||||
/// module's memory whatever it is called. Nothing in the wasm spec attaches meaning
|
||||
/// to `"memory"` — it is a toolchain convention, so the kind decides.
|
||||
#[test]
|
||||
fn a_memory_exported_under_any_name_is_the_guests_memory() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
for name in ["mem", "linear", "the memory"] {
|
||||
let wat = module(
|
||||
&[
|
||||
import::LDGR_INDEX,
|
||||
&format!(r#"(memory (export "{name}") 1)"#),
|
||||
],
|
||||
"(drop (call $ldgr_index (i32.const 64) (i32.const 4)))
|
||||
(i32.load (i32.const 64))",
|
||||
);
|
||||
assert_eq!(
|
||||
status(&wat, &host),
|
||||
7,
|
||||
"the host wrote into the memory exported as '{name}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One memory exported under several names is one memory. The engine resolves the
|
||||
/// first export of kind memory, and with at most one memory per module every such
|
||||
/// export is that memory, so the order the exports are walked in cannot change the
|
||||
/// answer.
|
||||
#[test]
|
||||
fn one_memory_exported_under_several_names_is_still_that_memory() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
let wat = module(
|
||||
&[
|
||||
import::LDGR_INDEX,
|
||||
r#"(memory (export "memory") (export "mem") (export "linear") 1)"#,
|
||||
],
|
||||
"(drop (call $ldgr_index (i32.const 64) (i32.const 4)))
|
||||
(i32.load (i32.const 64))",
|
||||
);
|
||||
assert_eq!(status(&wat, &host), 7);
|
||||
}
|
||||
|
||||
/// The export has to *be* a memory: a global named `memory` is not one, and it
|
||||
/// neither serves as the guest's memory nor hides the memory the module really
|
||||
/// exports. The kind decides, so the conventional name carries no weight on
|
||||
/// either side.
|
||||
#[test]
|
||||
fn an_export_named_memory_that_is_not_a_memory_is_not_the_guests_memory() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
let call = "(call $ldgr_index (i32.const 0) (i32.const 4))";
|
||||
|
||||
let wrong_kind = module(
|
||||
&[
|
||||
import::LDGR_INDEX,
|
||||
"(memory 1)",
|
||||
r#"(global (export "memory") i32 (i32.const 0))"#,
|
||||
],
|
||||
call,
|
||||
);
|
||||
assert_no_memory(&wrong_kind, &host);
|
||||
|
||||
let shadowed = module(
|
||||
&[
|
||||
import::LDGR_INDEX,
|
||||
r#"(memory (export "mem") 1)"#,
|
||||
r#"(global (export "memory") i32 (i32.const 0))"#,
|
||||
],
|
||||
call,
|
||||
);
|
||||
assert_eq!(
|
||||
status(&shadowed, &host),
|
||||
4,
|
||||
"the real memory is found past the global that took its name"
|
||||
);
|
||||
}
|
||||
|
||||
/// Bounds follow the memory the module actually declared, not a fixed page.
|
||||
#[test]
|
||||
fn bounds_follow_the_declared_memory_size() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
let wat = module(
|
||||
&[import::LDGR_INDEX, r#"(memory (export "memory") 2)"#],
|
||||
&format!("(call $ldgr_index (i32.const {PAGE}) (i32.const 4))"),
|
||||
);
|
||||
assert_eq!(status(&wat, &host), 4, "the second page is in bounds");
|
||||
}
|
||||
737
crates/xrpl-wasm-vm/tests/preflight.rs
Normal file
737
crates/xrpl-wasm-vm/tests/preflight.rs
Normal file
@@ -0,0 +1,737 @@
|
||||
//! What screening refuses, and that it refuses nothing a run would have served.
|
||||
//!
|
||||
//! `check` reaches its verdict from the compiled module alone, so these tests take
|
||||
//! no host — except the ones that put the same module through `run` to compare the
|
||||
//! two.
|
||||
|
||||
mod support;
|
||||
|
||||
use support::{ENTRY, FakeHost, ONE_PAGE, PLENTY_OF_GAS, assemble, import, module};
|
||||
use xrpl_host_functions::{HostFunctionSpec, 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(_));
|
||||
}
|
||||
}
|
||||
1670
crates/xrpl-wasm-vm/tests/support/mod.rs
Normal file
1670
crates/xrpl-wasm-vm/tests/support/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
711
crates/xrpl-wasm-vm/tests/vm_limits.rs
Normal file
711
crates/xrpl-wasm-vm/tests/vm_limits.rs
Normal file
@@ -0,0 +1,711 @@
|
||||
//! What the engine refuses outright: modules it will not compile, will not
|
||||
//! instantiate, or cannot find an entry point in — plus the memory and table caps.
|
||||
//!
|
||||
//! These are the sandbox's outer wall. Everything here fails the run rather than
|
||||
//! returning a code to the guest, so each test reads the failure's message.
|
||||
|
||||
mod support;
|
||||
|
||||
use support::{
|
||||
FakeHost, ONE_PAGE, PLENTY_OF_GAS, failure, import, module, run, run_entry, run_with_gas,
|
||||
};
|
||||
use xrpl_wasm_vm::{MAX_MEMORY_PAGES, MAX_TABLE_ELEMENTS, RunError};
|
||||
|
||||
/// Assert which stage a run failed at, because the caller maps the stages to
|
||||
/// different outcomes. A stage is one `RunError` variant, so the expectation is a
|
||||
/// pattern; the failure comes back out for the tests that also read its message.
|
||||
macro_rules! assert_stage {
|
||||
($failure:expr, $stage:pat) => {{
|
||||
let failure = $failure;
|
||||
assert!(
|
||||
matches!(failure.error, $stage),
|
||||
concat!("expected a ", stringify!($stage), " failure, got: {}"),
|
||||
failure
|
||||
);
|
||||
failure
|
||||
}};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Linear memory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A module declaring more than the cap fails to instantiate — the limit applies
|
||||
/// to the initial memory, not only to growth.
|
||||
#[test]
|
||||
fn an_initial_memory_past_the_cap_is_refused() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
let wat = module(
|
||||
&[&format!(
|
||||
r#"(memory (export "memory") {})"#,
|
||||
MAX_MEMORY_PAGES + 1
|
||||
)],
|
||||
"(i32.const 0)",
|
||||
);
|
||||
assert_stage!(failure(&wat, &host), RunError::Instantiate(_));
|
||||
}
|
||||
|
||||
/// The cap itself is allowed.
|
||||
#[test]
|
||||
fn an_initial_memory_at_the_cap_is_allowed() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
let wat = module(
|
||||
&[&format!(r#"(memory (export "memory") {MAX_MEMORY_PAGES})"#)],
|
||||
"(i32.const 0)",
|
||||
);
|
||||
assert_eq!(run(&wat, &host).expect("should run").result, 0);
|
||||
}
|
||||
|
||||
/// Growth up to the cap succeeds; growth past it traps rather than answering -1 as
|
||||
/// `memory.grow` otherwise would, because the engine's limiter sets
|
||||
/// `trap_on_grow_failure(true)`.
|
||||
#[test]
|
||||
fn growth_stops_at_the_cap() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
let wat = module(
|
||||
&[ONE_PAGE],
|
||||
&format!("(memory.grow (i32.const {}))", MAX_MEMORY_PAGES - 1),
|
||||
);
|
||||
assert_eq!(
|
||||
run(&wat, &host).expect("should run").result,
|
||||
1,
|
||||
"growing to exactly the cap answers the previous size"
|
||||
);
|
||||
|
||||
let wat = module(
|
||||
&[ONE_PAGE],
|
||||
&format!("(memory.grow (i32.const {MAX_MEMORY_PAGES}))"),
|
||||
);
|
||||
assert_stage!(failure(&wat, &host), RunError::Trap(_));
|
||||
}
|
||||
|
||||
/// A module may declare a maximum above the cap: the cap is enforced on the initial
|
||||
/// memory and on growth, not on the memory type's declared bound.
|
||||
#[test]
|
||||
fn a_declared_maximum_past_the_cap_is_allowed_but_unreachable() {
|
||||
let host = FakeHost::new();
|
||||
let memory = format!(r#"(memory (export "memory") 1 {})"#, MAX_MEMORY_PAGES + 1);
|
||||
|
||||
let wat = module(&[&memory], "(i32.const 0)");
|
||||
assert_eq!(run(&wat, &host).expect("should run").result, 0);
|
||||
|
||||
let wat = module(
|
||||
&[&memory],
|
||||
&format!("(memory.grow (i32.const {MAX_MEMORY_PAGES}))"),
|
||||
);
|
||||
assert_stage!(failure(&wat, &host), RunError::Trap(_));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A table's whole cost is paid at instantiation: wasmi writes all 8 bytes of every
|
||||
/// element before the guest's first instruction, so a module declaring more than the
|
||||
/// cap must be refused there rather than charged for it.
|
||||
#[test]
|
||||
fn an_initial_table_past_the_cap_is_refused() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
let wat = module(
|
||||
&[&format!("(table {} funcref)", MAX_TABLE_ELEMENTS + 1)],
|
||||
"(i32.const 0)",
|
||||
);
|
||||
assert_stage!(failure(&wat, &host), RunError::Instantiate(_));
|
||||
}
|
||||
|
||||
/// The cap itself is allowed.
|
||||
#[test]
|
||||
fn an_initial_table_at_the_cap_is_allowed() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
let wat = module(
|
||||
&[&format!("(table {MAX_TABLE_ELEMENTS} funcref)")],
|
||||
"(i32.const 0)",
|
||||
);
|
||||
assert_eq!(run(&wat, &host).expect("should run").result, 0);
|
||||
}
|
||||
|
||||
/// The cap binds a table the module keeps to itself, which is the case that matters:
|
||||
/// a contract has no reason to export its table, so screening never sees the one a
|
||||
/// hostile module declares.
|
||||
#[test]
|
||||
fn the_table_cap_binds_an_unexported_table() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
let wat = module(
|
||||
&[&format!("(table {} funcref)", u32::from(u16::MAX) * 100)],
|
||||
"(i32.const 0)",
|
||||
);
|
||||
assert_stage!(failure(&wat, &host), RunError::Instantiate(_));
|
||||
}
|
||||
|
||||
/// A declared *maximum* past the cap is legal and simply unreachable, mirroring what
|
||||
/// linear memory allows. Nothing can reach it: `table.grow` is a reference-types
|
||||
/// instruction and the engine turns that feature off, so a table's declared minimum
|
||||
/// is also its final size.
|
||||
#[test]
|
||||
fn a_declared_table_maximum_past_the_cap_is_allowed_but_unreachable() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
let wat = module(
|
||||
&[&format!(
|
||||
"(table 1 {} funcref)",
|
||||
u64::try_from(MAX_TABLE_ELEMENTS).expect("fits") + 1
|
||||
)],
|
||||
"(i32.const 0)",
|
||||
);
|
||||
assert_eq!(run(&wat, &host).expect("should run").result, 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Engine configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One row per feature `build_wasm_engine` turns off: the smallest module that uses
|
||||
/// it, and the fragment of wasmi's refusal that names the feature. A row declaring
|
||||
/// its own memory omits [`ONE_PAGE`], or it is refused for having two memories
|
||||
/// instead.
|
||||
fn disabled_features() -> Vec<(&'static str, Vec<&'static str>, &'static str, &'static str)> {
|
||||
vec![
|
||||
(
|
||||
"wasm_multi_value",
|
||||
vec![
|
||||
ONE_PAGE,
|
||||
"(func $two (result i32 i32) (i32.const 1) (i32.const 2))",
|
||||
],
|
||||
"(call $two) (drop) (drop) (i32.const 0)",
|
||||
"multi-value",
|
||||
),
|
||||
(
|
||||
"wasm_sign_extension",
|
||||
vec![ONE_PAGE],
|
||||
"(i32.extend8_s (i32.const 1))",
|
||||
"sign extension",
|
||||
),
|
||||
(
|
||||
"wasm_bulk_memory",
|
||||
vec![ONE_PAGE],
|
||||
"(memory.fill (i32.const 0) (i32.const 0) (i32.const 1)) (i32.const 0)",
|
||||
"bulk memory",
|
||||
),
|
||||
(
|
||||
"wasm_reference_types",
|
||||
vec![ONE_PAGE, "(table 1 externref)"],
|
||||
"(i32.const 0)",
|
||||
"reference types",
|
||||
),
|
||||
// The proposal covers mutable globals crossing the module boundary; an
|
||||
// internal one is core wasm and stays allowed — see the test below.
|
||||
(
|
||||
"wasm_mutable_global",
|
||||
vec![ONE_PAGE, r#"(global (export "g") (mut i32) (i32.const 0))"#],
|
||||
"(i32.const 0)",
|
||||
"mutable global",
|
||||
),
|
||||
(
|
||||
"wasm_tail_call",
|
||||
vec![ONE_PAGE, "(func $f (result i32) (i32.const 0))"],
|
||||
"(return_call $f)",
|
||||
"tail call",
|
||||
),
|
||||
// Arithmetic in a constant initialiser. wasmi names the operator rather
|
||||
// than the proposal here.
|
||||
(
|
||||
"wasm_extended_const",
|
||||
vec![
|
||||
ONE_PAGE,
|
||||
"(global $g i32 (i32.add (i32.const 1) (i32.const 2)))",
|
||||
],
|
||||
"(global.get $g)",
|
||||
"non-constant operator",
|
||||
),
|
||||
(
|
||||
"wasm_multi_memory",
|
||||
vec![ONE_PAGE, "(memory 1)"],
|
||||
"(i32.const 0)",
|
||||
"multiple memories",
|
||||
),
|
||||
(
|
||||
"wasm_memory64",
|
||||
vec![r#"(memory (export "memory") i64 1)"#],
|
||||
"(i32.const 0)",
|
||||
"memory64",
|
||||
),
|
||||
(
|
||||
"wasm_custom_page_sizes",
|
||||
vec![r#"(memory (export "memory") 1 (pagesize 1))"#],
|
||||
"(i32.const 0)",
|
||||
"custom page sizes",
|
||||
),
|
||||
(
|
||||
"wasm_wide_arithmetic",
|
||||
vec![ONE_PAGE],
|
||||
"(drop (i64.add128 (i64.const 1) (i64.const 2) (i64.const 3) (i64.const 4)))
|
||||
(i32.const 0)",
|
||||
"wide arithmetic",
|
||||
),
|
||||
// Determinism across nodes is the reason floats are off.
|
||||
(
|
||||
"floats",
|
||||
vec![ONE_PAGE],
|
||||
"(drop (f64.add (f64.const 1) (f64.const 2))) (i32.const 0)",
|
||||
"floating-point",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Every feature the engine disables is refused, and refused for that reason.
|
||||
///
|
||||
/// `wasm_custom_page_sizes` and `wasm_wide_arithmetic` are off by default in wasmi
|
||||
/// 1.1 (`engine/config.rs:72,74`), so their rows guard against wasmi changing that
|
||||
/// default rather than against this engine's own config.
|
||||
#[test]
|
||||
fn every_disabled_feature_is_refused_by_name() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
for (knob, parts, body, expected) in disabled_features() {
|
||||
let wat = module(&parts, body);
|
||||
let failure = assert_stage!(failure(&wat, &host), RunError::Compile(_)).to_string();
|
||||
|
||||
assert!(
|
||||
failure.contains(expected),
|
||||
"{knob}: expected a refusal mentioning {expected:?}, got: {failure}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The three knobs [`every_disabled_feature_is_refused_by_name`] cannot cover. The
|
||||
/// engine is a process-wide `LazyLock`, so a test observes the one configuration
|
||||
/// `build_wasm_engine` makes: a knob masked by another, or with no caller-visible
|
||||
/// effect, has no distinguishing module.
|
||||
#[test]
|
||||
fn the_knobs_without_a_module_of_their_own() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
// `wasm_saturating_float_to_int(false)`: every saturating conversion takes a
|
||||
// float operand, so `floats(false)` refuses it first, as the message shows.
|
||||
let wat = module(&[ONE_PAGE], "(i32.trunc_sat_f32_s (f32.const 1))");
|
||||
let refusal = failure(&wat, &host).to_string();
|
||||
assert!(refusal.contains("floating-point"), "{refusal}");
|
||||
assert!(!refusal.contains("saturating"), "{refusal}");
|
||||
|
||||
// `ignore_custom_sections(true)`: governs whether wasmi retains custom
|
||||
// sections, not accept/reject, so this pins only that one is harmless.
|
||||
let wat = module(
|
||||
&[ONE_PAGE, r#"(@custom "note" "ignored")"#],
|
||||
"(i32.const 0)",
|
||||
);
|
||||
assert_eq!(run(&wat, &host).expect("should run").result, 0);
|
||||
|
||||
// `consume_fuel(true)`: with it off, `Store::set_fuel` fails and `run` returns
|
||||
// before instantiating, so every test in the suite fails.
|
||||
let wat = module(&[ONE_PAGE], "(i32.const 0)");
|
||||
assert!(run(&wat, &host).expect("should run").fuel_used > 0);
|
||||
}
|
||||
|
||||
/// A mutable global the module keeps to itself is core wasm, so the disabled
|
||||
/// proposal does not reach it: a guest can still have mutable state.
|
||||
#[test]
|
||||
fn an_internal_mutable_global_is_still_allowed() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
let wat = module(
|
||||
&[ONE_PAGE, "(global $g (mut i32) (i32.const 0))"],
|
||||
"(global.set $g (i32.const 7)) (global.get $g)",
|
||||
);
|
||||
assert_eq!(run(&wat, &host).expect("should run").result, 7);
|
||||
}
|
||||
|
||||
/// Bytes that are not a wasm module at all.
|
||||
#[test]
|
||||
fn garbage_does_not_compile() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
for bytes in [b"".as_slice(), b"not wasm", &[0x00, 0x61, 0x73, 0x6d]] {
|
||||
let failure = xrpl_wasm_vm::run(bytes, PLENTY_OF_GAS, &host, support::ENTRY)
|
||||
.expect_err("garbage must not compile");
|
||||
assert_stage!(failure, RunError::Compile(_));
|
||||
}
|
||||
}
|
||||
|
||||
/// The VM takes wasm binaries, and text is not one. wasmi's `wat` feature is on by
|
||||
/// default and would have `Module::new` assemble text too, so the crate builds
|
||||
/// wasmi without it; turning it back on would make this transaction blob valid.
|
||||
#[test]
|
||||
fn the_vm_refuses_a_text_format_module() {
|
||||
let host = FakeHost::new();
|
||||
let text = module(&[ONE_PAGE], "(i32.const 0)");
|
||||
|
||||
let failure = xrpl_wasm_vm::run(text.as_bytes(), PLENTY_OF_GAS, &host, support::ENTRY)
|
||||
.expect_err("text must not compile as a module");
|
||||
assert_stage!(failure, RunError::Compile(_));
|
||||
|
||||
// The same module, assembled first, runs: the text is sound and only the
|
||||
// format was refused.
|
||||
assert_eq!(run(&text, &host).expect("should run").result, 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Imports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A module may import fewer host functions than are registered, but not more:
|
||||
/// an import the linker does not define fails instantiation.
|
||||
#[test]
|
||||
fn an_unknown_import_fails_instantiation() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
let wat = module(
|
||||
&[
|
||||
r#"(import "host_lib" "no_such_function" (func $f (param i32) (result i32)))"#,
|
||||
ONE_PAGE,
|
||||
],
|
||||
"(call $f (i32.const 0))",
|
||||
);
|
||||
assert_stage!(failure(&wat, &host), RunError::Instantiate(_));
|
||||
}
|
||||
|
||||
/// Host functions are registered under one module name — `host_lib`, the name the
|
||||
/// guest SDK and this repo's fixtures import from — and a guest naming a different
|
||||
/// one does not link. `env` is in the list because that is what plain clang emits.
|
||||
#[test]
|
||||
fn the_import_module_name_must_match() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
for module_name in ["host", "env", ""] {
|
||||
let wat = module(
|
||||
&[
|
||||
&format!(
|
||||
r#"(import "{module_name}" "ldgr_index" (func $f (param i32 i32) (result i32)))"#
|
||||
),
|
||||
ONE_PAGE,
|
||||
],
|
||||
"(call $f (i32.const 0) (i32.const 4))",
|
||||
);
|
||||
assert_stage!(failure(&wat, &host), RunError::Instantiate(_));
|
||||
}
|
||||
}
|
||||
|
||||
/// An import spelled with the wrong signature does not link even under the right
|
||||
/// name, which is what makes the registered signatures load-bearing.
|
||||
#[test]
|
||||
fn an_import_with_the_wrong_signature_fails_instantiation() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
for signature in [
|
||||
"(param i32) (result i32)", // too few parameters
|
||||
"(param i32 i32 i32) (result i32)", // too many
|
||||
"(param i64 i64) (result i32)", // wrong parameter types
|
||||
"(param i32 i32) (result i64)", // wrong result type
|
||||
"(param i32 i32)", // no result
|
||||
] {
|
||||
let wat = module(
|
||||
&[
|
||||
&format!(r#"(import "host_lib" "ldgr_index" (func $f {signature}))"#),
|
||||
ONE_PAGE,
|
||||
],
|
||||
"(i32.const 0)",
|
||||
);
|
||||
assert_stage!(failure(&wat, &host), RunError::Instantiate(_));
|
||||
}
|
||||
}
|
||||
|
||||
/// A module that imports a host function it never calls still has to link.
|
||||
#[test]
|
||||
fn an_unused_import_is_still_linked() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
let wat = module(
|
||||
&[import::LDGR_INDEX, import::TRACE, ONE_PAGE],
|
||||
"(i32.const 0)",
|
||||
);
|
||||
assert_eq!(run(&wat, &host).expect("should run").result, 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The start section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The engine disallows start sections, so a module carrying one is rejected at
|
||||
/// compile and never runs. No guest code executes ahead of the entry point, whatever
|
||||
/// that code would have done — trap, loop, or call the host — so nothing is metered
|
||||
/// and no fuel is burned. Screening catches the same module up front
|
||||
/// (`preflight::a_start_section_is_refused_by_screening`); this pins that `run`
|
||||
/// refuses it the same way rather than instantiating it.
|
||||
#[test]
|
||||
fn a_start_section_module_is_rejected_at_compile() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
let wat = format!(
|
||||
r#"(module {ONE_PAGE}
|
||||
(func $init (unreachable))
|
||||
(start $init)
|
||||
(func (export "finish") (result i32) (i32.const 0)))"#
|
||||
);
|
||||
let failure = assert_stage!(
|
||||
run_with_gas(&wat, PLENTY_OF_GAS, &host)
|
||||
.expect_err("a module with a start section must not run"),
|
||||
RunError::Compile(_)
|
||||
);
|
||||
assert_eq!(
|
||||
failure.fuel_used, 0,
|
||||
"no guest code runs, so nothing is charged: {failure}"
|
||||
);
|
||||
}
|
||||
|
||||
/// What `RunError::Instantiate` is left to mean: a module the linker or the store
|
||||
/// would not accept, rather than one whose guest code failed. Its two shapes, so the
|
||||
/// variant is not left standing for nothing.
|
||||
#[test]
|
||||
fn instantiation_failure_is_a_module_the_engine_will_not_accept() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
// The linker defines no such import.
|
||||
let wat = module(
|
||||
&[
|
||||
r#"(import "host_lib" "no_such_function" (func $f (result i32)))"#,
|
||||
ONE_PAGE,
|
||||
],
|
||||
"(call $f)",
|
||||
);
|
||||
assert_stage!(failure(&wat, &host), RunError::Instantiate(_));
|
||||
|
||||
// The store's limiter will not grant the memory, and does not trap to say so.
|
||||
let wat = module(
|
||||
&[&format!("(memory {})", MAX_MEMORY_PAGES + 1)],
|
||||
"(i32.const 0)",
|
||||
);
|
||||
assert_stage!(failure(&wat, &host), RunError::Instantiate(_));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn a_missing_entry_point_fails() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
let wat = r#"(module (memory (export "memory") 1) (func (export "other") (result i32) (i32.const 0)))"#;
|
||||
let failure = assert_stage!(
|
||||
run_with_gas(wat, PLENTY_OF_GAS, &host)
|
||||
.expect_err("a module without the entry point must not run"),
|
||||
RunError::EntryPoint(_)
|
||||
);
|
||||
assert!(
|
||||
failure.to_string().contains("no entry point 'finish'"),
|
||||
"{failure}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The entry point is looked up by the name the caller asks for.
|
||||
#[test]
|
||||
fn the_entry_point_is_the_name_the_caller_gives() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
let wat = r#"(module (memory (export "memory") 1) (func (export "other") (result i32) (i32.const 9)))"#;
|
||||
let outcome = run_entry(wat, &host, "other").expect("should run");
|
||||
assert_eq!(outcome.result, 9);
|
||||
}
|
||||
|
||||
/// The entry point must take nothing and return an `i32`. A module that exports the
|
||||
/// name with another signature is told so, rather than being told the export is
|
||||
/// missing: wasmi answers both cases with one error, and "no entry point" would send
|
||||
/// a contract author looking for a function they already have.
|
||||
#[test]
|
||||
fn an_entry_point_of_the_wrong_type_fails() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
for signature in ["(result i64)", "(param i32) (result i32)", ""] {
|
||||
let body = if signature.contains("result i64") {
|
||||
"(i64.const 0)"
|
||||
} else if signature.is_empty() {
|
||||
"(nop)"
|
||||
} else {
|
||||
"(i32.const 0)"
|
||||
};
|
||||
let wat = format!(
|
||||
r#"(module (memory (export "memory") 1) (func (export "finish") {signature} {body}))"#
|
||||
);
|
||||
let failure = assert_stage!(
|
||||
run_with_gas(&wat, PLENTY_OF_GAS, &host)
|
||||
.expect_err("a wrongly-typed entry point must not run"),
|
||||
RunError::EntryPoint(_)
|
||||
)
|
||||
.to_string();
|
||||
assert!(
|
||||
failure.contains("entry point 'finish' has the wrong signature"),
|
||||
"{signature}: {failure}"
|
||||
);
|
||||
assert!(
|
||||
!failure.contains("no entry point"),
|
||||
"a present export must not be reported as absent — {signature}: {failure}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An export of the entry point's name that is not a function at all is a third
|
||||
/// case, and named as such: nothing is missing and no signature is wrong.
|
||||
#[test]
|
||||
fn an_entry_point_that_is_not_a_function_fails() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
let wat =
|
||||
r#"(module (memory (export "memory") 1) (global (export "finish") i32 (i32.const 0)))"#;
|
||||
let failure = assert_stage!(
|
||||
run_with_gas(wat, PLENTY_OF_GAS, &host).expect_err("a non-function export must not run"),
|
||||
RunError::EntryPoint(_)
|
||||
)
|
||||
.to_string();
|
||||
assert!(
|
||||
failure.contains("export 'finish' is not a function"),
|
||||
"{failure}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A guest that traps fails the run rather than returning a value.
|
||||
#[test]
|
||||
fn a_trapping_guest_fails_the_run() {
|
||||
let host = FakeHost::new();
|
||||
|
||||
let wat = module(&[ONE_PAGE], "(unreachable)");
|
||||
assert_stage!(failure(&wat, &host), RunError::Trap(_));
|
||||
|
||||
// An out-of-bounds guest access is a trap too, caught by the engine rather
|
||||
// than anything the host is asked about.
|
||||
let wat = module(&[ONE_PAGE], "(i32.load (i32.const 100000))");
|
||||
assert_stage!(failure(&wat, &host), RunError::Trap(_));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_memory64_module_is_rejected_at_compile() {
|
||||
let host = FakeHost::new();
|
||||
let wat = r#"(module
|
||||
(memory i64 1)
|
||||
(func (export "finish") (result i32) (i32.const 0)))"#;
|
||||
|
||||
let failure = assert_stage!(
|
||||
run_with_gas(wat, PLENTY_OF_GAS, &host)
|
||||
.expect_err("a module using 64-bit memory must not run"),
|
||||
RunError::Compile(_)
|
||||
);
|
||||
assert_eq!(
|
||||
failure.fuel_used, 0,
|
||||
"rejected before instantiation, so nothing is charged: {failure}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A function declaring more parameters than wasm allows (1000) is refused at compile, so a
|
||||
/// contract cannot smuggle an unbounded signature past screening.
|
||||
#[test]
|
||||
fn a_function_with_too_many_params_is_refused() {
|
||||
let host = FakeHost::new();
|
||||
let params = " i32".repeat(1001);
|
||||
let wat = format!(
|
||||
"(module {ONE_PAGE} (func (param{params}) (result i32) (i32.const 0)) \
|
||||
(func (export \"finish\") (result i32) (i32.const 0)))"
|
||||
);
|
||||
assert_stage!(failure(&wat, &host), RunError::Compile(_));
|
||||
}
|
||||
|
||||
/// A function declaring more locals than wasm allows (50 000) is refused at compile.
|
||||
#[test]
|
||||
fn a_function_with_too_many_locals_is_refused() {
|
||||
let host = FakeHost::new();
|
||||
let locals = format!("(local{})", " i32".repeat(50_001));
|
||||
let wat = module(&[ONE_PAGE], &format!("{locals} (i32.const 0)"));
|
||||
assert_stage!(failure(&wat, &host), RunError::Compile(_));
|
||||
}
|
||||
|
||||
/// Below the compile cap but past the engine's register frame, a locals-heavy function is
|
||||
/// refused when the frame is built rather than at compile — still refused, just later.
|
||||
#[test]
|
||||
fn a_function_past_the_register_frame_is_refused() {
|
||||
let host = FakeHost::new();
|
||||
let locals = format!("(local{})", " i32".repeat(40_000));
|
||||
let wat = module(&[ONE_PAGE], &format!("{locals} (i32.const 0)"));
|
||||
assert_stage!(failure(&wat, &host), RunError::Trap(_));
|
||||
}
|
||||
|
||||
/// Unbounded recursion is stopped by the engine's call-stack limit — it traps rather than
|
||||
/// running the host's native stack off the end (the portable dispatcher makes loops safe;
|
||||
/// this pins that guest *calls* are bounded too).
|
||||
#[test]
|
||||
fn unbounded_recursion_is_stopped_by_the_call_stack_limit() {
|
||||
let host = FakeHost::new();
|
||||
let wat = format!(
|
||||
"(module {ONE_PAGE} \
|
||||
(func $rec (param i32) (result i32) \
|
||||
(if (result i32) (i32.eqz (local.get 0)) (then (i32.const 0)) \
|
||||
(else (call $rec (i32.sub (local.get 0) (i32.const 1)))))) \
|
||||
(func (export \"finish\") (result i32) (call $rec (i32.const 1000000))))"
|
||||
);
|
||||
assert_stage!(failure(&wat, &host), RunError::Trap(_));
|
||||
}
|
||||
|
||||
/// A module with many functions currently compiles and runs: wasmi's only cap is its
|
||||
/// 1,000,000 hard limit, so the ticket's ~24k-function module — a CodeMap-growth DoS, since
|
||||
/// every validation appends to the engine's append-only CodeMap — is not refused here.
|
||||
/// Enforcing a tighter bound (a function-count / average-bytes-per-function limit) belongs in
|
||||
/// a future preflight pass that parses the module before the engine sees it. Ignored until
|
||||
/// then, so this documents the gap without asserting it is acceptable.
|
||||
#[test]
|
||||
#[ignore = "CodeMap-DoS unmitigated; a function-count limit is deferred to preflight parsing"]
|
||||
fn many_functions_currently_run_unbounded() {
|
||||
let host = FakeHost::new();
|
||||
let funcs: String = (0..24_000)
|
||||
.map(|i| format!("(func $f{i} (result i32) (i32.const {}))", i % 7))
|
||||
.collect();
|
||||
let wat =
|
||||
format!("(module {ONE_PAGE} {funcs} (func (export \"finish\") (result i32) (call $f0)))");
|
||||
assert!(
|
||||
run(&wat, &host).is_ok(),
|
||||
"a large-function module currently compiles and runs"
|
||||
);
|
||||
}
|
||||
|
||||
/// The trap *kinds* wasmi distinguishes all reach the caller identically — a guest trap
|
||||
/// charged as the contract's fault — so the `unreachable` representative pins the mapping.
|
||||
/// These pin the individual kinds too, guarding against a wasmi upgrade reclassifying any of
|
||||
/// them as something other than a trap.
|
||||
#[test]
|
||||
fn a_division_by_zero_traps() {
|
||||
let host = FakeHost::new();
|
||||
let wat = module(&[ONE_PAGE], "(i32.div_s (i32.const 1) (i32.const 0))");
|
||||
assert_stage!(failure(&wat, &host), RunError::Trap(_));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signed_integer_overflow_traps() {
|
||||
let host = FakeHost::new();
|
||||
let wat = module(
|
||||
&[ONE_PAGE],
|
||||
"(i32.div_s (i32.const 0x80000000) (i32.const -1))",
|
||||
);
|
||||
assert_stage!(failure(&wat, &host), RunError::Trap(_));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_indirect_call_to_a_null_table_entry_traps() {
|
||||
let host = FakeHost::new();
|
||||
let wat = format!(
|
||||
"(module {ONE_PAGE} (type $t (func (result i32))) (table 1 funcref) \
|
||||
(func (export \"finish\") (result i32) (call_indirect (type $t) (i32.const 0))))"
|
||||
);
|
||||
assert_stage!(failure(&wat, &host), RunError::Trap(_));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_indirect_call_with_a_mismatched_signature_traps() {
|
||||
let host = FakeHost::new();
|
||||
let wat = format!(
|
||||
"(module {ONE_PAGE} (type $void (func)) (type $i32 (func (result i32))) \
|
||||
(table 1 funcref) (elem (i32.const 0) $f) (func $f (type $void)) \
|
||||
(func (export \"finish\") (result i32) (call_indirect (type $i32) (i32.const 0))))"
|
||||
);
|
||||
assert_stage!(failure(&wat, &host), RunError::Trap(_));
|
||||
}
|
||||
28
docs/build/environment.md
vendored
28
docs/build/environment.md
vendored
@@ -1,5 +1,5 @@
|
||||
Our [build instructions][BUILD.md] assume you have a C++ development
|
||||
environment complete with Git, Python, Conan, CMake, and a C++ compiler.
|
||||
environment complete with Git, Python, Conan, CMake, Rust, and a C++ compiler.
|
||||
This document explains how to set one up.
|
||||
|
||||
[BUILD.md]: ../../BUILD.md
|
||||
@@ -36,19 +36,17 @@ compiler building. Treat support for anything outside the table as best-effort.
|
||||
|
||||
Besides a compiler, building `xrpld` requires:
|
||||
|
||||
| Tool | Minimum version |
|
||||
| ------------------------------------------- | --------------- |
|
||||
| [Git](https://git-scm.com/downloads) | any recent |
|
||||
| [Python](https://www.python.org/downloads/) | 3.11 |
|
||||
| [Conan](https://conan.io/downloads.html) | 2.17 |
|
||||
| [CMake](https://cmake.org/download/) | 3.16 |
|
||||
| Tool | Minimum version |
|
||||
| ------------------------------------------- | ------------------------ |
|
||||
| [Git](https://git-scm.com/downloads) | any recent |
|
||||
| [Python](https://www.python.org/downloads/) | 3.11 |
|
||||
| [Conan](https://conan.io/downloads.html) | 2.17 |
|
||||
| [CMake](https://cmake.org/download/) | 3.16 |
|
||||
| [Rust](https://rustup.rs) | 1.95 (see [Rust](#rust)) |
|
||||
|
||||
On Linux and macOS, the [Nix development shell](./nix.md) provides all of them
|
||||
(see below). On Windows they have to be installed manually.
|
||||
|
||||
Building with `-Drust=ON` additionally requires a Rust toolchain, see
|
||||
[Rust](#rust). A default build does not, so it is not in the table above.
|
||||
|
||||
Once they are in place, verify that everything is installed and runnable with:
|
||||
|
||||
```bash
|
||||
@@ -122,18 +120,14 @@ manually:
|
||||
"x64 Native Tools Command Prompt". CI configures CMake with the
|
||||
`Visual Studio 18 2026` generator.
|
||||
- [Git for Windows](https://git-scm.com/download/win)
|
||||
- Python, Conan, and CMake, at the versions listed in
|
||||
- Python, Conan, CMake, and Rust, at the versions listed in
|
||||
[Required tools](#required-tools).
|
||||
- a [Rust toolchain](https://rustup.rs) — only needed to build with
|
||||
`-Drust=ON`, see [Rust](#rust)
|
||||
|
||||
## Rust
|
||||
|
||||
The repository contains a Rust workspace in [`crates/`](../../crates), whose
|
||||
crates are exposed to C++ through [cxx](https://cxx.rs) bindings. It is **not**
|
||||
part of a default build: the CMake `rust` option is OFF by default, and with it
|
||||
off no Rust toolchain is needed. It is only required when configuring with
|
||||
`-Drust=ON` (which is what CI does), see [Options](../../BUILD.md#options).
|
||||
crates are exposed to C++ through [cxx](https://cxx.rs) bindings and compiled by
|
||||
the CMake build, so a Rust toolchain is required.
|
||||
|
||||
The toolchain (`cargo`, `rustc`) is pinned to the channel in
|
||||
[`rust-toolchain.toml`](../../rust-toolchain.toml) at the repository root. If
|
||||
|
||||
5
docs/build/nix.md
vendored
5
docs/build/nix.md
vendored
@@ -128,9 +128,8 @@ Coverage builds (`-Dcoverage=ON`) work in the `gcc` shell (and `gcc-plain` on Li
|
||||
each ships a `gcov` matching its compiler, since Nix's cc-wrapper does not expose one.
|
||||
The `clang` shells do not include `llvm-cov`, so use a `gcc` shell for coverage.
|
||||
|
||||
Builds of the Rust crates (`-Drust=ON`) also work out of the box: every shell
|
||||
provides the Rust toolchain pinned in
|
||||
[`rust-toolchain.toml`](../../rust-toolchain.toml) (see
|
||||
The Rust toolchain the build needs is included too: every shell provides the
|
||||
channel pinned in [`rust-toolchain.toml`](../../rust-toolchain.toml) (see
|
||||
[Rust](./environment.md#rust)), plus the `cargo-audit`, `cargo-llvm-cov` and
|
||||
`cargo-nextest` plugins.
|
||||
|
||||
|
||||
@@ -543,8 +543,21 @@ public:
|
||||
setround(RoundingMode inMode);
|
||||
|
||||
/**
|
||||
* Returns which mantissa scale is currently in use for normalization.
|
||||
* Convert an integer to a RoundingMode, validating that it is in range.
|
||||
*
|
||||
* Returns std::nullopt if the value does not correspond to a valid
|
||||
* RoundingMode.
|
||||
*/
|
||||
static std::optional<RoundingMode>
|
||||
checkedRoundingMode(int mode) noexcept
|
||||
{
|
||||
if (mode < static_cast<int>(RoundingMode::ToNearest) ||
|
||||
mode > static_cast<int>(RoundingMode::Upward))
|
||||
return std::nullopt;
|
||||
return static_cast<RoundingMode>(mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* If you think you need to call this outside of unit tests, no you don't.
|
||||
*/
|
||||
static MantissaRange::MantissaScale
|
||||
|
||||
@@ -94,6 +94,7 @@ struct Keys
|
||||
static constexpr auto kBbtOptions = "bbt_options";
|
||||
static constexpr auto kBgThreads = "bg_threads";
|
||||
static constexpr auto kBlockSize = "block_size";
|
||||
static constexpr auto kBytecodeSizeLimit = "bytecode_size_limit";
|
||||
static constexpr auto kCacheAge = "cache_age";
|
||||
static constexpr auto kCacheMb = "cache_mb";
|
||||
static constexpr auto kCacheSize = "cache_size";
|
||||
@@ -108,6 +109,8 @@ struct Keys
|
||||
static constexpr auto kFileSizeMult = "file_size_mult";
|
||||
static constexpr auto kFilterBits = "filter_bits";
|
||||
static constexpr auto kFilterFull = "filter_full";
|
||||
static constexpr auto kGasLimit = "gas_limit";
|
||||
static constexpr auto kGasPrice = "gas_price";
|
||||
static constexpr auto kHardSet = "hard_set";
|
||||
static constexpr auto kHighThreads = "high_threads";
|
||||
static constexpr auto kHoldTime = "hold_time";
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <xrpl/basics/TaggedCache.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/protocol/Fees.h>
|
||||
|
||||
#include <boost/asio.hpp>
|
||||
|
||||
@@ -246,6 +247,9 @@ public:
|
||||
virtual DatabaseCon&
|
||||
getWalletDB() = 0;
|
||||
|
||||
[[nodiscard]] virtual Fees
|
||||
getFees() const = 0;
|
||||
|
||||
// Temporary: Get the underlying Application for functions that haven't
|
||||
// been migrated yet. This should be removed once all code is migrated.
|
||||
virtual Application&
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include <xrpl/protocol/TxMeta.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
|
||||
@@ -68,6 +69,18 @@ public:
|
||||
deliver_ = amount;
|
||||
}
|
||||
|
||||
void
|
||||
setGasUsed(std::optional<std::uint32_t> const gasUsed)
|
||||
{
|
||||
gasUsed_ = gasUsed;
|
||||
}
|
||||
|
||||
void
|
||||
setVMReturnCode(std::int32_t const vmReturnCode)
|
||||
{
|
||||
vmReturnCode_ = vmReturnCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of modified entries
|
||||
*/
|
||||
@@ -88,6 +101,8 @@ public:
|
||||
|
||||
private:
|
||||
std::optional<STAmount> deliver_;
|
||||
std::optional<std::uint32_t> gasUsed_;
|
||||
std::optional<std::int32_t> vmReturnCode_;
|
||||
};
|
||||
|
||||
} // namespace xrpl
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#include <xrpl/protocol/XRPAmount.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
@@ -62,6 +63,8 @@ public:
|
||||
TER ter,
|
||||
std::optional<STAmount> const& deliver,
|
||||
std::optional<uint256 const> const& parentBatchId,
|
||||
std::optional<std::uint32_t> const& gasUsed,
|
||||
std::optional<std::int32_t> const& vmReturnCode,
|
||||
bool isDryRun,
|
||||
beast::Journal j);
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/protocol/UintTypes.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
template <ValidIssueType T>
|
||||
@@ -272,4 +274,15 @@ escrowUnlockApplyHelper<MPTIssue>(
|
||||
journal);
|
||||
}
|
||||
|
||||
template <class T>
|
||||
static int32_t
|
||||
calculateAdditionalReserve(T const& finishFunction)
|
||||
{
|
||||
if (!finishFunction)
|
||||
return 1;
|
||||
// First 500 bytes included in the normal reserve
|
||||
// Each additional 500 bytes requires an additional reserve
|
||||
return 1 + (finishFunction->size() / 500);
|
||||
}
|
||||
|
||||
} // namespace xrpl
|
||||
|
||||
@@ -10,6 +10,15 @@ namespace xrpl {
|
||||
// This was the reference fee units used in the old fee calculation.
|
||||
inline constexpr std::uint32_t kFeeUnitsDeprecated = 10;
|
||||
|
||||
// Number of micro-drops in one drop.
|
||||
constexpr std::uint32_t microDropsPerDrop{1'000'000};
|
||||
|
||||
/**
|
||||
* Maximum Feature Extension fee settings.
|
||||
*/
|
||||
inline constexpr std::uint32_t kMaxGasLimit{2'000'000};
|
||||
inline constexpr std::uint32_t kMaxBytecodeSizeLimit{200'000};
|
||||
|
||||
/**
|
||||
* Reflects the fee settings for a particular ledger.
|
||||
*
|
||||
@@ -33,6 +42,21 @@ struct Fees
|
||||
*/
|
||||
XRPAmount increment{0};
|
||||
|
||||
/**
|
||||
* @brief Gas limit for Feature Extensions (instructions).
|
||||
*/
|
||||
std::uint32_t gasLimit{0};
|
||||
|
||||
/**
|
||||
* @brief Bytecode size limit for Feature Extensions (bytes).
|
||||
*/
|
||||
std::uint32_t bytecodeSizeLimit{0};
|
||||
|
||||
/**
|
||||
* @brief Price of WASM gas (micro-drops).
|
||||
*/
|
||||
std::uint32_t gasPrice{0};
|
||||
|
||||
explicit Fees() = default;
|
||||
Fees(Fees const&) = default;
|
||||
Fees&
|
||||
|
||||
@@ -237,6 +237,12 @@ page(Keylet const& root, std::uint64_t const index = 0) noexcept
|
||||
Keylet
|
||||
escrow(AccountID const& src, SeqProxy const& seq) noexcept;
|
||||
|
||||
inline Keylet
|
||||
escrow(uint256 const& key) noexcept
|
||||
{
|
||||
return {ltESCROW, key};
|
||||
}
|
||||
|
||||
/**
|
||||
* A PaymentChannel
|
||||
*/
|
||||
|
||||
@@ -376,6 +376,16 @@ constexpr std::uint32_t kMaxInvestmentPeriod = std::chrono::seconds{std::chrono:
|
||||
*/
|
||||
constexpr std::uint8_t kMaxAssetCheckDepth = 5;
|
||||
|
||||
/**
|
||||
* Maximum length of a Data field in Escrow object that can be updated by WASM code.
|
||||
*/
|
||||
constexpr std::size_t kMaxWasmDataLength = 1 * 1024; // 1KB
|
||||
|
||||
/**
|
||||
* Maximum amount of data transfer across hostfunction<->wasm border.
|
||||
*/
|
||||
constexpr std::size_t kWasmTransferLimit = 1 << 20; // 1MB
|
||||
|
||||
/**
|
||||
* A ledger index.
|
||||
*/
|
||||
|
||||
@@ -95,6 +95,12 @@ public:
|
||||
|
||||
if (obj.isFieldPresent(sfParentBatchID))
|
||||
parentBatchID_ = obj.getFieldH256(sfParentBatchID);
|
||||
|
||||
if (obj.isFieldPresent(sfGasUsed))
|
||||
gasUsed_ = obj.getFieldU32(sfGasUsed);
|
||||
|
||||
if (obj.isFieldPresent(sfVMReturnCode))
|
||||
vmReturnCode_ = obj.getFieldI32(sfVMReturnCode);
|
||||
}
|
||||
|
||||
[[nodiscard]] std::optional<STAmount> const&
|
||||
@@ -115,6 +121,30 @@ public:
|
||||
parentBatchID_ = id;
|
||||
}
|
||||
|
||||
void
|
||||
setGasUsed(std::optional<std::uint32_t> const gasUsed)
|
||||
{
|
||||
gasUsed_ = gasUsed;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::optional<std::uint32_t> const&
|
||||
getGasUsed() const
|
||||
{
|
||||
return gasUsed_;
|
||||
}
|
||||
|
||||
void
|
||||
setVMReturnCode(std::optional<std::int32_t> const vmReturnCode)
|
||||
{
|
||||
vmReturnCode_ = vmReturnCode;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::optional<std::int32_t> const&
|
||||
getVMReturnCode() const
|
||||
{
|
||||
return vmReturnCode_;
|
||||
}
|
||||
|
||||
private:
|
||||
uint256 transactionID_;
|
||||
std::uint32_t ledgerSeq_;
|
||||
@@ -123,6 +153,8 @@ private:
|
||||
|
||||
std::optional<STAmount> deliveredAmount_;
|
||||
std::optional<uint256> parentBatchID_;
|
||||
std::optional<std::uint32_t> gasUsed_;
|
||||
std::optional<std::int32_t> vmReturnCode_;
|
||||
|
||||
STArray nodes_;
|
||||
};
|
||||
|
||||
@@ -252,6 +252,9 @@ JSS(expected_date); // out: any (warnings)
|
||||
JSS(expected_date_UTC); // out: any (warnings)
|
||||
JSS(expected_ledger_size); // out: TxQ
|
||||
JSS(expiration); // out: AccountOffers, AccountChannels, ValidatorList, amm_info
|
||||
JSS(gas_limit); // out: NetworkOPs
|
||||
JSS(bytecode_size_limit); // out: NetworkOPs
|
||||
JSS(gas_price); // out: NetworkOPs
|
||||
JSS(fail_hard); // in: Sign, Submit
|
||||
JSS(failed); // out: InboundLedger
|
||||
JSS(feature); // in: Feature
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#include <xrpl/protocol/XRPAmount.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
|
||||
@@ -94,6 +95,24 @@ public:
|
||||
view_->deliver(amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the gas used in the metadata
|
||||
*/
|
||||
void
|
||||
setGasUsed(std::uint32_t const gasUsed)
|
||||
{
|
||||
gasUsed_ = gasUsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the gas used in the metadata
|
||||
*/
|
||||
void
|
||||
setVMReturnCode(std::int32_t const vmReturnCode)
|
||||
{
|
||||
vmReturnCode_ = vmReturnCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard changes and start fresh.
|
||||
*/
|
||||
@@ -145,6 +164,8 @@ private:
|
||||
|
||||
// The ID of the batch transaction we are executing under, if set.
|
||||
std::optional<uint256 const> parentBatchId_;
|
||||
std::optional<std::uint32_t> gasUsed_;
|
||||
std::optional<std::int32_t> vmReturnCode_;
|
||||
};
|
||||
|
||||
} // namespace xrpl
|
||||
|
||||
@@ -20,15 +20,21 @@ public:
|
||||
{
|
||||
}
|
||||
|
||||
static TxConsequences
|
||||
makeTxConsequences(PreflightContext const& ctx);
|
||||
|
||||
static bool
|
||||
checkExtraFeatures(PreflightContext const& ctx);
|
||||
|
||||
static TxConsequences
|
||||
makeTxConsequences(PreflightContext const& ctx);
|
||||
|
||||
static XRPAmount
|
||||
calculateBaseFee(ReadView const& view, STTx const& tx);
|
||||
|
||||
static NotTEC
|
||||
preflight(PreflightContext const& ctx);
|
||||
|
||||
static NotTEC
|
||||
preflightSigValidated(PreflightContext const& ctx);
|
||||
|
||||
static TER
|
||||
preclaim(PreclaimContext const& ctx);
|
||||
|
||||
|
||||
430
include/xrpl/tx/wasm/HostContext.h
Normal file
430
include/xrpl/tx/wasm/HostContext.h
Normal file
@@ -0,0 +1,430 @@
|
||||
#pragma once
|
||||
|
||||
#include <rust/cxx.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace xrpl {
|
||||
// `xrpl::HostFunctions` is forward-declared rather than included: this header is
|
||||
// `include!()`d by the cxxbridge-generated translation unit, whose target gets only the
|
||||
// project's `include/` directory - not the Boost paths that HostFunc.h -> Slice.h ->
|
||||
// strHex.h transitively need. A reference member and declarations alone do not require a
|
||||
// complete type; HostContext.cpp, compiled into libxrpl, includes the real header.
|
||||
class HostFunctions;
|
||||
|
||||
// Defined by the cxx bridge, which emits it into `xrpl_wasm_vm_ffi_cxxbridge/lib.h` from the
|
||||
// declaration in `crates/xrpl-wasm-vm-ffi` - so the data types and their wire values are
|
||||
// written once, in Rust, rather than kept in step with a copy here.
|
||||
//
|
||||
// Forward-declared for the reason `HostFunctions` above is: that generated header includes
|
||||
// this one, so naming its definition here would be circular. A scoped enum with a fixed
|
||||
// underlying type needs no definition to appear in a signature; `HostContext.cpp` includes
|
||||
// the generated header for the `switch`.
|
||||
enum class TraceDataType : std::int32_t;
|
||||
|
||||
// The host handed to the Rust wasm engine: one method per entry in the wasm host ABI,
|
||||
// each forwarding to `xrpl::HostFunctions` - the single source of truth for ledger
|
||||
// access - and lowering its typed `std::expected` result onto the ABI's wire form.
|
||||
//
|
||||
// Every method is `noexcept`, and every body catches everything: a C++ exception
|
||||
// unwinding into the Rust frames that called it would be undefined behaviour, so a caught
|
||||
// one leaves here as `HostFunctionError::InternalFatal`, which the engine reads as a fatal
|
||||
// error and reports as `tecINTERNAL`.
|
||||
//
|
||||
// Not an owner: it borrows the `HostFunctions` it is built over for the length of one run.
|
||||
class HostContext
|
||||
{
|
||||
// Non-const so a host function that mutates (`cacheLedgerObj`, `updateData`) can be
|
||||
// reached from the `const` methods below: constness of the reference is not
|
||||
// constness of the referent.
|
||||
HostFunctions& hostFunctions_;
|
||||
|
||||
public:
|
||||
HostContext(HostFunctions& hostFunctions);
|
||||
|
||||
// A byte-producing call is handed `out` - a slice aliasing either guest linear
|
||||
// memory or the engine's output buffer - writes the value only if the whole of it
|
||||
// fits, and returns the value's *true* length, which may exceed `out`. That is how a
|
||||
// guest learns the size to ask for, and it is why these methods never need to know
|
||||
// the guest's capacity: the engine owns the buffer-fit, field-cap and transfer-budget
|
||||
// rules and derives all three from the length returned here.
|
||||
//
|
||||
// A negative return is a `HostFunctionError` code.
|
||||
[[nodiscard]] std::int32_t
|
||||
getLedgerSqn(rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getParentLedgerTime(rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getParentLedgerHash(rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getBaseFee(rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The amendment is either a 32-byte id or a name; a 32-byte input is tried as an
|
||||
// id first and falls back to a name lookup. Answers 1 or 0, or a negative
|
||||
// `HostFunctionError` code.
|
||||
[[nodiscard]] std::int32_t
|
||||
isAmendmentEnabled(rust::Slice<std::uint8_t const> amendment) const noexcept;
|
||||
|
||||
// The object id must be a 32-byte uint256, else `InvalidParams`. `cacheIdx` selects
|
||||
// the slot (0 = pick a free one). Answers the slot used, or a negative
|
||||
// `HostFunctionError` code.
|
||||
[[nodiscard]] std::int32_t
|
||||
cacheLedgerObj(rust::Slice<std::uint8_t const> objId, std::int32_t cacheIdx) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getTxField(std::int32_t field, rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getCurrentLedgerObjField(std::int32_t field, rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getLedgerObjField(std::int32_t cacheIdx, std::int32_t field, rust::Slice<std::uint8_t> out)
|
||||
const noexcept;
|
||||
|
||||
// The locator is a path of little-endian i32 steps, so its byte length must be a
|
||||
// non-zero multiple of 4, else `LocatorMalformed`.
|
||||
[[nodiscard]] std::int32_t
|
||||
getTxNestedField(rust::Slice<std::uint8_t const> locator, rust::Slice<std::uint8_t> out)
|
||||
const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getCurrentLedgerObjNestedField(
|
||||
rust::Slice<std::uint8_t const> locator,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getLedgerObjNestedField(
|
||||
std::int32_t cacheIdx,
|
||||
rust::Slice<std::uint8_t const> locator,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// Answers the array's element count directly, or a negative `HostFunctionError`
|
||||
// code (`NoArray` if the field is not an array).
|
||||
[[nodiscard]] std::int32_t
|
||||
getTxArrayLen(std::int32_t field) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getCurrentLedgerObjArrayLen(std::int32_t field) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getLedgerObjArrayLen(std::int32_t cacheIdx, std::int32_t field) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getTxNestedArrayLen(rust::Slice<std::uint8_t const> locator) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getCurrentLedgerObjNestedArrayLen(rust::Slice<std::uint8_t const> locator) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
getLedgerObjNestedArrayLen(std::int32_t cacheIdx, rust::Slice<std::uint8_t const> locator)
|
||||
const noexcept;
|
||||
|
||||
// Answers 1/0 for a valid/invalid signature, or a negative `HostFunctionError`.
|
||||
[[nodiscard]] std::int32_t
|
||||
checkSignature(
|
||||
rust::Slice<std::uint8_t const> message,
|
||||
rust::Slice<std::uint8_t const> signature,
|
||||
rust::Slice<std::uint8_t const> pubkey) const noexcept;
|
||||
|
||||
// The account id must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
accountKeylet(rust::Slice<std::uint8_t const> account, rust::Slice<std::uint8_t> out)
|
||||
const noexcept;
|
||||
|
||||
// Each asset is decoded by length (24 = MPT, 20 = XRP, 40 = issue), else
|
||||
// `InvalidParams`. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
ammKeylet(
|
||||
rust::Slice<std::uint8_t const> asset1,
|
||||
rust::Slice<std::uint8_t const> asset2,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The account id must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
checkKeylet(
|
||||
rust::Slice<std::uint8_t const> account,
|
||||
std::uint32_t seq,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// Subject and issuer must each be 20 bytes, else `InvalidParams`. Writes the
|
||||
// 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
credentialKeylet(
|
||||
rust::Slice<std::uint8_t const> subject,
|
||||
rust::Slice<std::uint8_t const> issuer,
|
||||
rust::Slice<std::uint8_t const> credentialType,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// Both accounts must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
delegateKeylet(
|
||||
rust::Slice<std::uint8_t const> account,
|
||||
rust::Slice<std::uint8_t const> authorize,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// Both accounts must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
depositPreauthKeylet(
|
||||
rust::Slice<std::uint8_t const> account,
|
||||
rust::Slice<std::uint8_t const> authorize,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The account id must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
didKeylet(rust::Slice<std::uint8_t const> account, rust::Slice<std::uint8_t> out)
|
||||
const noexcept;
|
||||
|
||||
// The account id must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
escrowKeylet(
|
||||
rust::Slice<std::uint8_t const> account,
|
||||
std::uint32_t seq,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// Both accounts and the currency must each be 20 bytes, else `InvalidParams`.
|
||||
// Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
trustLineKeylet(
|
||||
rust::Slice<std::uint8_t const> account1,
|
||||
rust::Slice<std::uint8_t const> account2,
|
||||
rust::Slice<std::uint8_t const> currency,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The issuer id must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
mptokenIssuanceKeylet(
|
||||
rust::Slice<std::uint8_t const> issuer,
|
||||
std::uint32_t seq,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The MPT id must be 24 bytes and the holder 20, else `InvalidParams`. Writes the
|
||||
// 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
mptokenKeylet(
|
||||
rust::Slice<std::uint8_t const> mptid,
|
||||
rust::Slice<std::uint8_t const> holder,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The account id must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
nftokenOfferKeylet(
|
||||
rust::Slice<std::uint8_t const> account,
|
||||
std::uint32_t seq,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The account id must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
offerKeylet(
|
||||
rust::Slice<std::uint8_t const> account,
|
||||
std::uint32_t seq,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The account id must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
oracleKeylet(
|
||||
rust::Slice<std::uint8_t const> account,
|
||||
std::uint32_t docId,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// Both account ids must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
paychannelKeylet(
|
||||
rust::Slice<std::uint8_t const> account,
|
||||
rust::Slice<std::uint8_t const> destination,
|
||||
std::uint32_t seq,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The account id must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
permissionedDomainKeylet(
|
||||
rust::Slice<std::uint8_t const> account,
|
||||
std::uint32_t seq,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The account id must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
signerListKeylet(rust::Slice<std::uint8_t const> account, rust::Slice<std::uint8_t> out)
|
||||
const noexcept;
|
||||
|
||||
// The account id must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
ticketKeylet(
|
||||
rust::Slice<std::uint8_t const> account,
|
||||
std::uint32_t seq,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The account id must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
vaultKeylet(
|
||||
rust::Slice<std::uint8_t const> account,
|
||||
std::uint32_t seq,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// Both accounts must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
sponsorshipKeylet(
|
||||
rust::Slice<std::uint8_t const> sponsor,
|
||||
rust::Slice<std::uint8_t const> sponsee,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The account id must be 20 bytes, else `InvalidParams`. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
loanBrokerKeylet(
|
||||
rust::Slice<std::uint8_t const> owner,
|
||||
std::uint32_t seq,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The loan broker id must be 32 bytes, else `InvalidParams`. Writes the 32-byte keylet.
|
||||
[[nodiscard]] std::int32_t
|
||||
loanKeylet(
|
||||
rust::Slice<std::uint8_t const> loanBrokerID,
|
||||
std::uint32_t loanSeq,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
sha512Half(rust::Slice<std::uint8_t const> data, rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// Renders `data` as `dataType` says, and hands the text to `HostFunctions::trace`, which
|
||||
// is what puts it in this node's log.
|
||||
//
|
||||
// The one call that answers nothing: the guest's wasm function has no result, and this
|
||||
// node's own log is the only thing a trace touches, so a buffer that does not hold what
|
||||
// it claims is logged here and dropped rather than reported to a contract.
|
||||
void
|
||||
trace(rust::Str msg, rust::Slice<std::uint8_t const> data, TraceDataType dataType)
|
||||
const noexcept;
|
||||
|
||||
// Stores `data` as the current object's data field and returns the number of bytes
|
||||
// stored, or a negative `HostFunctionError` code.
|
||||
[[nodiscard]] std::int32_t
|
||||
updateData(rust::Slice<std::uint8_t const> data) const noexcept;
|
||||
|
||||
// The account id must be 20 bytes and the nft id 32 bytes, else `InvalidParams`.
|
||||
// Writes the token's URI bytes.
|
||||
[[nodiscard]] std::int32_t
|
||||
getNFT(
|
||||
rust::Slice<std::uint8_t const> account,
|
||||
rust::Slice<std::uint8_t const> nftId,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The nft id must be 32 bytes, else `InvalidParams`. Writes the 20-byte issuer
|
||||
// account encoded in the id.
|
||||
[[nodiscard]] std::int32_t
|
||||
getNFTIssuer(rust::Slice<std::uint8_t const> nftId, rust::Slice<std::uint8_t> out)
|
||||
const noexcept;
|
||||
|
||||
// The nft id must be 32 bytes, else `InvalidParams`. Writes the taxon as its four
|
||||
// little-endian bytes.
|
||||
[[nodiscard]] std::int32_t
|
||||
getNFTTaxon(rust::Slice<std::uint8_t const> nftId, rust::Slice<std::uint8_t> out)
|
||||
const noexcept;
|
||||
|
||||
// The nft id must be 32 bytes, else `InvalidParams`. Returns the flags, or a
|
||||
// negative `HostFunctionError` code.
|
||||
[[nodiscard]] std::int32_t
|
||||
getNFTFlags(rust::Slice<std::uint8_t const> nftId) const noexcept;
|
||||
|
||||
// The nft id must be 32 bytes, else `InvalidParams`. Returns the transfer fee, or a
|
||||
// negative `HostFunctionError` code.
|
||||
[[nodiscard]] std::int32_t
|
||||
getNFTTransferFee(rust::Slice<std::uint8_t const> nftId) const noexcept;
|
||||
|
||||
// The nft id must be 32 bytes, else `InvalidParams`. Writes the sequence number as
|
||||
// its four little-endian bytes.
|
||||
[[nodiscard]] std::int32_t
|
||||
getNFTSequence(rust::Slice<std::uint8_t const> nftId, rust::Slice<std::uint8_t> out)
|
||||
const noexcept;
|
||||
|
||||
// Float / number arithmetic. A float is an XRPL `Number` in serialized form;
|
||||
// `mode` is a rounding mode. Each writes the result float bytes unless noted.
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
floatFromInt(std::int64_t x, std::int32_t mode, rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// The integer region must be eight bytes, else `InvalidParams`.
|
||||
[[nodiscard]] std::int32_t
|
||||
floatFromUint(
|
||||
rust::Slice<std::uint8_t const> x,
|
||||
std::int32_t mode,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// `amount` must be a serialized `STAmount`, else `InvalidParams`.
|
||||
[[nodiscard]] std::int32_t
|
||||
floatFromSTAmount(
|
||||
rust::Slice<std::uint8_t const> amount,
|
||||
std::int32_t mode,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// `number` must be a serialized `STNumber`, else `InvalidParams`.
|
||||
[[nodiscard]] std::int32_t
|
||||
floatFromSTNumber(
|
||||
rust::Slice<std::uint8_t const> number,
|
||||
std::int32_t mode,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// Rounds the float to an integer, written as its eight little-endian bytes.
|
||||
[[nodiscard]] std::int32_t
|
||||
floatToInt(rust::Slice<std::uint8_t const> x, std::int32_t mode, rust::Slice<std::uint8_t> out)
|
||||
const noexcept;
|
||||
|
||||
// Writes the mantissa (eight little-endian bytes) and the exponent (four little-
|
||||
// endian bytes) to two output regions; returns their total size.
|
||||
[[nodiscard]] std::int32_t
|
||||
floatToMantExp(
|
||||
rust::Slice<std::uint8_t const> x,
|
||||
rust::Slice<std::uint8_t> mantissaOut,
|
||||
rust::Slice<std::uint8_t> exponentOut) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
floatFromMantExp(
|
||||
std::int64_t mantissa,
|
||||
std::int32_t exponent,
|
||||
std::int32_t mode,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
// Returns a negative, zero, or positive scalar as `x` is less than, equal to, or
|
||||
// greater than `y`, or a negative `HostFunctionError` code on failure.
|
||||
[[nodiscard]] std::int32_t
|
||||
floatCompare(rust::Slice<std::uint8_t const> x, rust::Slice<std::uint8_t const> y)
|
||||
const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
floatAdd(
|
||||
rust::Slice<std::uint8_t const> x,
|
||||
rust::Slice<std::uint8_t const> y,
|
||||
std::int32_t mode,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
floatSubtract(
|
||||
rust::Slice<std::uint8_t const> x,
|
||||
rust::Slice<std::uint8_t const> y,
|
||||
std::int32_t mode,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
floatMultiply(
|
||||
rust::Slice<std::uint8_t const> x,
|
||||
rust::Slice<std::uint8_t const> y,
|
||||
std::int32_t mode,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
floatDivide(
|
||||
rust::Slice<std::uint8_t const> x,
|
||||
rust::Slice<std::uint8_t const> y,
|
||||
std::int32_t mode,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
floatPower(
|
||||
rust::Slice<std::uint8_t const> x,
|
||||
std::int32_t n,
|
||||
std::int32_t mode,
|
||||
rust::Slice<std::uint8_t> out) const noexcept;
|
||||
};
|
||||
|
||||
} // namespace xrpl
|
||||
482
include/xrpl/tx/wasm/HostFunc.h
Normal file
482
include/xrpl/tx/wasm/HostFunc.h
Normal file
@@ -0,0 +1,482 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/basics/Slice.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/UintTypes.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
namespace wasm_float {
|
||||
|
||||
std::string
|
||||
floatToString(Slice const& data);
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatFromIntImpl(int64_t x, int32_t mode);
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatFromUintImpl(uint64_t x, int32_t mode);
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatFromSTAmountImpl(STAmount const& x, int32_t mode);
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatFromSTNumberImpl(STNumber const& x, int32_t mode);
|
||||
|
||||
std::expected<int64_t, HostFunctionError>
|
||||
floatToIntImpl(Slice const& x, int32_t mode);
|
||||
|
||||
std::expected<FloatPair, HostFunctionError>
|
||||
floatToMantExpImpl(Slice const& x);
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatFromMantExpImpl(int64_t mantissa, int32_t exponent, int32_t mode);
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
floatCompareImpl(Slice const& x, Slice const& y);
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatAddImpl(Slice const& x, Slice const& y, int32_t mode);
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatSubtractImpl(Slice const& x, Slice const& y, int32_t mode);
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatMultiplyImpl(Slice const& x, Slice const& y, int32_t mode);
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatDivideImpl(Slice const& x, Slice const& y, int32_t mode);
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatPowerImpl(Slice const& x, int32_t n, int32_t mode);
|
||||
|
||||
} // namespace wasm_float
|
||||
|
||||
// Intended to work only through wasm runtime. Don't call them directly, except with unit tests
|
||||
class HostFunctions
|
||||
{
|
||||
protected:
|
||||
beast::Journal j_;
|
||||
|
||||
public:
|
||||
HostFunctions(beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) : j_(j)
|
||||
{
|
||||
}
|
||||
|
||||
[[nodiscard]] beast::Journal
|
||||
getJournal() const
|
||||
{
|
||||
return j_;
|
||||
}
|
||||
|
||||
// LCOV_EXCL_START
|
||||
|
||||
[[nodiscard]] virtual bool
|
||||
checkSelf() const
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<std::uint32_t, HostFunctionError>
|
||||
getLedgerSqn() const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<std::uint32_t, HostFunctionError>
|
||||
getParentLedgerTime() const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Hash, HostFunctionError>
|
||||
getParentLedgerHash() const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<uint32_t, HostFunctionError>
|
||||
getBaseFee() const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<int32_t, HostFunctionError>
|
||||
isAmendmentEnabled(uint256 const& amendmentId) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<int32_t, HostFunctionError>
|
||||
isAmendmentEnabled(std::string_view const& amendmentName) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
virtual std::expected<int32_t, HostFunctionError>
|
||||
cacheLedgerObj(uint256 const& objId, int32_t cacheIdx)
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
getTxField(SField const& fname) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
getCurrentLedgerObjField(SField const& fname) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
getLedgerObjField(int32_t cacheIdx, SField const& fname) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
getTxNestedField(FieldLocator const& locator) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
getCurrentLedgerObjNestedField(FieldLocator const& locator) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
getLedgerObjNestedField(int32_t cacheIdx, FieldLocator const& locator) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<int32_t, HostFunctionError>
|
||||
getTxArrayLen(SField const& fname) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<int32_t, HostFunctionError>
|
||||
getCurrentLedgerObjArrayLen(SField const& fname) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<int32_t, HostFunctionError>
|
||||
getLedgerObjArrayLen(int32_t cacheIdx, SField const& fname) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<int32_t, HostFunctionError>
|
||||
getTxNestedArrayLen(FieldLocator const& locator) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<int32_t, HostFunctionError>
|
||||
getCurrentLedgerObjNestedArrayLen(FieldLocator const& locator) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<int32_t, HostFunctionError>
|
||||
getLedgerObjNestedArrayLen(int32_t cacheIdx, FieldLocator const& locator) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
virtual std::expected<int32_t, HostFunctionError>
|
||||
updateData(Slice const& data)
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<int32_t, HostFunctionError>
|
||||
checkSignature(Slice const& message, Slice const& signature, Slice const& pubkey) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Hash, HostFunctionError>
|
||||
computeSha512HalfHash(Slice const& data) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
accountKeylet(AccountID const& account) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
ammKeylet(Asset const& issue1, Asset const& issue2) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
checkKeylet(AccountID const& account, std::uint32_t seq) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
credentialKeylet(AccountID const& subject, AccountID const& issuer, Slice const& credentialType)
|
||||
const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
didKeylet(AccountID const& account) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
delegateKeylet(AccountID const& account, AccountID const& authorize) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
depositPreauthKeylet(AccountID const& account, AccountID const& authorize) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
escrowKeylet(AccountID const& account, std::uint32_t seq) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
trustLineKeylet(AccountID const& account1, AccountID const& account2, Currency const& currency)
|
||||
const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
mptokenIssuanceKeylet(AccountID const& issuer, std::uint32_t seq) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
mptokenKeylet(MPTID const& mptid, AccountID const& holder) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
nftokenOfferKeylet(AccountID const& account, std::uint32_t seq) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
offerKeylet(AccountID const& account, std::uint32_t seq) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
oracleKeylet(AccountID const& account, std::uint32_t docId) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
paychannelKeylet(AccountID const& account, AccountID const& destination, std::uint32_t seq)
|
||||
const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
permissionedDomainKeylet(AccountID const& account, std::uint32_t seq) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
signerListKeylet(AccountID const& account) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
ticketKeylet(AccountID const& account, std::uint32_t seq) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
vaultKeylet(AccountID const& account, std::uint32_t seq) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
sponsorshipKeylet(AccountID const& sponsor, AccountID const& sponsee) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
loanBrokerKeylet(AccountID const& owner, std::uint32_t seq) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
loanKeylet(uint256 const& loanBrokerID, std::uint32_t loanSeq) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
getNFT(AccountID const& account, uint256 const& nftId) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
getNFTIssuer(uint256 const& nftId) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<std::uint32_t, HostFunctionError>
|
||||
getNFTTaxon(uint256 const& nftId) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<int32_t, HostFunctionError>
|
||||
getNFTFlags(uint256 const& nftId) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<int32_t, HostFunctionError>
|
||||
getNFTTransferFee(uint256 const& nftId) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<std::uint32_t, HostFunctionError>
|
||||
getNFTSequence(uint256 const& nftId) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
// A no-op rather than Unimplemented: trace only writes to the local log.
|
||||
// trace_wrap has already rendered the guest's buffer into `data`.
|
||||
virtual void
|
||||
trace(std::string_view const& msg, std::string_view const& data) const
|
||||
{
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
floatFromInt(int64_t x, int32_t mode) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
floatFromUint(uint64_t x, int32_t mode) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
floatFromSTAmount(STAmount const& x, int32_t mode) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
floatFromSTNumber(STNumber const& x, int32_t mode) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<int64_t, HostFunctionError>
|
||||
floatToInt(Slice const& x, int32_t mode) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<FloatPair, HostFunctionError>
|
||||
floatToMantExp(Slice const& x) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
floatFromMantExp(int64_t mantissa, int32_t exponent, int32_t mode) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<int32_t, HostFunctionError>
|
||||
floatCompare(Slice const& x, Slice const& y) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
floatAdd(Slice const& x, Slice const& y, int32_t mode) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
floatSubtract(Slice const& x, Slice const& y, int32_t mode) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
floatMultiply(Slice const& x, Slice const& y, int32_t mode) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
floatDivide(Slice const& x, Slice const& y, int32_t mode) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
[[nodiscard]] [[nodiscard]] virtual std::expected<Bytes, HostFunctionError>
|
||||
floatPower(Slice const& x, int32_t n, int32_t mode) const
|
||||
{
|
||||
return std::unexpected(HostFunctionError::Unimplemented);
|
||||
}
|
||||
|
||||
virtual ~HostFunctions() = default;
|
||||
// LCOV_EXCL_STOP
|
||||
};
|
||||
|
||||
} // namespace xrpl
|
||||
296
include/xrpl/tx/wasm/HostFuncImpl.h
Normal file
296
include/xrpl/tx/wasm/HostFuncImpl.h
Normal file
@@ -0,0 +1,296 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/basics/Slice.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/core/ServiceRegistry.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/Keylet.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STAmount.h>
|
||||
#include <xrpl/protocol/UintTypes.h>
|
||||
#include <xrpl/tx/ApplyContext.h>
|
||||
#include <xrpl/tx/wasm/HostFunc.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
// Intended to work only through wasm runtime. Don't call them directly, except with unit tests
|
||||
class WasmHostFunctionsImpl : public HostFunctions
|
||||
{
|
||||
ApplyContext& ctx_;
|
||||
|
||||
Keylet leKey_;
|
||||
mutable std::optional<std::shared_ptr<SLE const>> currentLedgerObj_;
|
||||
|
||||
static int constexpr maxCache = 256;
|
||||
std::array<std::shared_ptr<SLE const>, maxCache> cache_;
|
||||
|
||||
std::optional<Bytes> data_;
|
||||
|
||||
public:
|
||||
std::expected<std::shared_ptr<SLE const>, HostFunctionError>
|
||||
getCurrentLedgerObj() const
|
||||
{
|
||||
if (!currentLedgerObj_)
|
||||
currentLedgerObj_ = ctx_.view().read(leKey_);
|
||||
if (*currentLedgerObj_)
|
||||
return *currentLedgerObj_;
|
||||
return std::unexpected(HostFunctionError::LedgerObjNotFound);
|
||||
}
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
normalizeCacheIndex(int32_t cacheIdx) const
|
||||
{
|
||||
--cacheIdx;
|
||||
if (cacheIdx < 0 || cacheIdx >= maxCache)
|
||||
return std::unexpected(HostFunctionError::SlotOutRange);
|
||||
if (!cache_[cacheIdx])
|
||||
return std::unexpected(HostFunctionError::EmptySlot);
|
||||
return cacheIdx;
|
||||
}
|
||||
|
||||
template <typename F>
|
||||
void
|
||||
log(std::string_view const& msg, F&& dataFn) const
|
||||
{
|
||||
#ifdef DEBUG_OUTPUT
|
||||
auto& j = std::cerr;
|
||||
#else
|
||||
if (!getJournal().active(beast::Severity::Trace))
|
||||
return;
|
||||
auto j = getJournal().trace();
|
||||
#endif
|
||||
j << "WasmTrace[" << toShortString(leKey_.key) << "]: " << msg << " " << dataFn();
|
||||
|
||||
#ifdef DEBUG_OUTPUT
|
||||
j << std::endl;
|
||||
#endif
|
||||
}
|
||||
|
||||
public:
|
||||
WasmHostFunctionsImpl(ApplyContext& ct, Keylet const& leKey)
|
||||
: HostFunctions(ct.journal), ctx_(ct), leKey_(leKey)
|
||||
{
|
||||
}
|
||||
|
||||
bool
|
||||
checkSelf() const override
|
||||
{
|
||||
return !currentLedgerObj_ && !data_ &&
|
||||
std::ranges::none_of(cache_, [](auto const& p) { return !!p; });
|
||||
}
|
||||
|
||||
std::optional<Bytes> const&
|
||||
getData() const
|
||||
{
|
||||
return data_;
|
||||
}
|
||||
|
||||
std::expected<std::uint32_t, HostFunctionError>
|
||||
getLedgerSqn() const override;
|
||||
|
||||
std::expected<std::uint32_t, HostFunctionError>
|
||||
getParentLedgerTime() const override;
|
||||
|
||||
std::expected<Hash, HostFunctionError>
|
||||
getParentLedgerHash() const override;
|
||||
|
||||
std::expected<std::uint32_t, HostFunctionError>
|
||||
getBaseFee() const override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
isAmendmentEnabled(uint256 const& amendmentId) const override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
isAmendmentEnabled(std::string_view const& amendmentName) const override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
cacheLedgerObj(uint256 const& objId, int32_t cacheIdx) override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
getTxField(SField const& fname) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
getCurrentLedgerObjField(SField const& fname) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
getLedgerObjField(int32_t cacheIdx, SField const& fname) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
getTxNestedField(FieldLocator const& locator) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
getCurrentLedgerObjNestedField(FieldLocator const& locator) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
getLedgerObjNestedField(int32_t cacheIdx, FieldLocator const& locator) const override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
getTxArrayLen(SField const& fname) const override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
getCurrentLedgerObjArrayLen(SField const& fname) const override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
getLedgerObjArrayLen(int32_t cacheIdx, SField const& fname) const override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
getTxNestedArrayLen(FieldLocator const& locator) const override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
getCurrentLedgerObjNestedArrayLen(FieldLocator const& locator) const override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
getLedgerObjNestedArrayLen(int32_t cacheIdx, FieldLocator const& locator) const override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
updateData(Slice const& data) override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
checkSignature(Slice const& message, Slice const& signature, Slice const& pubkey)
|
||||
const override;
|
||||
|
||||
std::expected<Hash, HostFunctionError>
|
||||
computeSha512HalfHash(Slice const& data) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
accountKeylet(AccountID const& account) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
ammKeylet(Asset const& issue1, Asset const& issue2) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
checkKeylet(AccountID const& account, std::uint32_t seq) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
credentialKeylet(AccountID const& subject, AccountID const& issuer, Slice const& credentialType)
|
||||
const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
didKeylet(AccountID const& account) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
delegateKeylet(AccountID const& account, AccountID const& authorize) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
depositPreauthKeylet(AccountID const& account, AccountID const& authorize) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
escrowKeylet(AccountID const& account, std::uint32_t seq) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
trustLineKeylet(AccountID const& account1, AccountID const& account2, Currency const& currency)
|
||||
const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
mptokenIssuanceKeylet(AccountID const& issuer, std::uint32_t seq) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
mptokenKeylet(MPTID const& mptid, AccountID const& holder) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
nftokenOfferKeylet(AccountID const& account, std::uint32_t seq) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
offerKeylet(AccountID const& account, std::uint32_t seq) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
oracleKeylet(AccountID const& account, std::uint32_t docId) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
paychannelKeylet(AccountID const& account, AccountID const& destination, std::uint32_t seq)
|
||||
const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
permissionedDomainKeylet(AccountID const& account, std::uint32_t seq) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
signerListKeylet(AccountID const& account) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
ticketKeylet(AccountID const& account, std::uint32_t seq) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
vaultKeylet(AccountID const& account, std::uint32_t seq) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
sponsorshipKeylet(AccountID const& sponsor, AccountID const& sponsee) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
loanBrokerKeylet(AccountID const& owner, std::uint32_t seq) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
loanKeylet(uint256 const& loanBrokerID, std::uint32_t loanSeq) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
getNFT(AccountID const& account, uint256 const& nftId) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
getNFTIssuer(uint256 const& nftId) const override;
|
||||
|
||||
std::expected<std::uint32_t, HostFunctionError>
|
||||
getNFTTaxon(uint256 const& nftId) const override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
getNFTFlags(uint256 const& nftId) const override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
getNFTTransferFee(uint256 const& nftId) const override;
|
||||
|
||||
std::expected<std::uint32_t, HostFunctionError>
|
||||
getNFTSequence(uint256 const& nftId) const override;
|
||||
|
||||
void
|
||||
trace(std::string_view const& msg, std::string_view const& data) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatFromInt(int64_t x, int32_t mode) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatFromUint(uint64_t x, int32_t mode) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatFromSTAmount(STAmount const& x, int32_t mode) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatFromSTNumber(STNumber const& x, int32_t mode) const override;
|
||||
|
||||
std::expected<int64_t, HostFunctionError>
|
||||
floatToInt(Slice const& x, int32_t mode) const override;
|
||||
|
||||
std::expected<FloatPair, HostFunctionError>
|
||||
floatToMantExp(Slice const& x) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatFromMantExp(int64_t mantissa, int32_t exponent, int32_t mode) const override;
|
||||
|
||||
std::expected<int32_t, HostFunctionError>
|
||||
floatCompare(Slice const& x, Slice const& y) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatAdd(Slice const& x, Slice const& y, int32_t mode) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatSubtract(Slice const& x, Slice const& y, int32_t mode) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatMultiply(Slice const& x, Slice const& y, int32_t mode) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatDivide(Slice const& x, Slice const& y, int32_t mode) const override;
|
||||
|
||||
std::expected<Bytes, HostFunctionError>
|
||||
floatPower(Slice const& x, int32_t n, int32_t mode) const override;
|
||||
};
|
||||
|
||||
} // namespace xrpl
|
||||
41
include/xrpl/tx/wasm/README.md
Normal file
41
include/xrpl/tx/wasm/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# WASM Module for Programmable Escrows
|
||||
|
||||
WebAssembly execution for programmable escrows. When an escrow is finished, its contract
|
||||
runs to decide whether the release conditions are met. Specification:
|
||||
[XLS-0102: WASM VM](https://xls.xrpl.org/xls/XLS-0102-wasm-vm.html).
|
||||
|
||||
The engine itself is Rust (`crates/xrpl-wasm-vm`, over wasmi), reached through a cxx
|
||||
bridge.
|
||||
|
||||
## What is in this directory
|
||||
|
||||
- **`WasmVM.h`** — the entry points xrpld calls: `runEscrowWasm` (execute a contract,
|
||||
returning a result and its gas cost, or a `WasmTER`) and `preflightEscrowWasm` (screen a
|
||||
module with no host and no execution). Both own their TER maps.
|
||||
- **`HostFunc.h`** — the `HostFunctions` interface: one virtual per host function, each
|
||||
defaulting to `Unimplemented`, returning `std::expected<T, HostFunctionError>`.
|
||||
- **`HostFuncImpl.h`** — `WasmHostFunctionsImpl`, the implementation over an
|
||||
`ApplyContext&`. Bodies are split across `HostFuncImpl*.cpp` by category.
|
||||
- **`HostContext.h`** — the bridge's C++ half: an ABI-shaped, `noexcept` view of
|
||||
`HostFunctions` that the engine calls back into. Nothing may unwind into Rust, so every
|
||||
method catches everything — through `guarded()`, except `trace`, which answers the guest
|
||||
nothing and so has its own catch that only logs.
|
||||
- **`WasmCommon.h`** — the shared vocabulary: `HostFunctionError` (the codes a contract
|
||||
sees), `Bytes`, `FieldLocator`, `WasmTER`, `adjustWasmEndianess`, which is where the
|
||||
boundary's byte order is decided, and `guarded()`, the catch that turns a throwing host
|
||||
body into a code the engine can read.
|
||||
|
||||
## Host functions
|
||||
|
||||
Grouped by what they reach: ledger information; transaction and ledger-object field access;
|
||||
keylet construction; cryptography; float arithmetic; NFT queries; tracing.
|
||||
|
||||
The wire names and per-call gas costs are declared in `crates/xrpl-host-functions` —
|
||||
one `host_functions!` block that generates the ABI trait and the spec table. That
|
||||
declaration is the single source of truth; `HostFunc.h` is the C++ side of it.
|
||||
|
||||
## Entry point
|
||||
|
||||
A module must export `escrow_finish` (`escrowFunctionName`) taking no parameters and
|
||||
returning `int32_t`: positive means the conditions are met, zero or negative rejects the
|
||||
finish. Everything the contract needs it asks for through a host call.
|
||||
187
include/xrpl/tx/wasm/WasmCommon.h
Normal file
187
include/xrpl/tx/wasm/WasmCommon.h
Normal file
@@ -0,0 +1,187 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/basics/Log.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/basics/contract.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
|
||||
#include <bit>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <exception>
|
||||
#include <limits>
|
||||
#include <optional>
|
||||
#include <source_location>
|
||||
#include <stdexcept>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
using Bytes = std::vector<std::uint8_t>;
|
||||
using Hash = xrpl::uint256;
|
||||
using FloatPair = std::pair<int64_t, int32_t>;
|
||||
|
||||
enum class HostFunctionError : int32_t {
|
||||
Unimplemented = -1,
|
||||
FieldNotFound = -2,
|
||||
BufferTooSmall = -3,
|
||||
NoArray = -4,
|
||||
NotLeafField = -5,
|
||||
LocatorMalformed = -6,
|
||||
SlotOutRange = -7,
|
||||
SlotsFull = -8,
|
||||
EmptySlot = -9,
|
||||
LedgerObjNotFound = -10,
|
||||
OutOfTransferLimit = -11,
|
||||
DataFieldTooLarge = -12,
|
||||
PointerOutOfBounds = -13,
|
||||
NoMemExported = -14,
|
||||
InvalidParams = -15,
|
||||
InvalidAccount = -16,
|
||||
InvalidField = -17,
|
||||
IndexOutOfBounds = -18,
|
||||
FloatInputMalformed = -19,
|
||||
FloatComputationError = -20,
|
||||
|
||||
// The call was not served at all, so the engine stops the run and the transaction is
|
||||
// tecINTERNAL rather than the contract being handed a code to interpret. `guarded`
|
||||
// answers it for a host body that throws.
|
||||
//
|
||||
// The only entry outside the -1 ..= -20 range a contract reads: it needs no number
|
||||
// there, and INT32_MIN cannot collide with a code appended above. Negative so that a
|
||||
// reader treating it as an ordinary failure is still right.
|
||||
InternalFatal = std::numeric_limits<int32_t>::min(),
|
||||
};
|
||||
|
||||
template <typename T>
|
||||
struct WasmResult
|
||||
{
|
||||
T result;
|
||||
int64_t cost;
|
||||
};
|
||||
using EscrowResult = WasmResult<int32_t>;
|
||||
|
||||
// Engine error when wasm does not run to completion. `cost` is the gas consumed
|
||||
// when meaningful (tecOUT_OF_GAS / tecFAILED_PROCESSING; caller writes it to tx
|
||||
// metadata); std::nullopt for tecINTERNAL and malformed input (no gas reported).
|
||||
struct WasmTER
|
||||
{
|
||||
TER ter;
|
||||
std::optional<int64_t> cost;
|
||||
};
|
||||
|
||||
class FieldLocator
|
||||
{
|
||||
int32_t const* ptr_ = nullptr;
|
||||
uint32_t size_ = 0;
|
||||
std::vector<int32_t> buf_;
|
||||
|
||||
public:
|
||||
FieldLocator(std::vector<int32_t>&& buf)
|
||||
: ptr_(&buf[0]), size_(buf.size()), buf_(std::move(buf))
|
||||
{
|
||||
}
|
||||
|
||||
FieldLocator(int32_t const* ptr, uint32_t const size) : ptr_(ptr), size_(size)
|
||||
{
|
||||
}
|
||||
|
||||
FieldLocator(FieldLocator const&) = delete;
|
||||
FieldLocator&
|
||||
operator=(FieldLocator const&) = delete;
|
||||
FieldLocator(FieldLocator&&) = default;
|
||||
FieldLocator&
|
||||
operator=(FieldLocator&&) = default;
|
||||
|
||||
int32_t
|
||||
operator[](unsigned i) const
|
||||
{
|
||||
if (i >= size_)
|
||||
Throw<std::runtime_error>("index out of bounds");
|
||||
return ptr_[i];
|
||||
}
|
||||
|
||||
[[nodiscard]] uint32_t
|
||||
size() const
|
||||
{
|
||||
return size_;
|
||||
}
|
||||
|
||||
[[nodiscard]] int32_t const*
|
||||
data() const
|
||||
{
|
||||
return ptr_;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool
|
||||
empty() const
|
||||
{
|
||||
return size_ == 0;
|
||||
}
|
||||
};
|
||||
|
||||
template <typename T, size_t Size = sizeof(T)>
|
||||
constexpr T
|
||||
adjustWasmEndianessHlp(T x)
|
||||
{
|
||||
static_assert(std::is_integral_v<T>, "Only integral types");
|
||||
if constexpr (Size > 1)
|
||||
{
|
||||
using U = std::make_unsigned_t<T>;
|
||||
U u = static_cast<U>(x);
|
||||
U const low = (u & 0xFF) << ((Size - 1) << 3);
|
||||
u = adjustWasmEndianessHlp<U, Size - 1>(u >> 8);
|
||||
return static_cast<T>(low | u);
|
||||
}
|
||||
|
||||
return x;
|
||||
}
|
||||
|
||||
template <typename T, size_t Size = sizeof(T)>
|
||||
constexpr T
|
||||
adjustWasmEndianess(T x)
|
||||
{
|
||||
// LCOV_EXCL_START
|
||||
static_assert(std::is_integral_v<T>, "Only integral types");
|
||||
if constexpr (std::endian::native == std::endian::big)
|
||||
{
|
||||
return adjustWasmEndianessHlp(x);
|
||||
}
|
||||
return x;
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
constexpr int32_t
|
||||
hfErrorToInt(HostFunctionError e)
|
||||
{
|
||||
return static_cast<int32_t>(e);
|
||||
}
|
||||
|
||||
template <class Body>
|
||||
std::invoke_result_t<Body>
|
||||
guarded(
|
||||
beast::Journal journal,
|
||||
std::invoke_result_t<Body> onThrow,
|
||||
Body&& body,
|
||||
std::source_location const location = std::source_location::current()) noexcept
|
||||
{
|
||||
try
|
||||
{
|
||||
return body();
|
||||
}
|
||||
catch (std::exception const& e)
|
||||
{
|
||||
JLOG(journal.error()) << "wasm: " << location.function_name() << " threw: " << e.what();
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
JLOG(journal.error()) << "wasm: " << location.function_name() << " threw";
|
||||
}
|
||||
|
||||
return onThrow;
|
||||
}
|
||||
|
||||
} // namespace xrpl
|
||||
50
include/xrpl/tx/wasm/WasmVM.h
Normal file
50
include/xrpl/tx/wasm/WasmVM.h
Normal file
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/tx/wasm/HostFunc.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
// The export a programmable escrow's contract is run through.
|
||||
std::string_view inline constexpr escrowFunctionName = "escrow_finish";
|
||||
|
||||
// Run `wasmCode`'s `funcName` export with `gasLimit` gas, servicing its host calls
|
||||
// through `hfs`.
|
||||
//
|
||||
// On success the result is what the contract returned - positive means the escrow may
|
||||
// finish - together with the gas it consumed. On failure it is the TER to apply and,
|
||||
// when the number means anything, the gas to write to transaction metadata: a contract
|
||||
// that traps or exhausts its budget is charged for what it burned, while a `tecINTERNAL`
|
||||
// reports no cost because the fault is the node's rather than the transaction's.
|
||||
std::expected<EscrowResult, WasmTER>
|
||||
runEscrowWasm(
|
||||
Bytes const& wasmCode,
|
||||
HostFunctions& hfs,
|
||||
std::int64_t gasLimit,
|
||||
std::string_view funcName = escrowFunctionName) noexcept;
|
||||
|
||||
// Screen `wasmCode`: whether `runEscrowWasm` would refuse it before the contract's
|
||||
// first instruction. Compiles the module and reads its imports and exports; runs
|
||||
// nothing.
|
||||
//
|
||||
// Takes no `HostFunctions`, because the verdict comes from the compiled module alone.
|
||||
// That is what makes this callable from a transactor's `preflight`, which has no view
|
||||
// to build a host over.
|
||||
//
|
||||
// `temINVALID_BYTECODE` for every fault in the module - the transaction carries something this
|
||||
// engine cannot run, so it is refused before it can reach the ledger.
|
||||
// `telFAILED_PROCESSING` if the engine itself failed: nothing was learned about the
|
||||
// module, and a defect here is not evidence that the transaction is malformed.
|
||||
NotTEC
|
||||
preflightEscrowWasm(
|
||||
Bytes const& wasmCode,
|
||||
beast::Journal j,
|
||||
std::string_view funcName = escrowFunctionName) noexcept;
|
||||
|
||||
} // namespace xrpl
|
||||
@@ -131,6 +131,9 @@ Rust toolchain:
|
||||
✅ rust-analyzer
|
||||
rust-analyzer 1.97.1 (8bab26f4 2026-07-14)
|
||||
/nix/store/j6apc5pmd0giy15da9p650r8zklslmvi-rust-analyzer-preview-1.97.1-aarch64-apple-darwin/bin/rust-analyzer
|
||||
✅ rust-nightly
|
||||
rustc 1.99.0-nightly (87e5904f5 2026-07-20)
|
||||
/nix/store/fqpjz4l0nsnji8b2pz57mnj0akbp6hcl-rust-nightly/bin/rust-nightly
|
||||
✅ rustc
|
||||
rustc 1.97.1 (8bab26f4f 2026-07-14)
|
||||
/nix/store/bnfk1sl4s9angb0vj1cj9a5y5zvqinwy-rust-minimal-1.97.1/bin/rustc
|
||||
@@ -140,4 +143,4 @@ Rust toolchain:
|
||||
|
||||
Skipping git-over-HTTPS check (CHECK_TOOLS_SKIP_CLONE is set).
|
||||
|
||||
✅ All 44 checked tools are present and runnable.
|
||||
✅ All 45 checked tools are present and runnable.
|
||||
|
||||
@@ -131,6 +131,9 @@ Rust toolchain:
|
||||
✅ rust-analyzer
|
||||
rust-analyzer 1.97.1 (8bab26f 2026-07-14)
|
||||
/nix/store/lr3m97p3hx1k22a7c44pb0wa7rbayhfi-rust-analyzer-preview-1.97.1-x86_64-unknown-linux-gnu/bin/rust-analyzer
|
||||
✅ rust-nightly
|
||||
rustc 1.99.0-nightly (87e5904f5 2026-07-20)
|
||||
/nix/store/j7kf7a5h4xypzp6x1skg4dsdx2k4fwb3-rust-nightly/bin/rust-nightly
|
||||
✅ rustc
|
||||
rustc 1.97.1 (8bab26f4f 2026-07-14)
|
||||
/nix/store/40d3mzka7r1ps71l0yv2fs6616nbw85m-rust-minimal-1.97.1/bin/rustc
|
||||
@@ -168,4 +171,4 @@ Mold:
|
||||
|
||||
Skipping git-over-HTTPS check (CHECK_TOOLS_SKIP_CLONE is set).
|
||||
|
||||
✅ All 52 checked tools are present and runnable.
|
||||
✅ All 53 checked tools are present and runnable.
|
||||
|
||||
@@ -131,6 +131,9 @@ Rust toolchain:
|
||||
✅ rust-analyzer
|
||||
rust-analyzer 1.97.1 (8bab26f 2026-07-14)
|
||||
/nix/store/262830dlw2517lnagfx7i7agqgl4fmsd-rust-analyzer-preview-1.97.1-aarch64-unknown-linux-gnu/bin/rust-analyzer
|
||||
✅ rust-nightly
|
||||
rustc 1.99.0-nightly (87e5904f5 2026-07-20)
|
||||
/nix/store/c59pxk1yikdlf129qwyg4fplmxcrha0k-rust-nightly/bin/rust-nightly
|
||||
✅ rustc
|
||||
rustc 1.97.1 (8bab26f4f 2026-07-14)
|
||||
/nix/store/a6p27cg6b8szfixfyvkssx6l0c345zw8-rust-minimal-1.97.1/bin/rustc
|
||||
@@ -168,4 +171,4 @@ Mold:
|
||||
|
||||
Skipping git-over-HTTPS check (CHECK_TOOLS_SKIP_CLONE is set).
|
||||
|
||||
✅ All 52 checked tools are present and runnable.
|
||||
✅ All 53 checked tools are present and runnable.
|
||||
|
||||
@@ -20,3 +20,20 @@ target_link_libraries(
|
||||
xrpl_add_benchmark(nodestore)
|
||||
target_link_libraries(xrpl.bench.nodestore PRIVATE xrpl.imports.bench)
|
||||
add_dependencies(xrpl.benchmarks xrpl.bench.nodestore)
|
||||
|
||||
# Gas calibration for the wasm host functions. The ledger and real host come from
|
||||
# `xrpl.testkit.wasm` (built with the tests, but framework-free), so this target links
|
||||
# no GTest and no GMock.
|
||||
if(TARGET xrpl.testkit.wasm)
|
||||
xrpl_add_benchmark(wasm)
|
||||
target_link_libraries(
|
||||
xrpl.bench.wasm
|
||||
PRIVATE xrpl.imports.bench xrpl.testkit.wasm
|
||||
)
|
||||
add_dependencies(xrpl.benchmarks xrpl.bench.wasm)
|
||||
else()
|
||||
message(
|
||||
STATUS
|
||||
"xrpl.testkit.wasm not built (tests disabled); skipping xrpl.bench.wasm."
|
||||
)
|
||||
endif()
|
||||
|
||||
175
src/benchmarks/libxrpl/wasm/BenchFixtures.cpp
Normal file
175
src/benchmarks/libxrpl/wasm/BenchFixtures.cpp
Normal file
@@ -0,0 +1,175 @@
|
||||
#include <benchmarks/libxrpl/wasm/BenchFixtures.h>
|
||||
|
||||
#include <xrpl/basics/Slice.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/Keylet.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STArray.h>
|
||||
#include <xrpl/protocol/STObject.h>
|
||||
#include <xrpl/protocol/SeqProxy.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/protocol_autogen/transactions/EscrowCreate.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <helpers/Account.h>
|
||||
#include <helpers/TxTest.h>
|
||||
#include <tx/wasm/fixtures/FloatConstants.h>
|
||||
#include <tx/wasm/fixtures/NftSetup.h>
|
||||
#include <tx/wasm/fixtures/WasmLedger.h>
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
|
||||
namespace xrpl::test::bench {
|
||||
|
||||
Fixtures::Fixtures()
|
||||
: alice_{ledger_.fund("benchAlice")}
|
||||
, bob_{ledger_.fund("benchBob")}
|
||||
, signerListOwner_{ledger_.fund("benchSigners")}
|
||||
, escrow_{keylet::account(AccountID{})}
|
||||
, signedMessage_{signMessage("the quick brown fox jumps over the lazy dog")}
|
||||
, nftId_{NftIds::makeNftId(alice_.id())}
|
||||
{
|
||||
ledger_.makeSignerList(signerListOwner_, 2, {{alice_, 1}, {bob_, 1}});
|
||||
|
||||
// The escrow has to be submitted after the accounts exist, which is why it is built here
|
||||
// rather than in the initializer list: its keylet depends on the owner's sequence number at
|
||||
// submission time.
|
||||
auto const ownerSeq = ledger_.ledger.getAccountRoot(alice_.id()).getSequence();
|
||||
auto const created = ledger_.ledger.submit(
|
||||
transactions::EscrowCreateBuilder{alice_.id(), bob_.id(), XRP(100)}.setFinishAfter(
|
||||
900'000'000),
|
||||
alice_);
|
||||
if (created.ter != tesSUCCESS)
|
||||
{
|
||||
fixtureFailed(std::string{"creating the escrow: "} + transToken(created.ter));
|
||||
}
|
||||
ledger_.ledger.close();
|
||||
escrow_ = keylet::escrow(alice_.id(), SeqProxy::rawSequence(ownerSeq));
|
||||
}
|
||||
|
||||
Account const&
|
||||
Fixtures::alice() const
|
||||
{
|
||||
return alice_;
|
||||
}
|
||||
|
||||
Account const&
|
||||
Fixtures::bob() const
|
||||
{
|
||||
return bob_;
|
||||
}
|
||||
|
||||
TxAssembler
|
||||
Fixtures::memoTx()
|
||||
{
|
||||
auto assembler = escrowFinishTx(ledger_.ledger, alice_);
|
||||
assembler.build = [inner = std::move(assembler.build)](STObject& obj) {
|
||||
inner(obj);
|
||||
auto memos = STArray{};
|
||||
memos.push_back(makeMemo(WasmLedger::toBytes("hello")));
|
||||
memos.push_back(makeMemo(WasmLedger::toBytes("world")));
|
||||
obj.setFieldArray(sfMemos, memos);
|
||||
};
|
||||
return assembler;
|
||||
}
|
||||
|
||||
FieldLocator
|
||||
Fixtures::memoLocator()
|
||||
{
|
||||
return FieldLocator{{sfMemos.getCode(), 0, sfMemoData.getCode()}};
|
||||
}
|
||||
|
||||
WasmHost
|
||||
Fixtures::host()
|
||||
{
|
||||
auto assembler = memoTx();
|
||||
return ledger_.makeHost(
|
||||
keylet::account(alice_.id()), assembler.type, std::move(assembler.build));
|
||||
}
|
||||
|
||||
WasmHost
|
||||
Fixtures::cachedHost()
|
||||
{
|
||||
auto wasmHost = host();
|
||||
if (!wasmHost->cacheLedgerObj(keylet::account(alice_.id()).key, 1).has_value())
|
||||
{
|
||||
fixtureFailed("caching the account root into slot 1");
|
||||
}
|
||||
return wasmHost;
|
||||
}
|
||||
|
||||
WasmHost
|
||||
Fixtures::signerListHost()
|
||||
{
|
||||
auto assembler = bareTx();
|
||||
return ledger_.makeHost(
|
||||
keylet::signerList(signerListOwner_.id()), assembler.type, std::move(assembler.build));
|
||||
}
|
||||
|
||||
WasmHost
|
||||
Fixtures::cachedSignerListHost()
|
||||
{
|
||||
auto assembler = bareTx();
|
||||
auto wasmHost =
|
||||
ledger_.makeHost(keylet::account(AccountID{}), assembler.type, std::move(assembler.build));
|
||||
if (!wasmHost->cacheLedgerObj(keylet::signerList(signerListOwner_.id()).key, 1).has_value())
|
||||
{
|
||||
fixtureFailed("caching the signer list into slot 1");
|
||||
}
|
||||
return wasmHost;
|
||||
}
|
||||
|
||||
WasmHost
|
||||
Fixtures::tracingHost()
|
||||
{
|
||||
return ledger_.makeTracingHost();
|
||||
}
|
||||
|
||||
Keylet const&
|
||||
Fixtures::escrow() const
|
||||
{
|
||||
return escrow_;
|
||||
}
|
||||
|
||||
WasmHost
|
||||
Fixtures::escrowHost()
|
||||
{
|
||||
return ledger_.makeHost(escrow_);
|
||||
}
|
||||
|
||||
Slice
|
||||
Fixtures::floatX()
|
||||
{
|
||||
return FloatConstants::slice(FloatConstants::kPi);
|
||||
}
|
||||
|
||||
Slice
|
||||
Fixtures::floatY()
|
||||
{
|
||||
return FloatConstants::slice(FloatConstants::kTwo);
|
||||
}
|
||||
|
||||
SignedMessage const&
|
||||
Fixtures::signedMessage() const
|
||||
{
|
||||
return signedMessage_;
|
||||
}
|
||||
|
||||
uint256 const&
|
||||
Fixtures::nftId() const
|
||||
{
|
||||
return nftId_;
|
||||
}
|
||||
|
||||
Fixtures&
|
||||
Fixtures::instance()
|
||||
{
|
||||
static Fixtures kValue;
|
||||
return kValue;
|
||||
}
|
||||
|
||||
} // namespace xrpl::test::bench
|
||||
111
src/benchmarks/libxrpl/wasm/BenchFixtures.h
Normal file
111
src/benchmarks/libxrpl/wasm/BenchFixtures.h
Normal file
@@ -0,0 +1,111 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/basics/Slice.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/protocol/Keylet.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
#include <helpers/Account.h>
|
||||
#include <tx/wasm/fixtures/WasmLedger.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace xrpl::test::bench {
|
||||
|
||||
// The ledger and canned inputs every `*.bench.cpp` measures against.
|
||||
class Fixtures
|
||||
{
|
||||
public:
|
||||
// The one set of fixtures every benchmark shares, built on first use.
|
||||
static Fixtures&
|
||||
instance();
|
||||
|
||||
// A sequence number for the keylets that take one. Arbitrary — a keylet hashes whatever it
|
||||
// is given, so the value cannot change the cost.
|
||||
static constexpr std::uint32_t kSeq = 42;
|
||||
|
||||
// Rounding mode 0 throughout the float family: modes select a tie-breaking rule, not a
|
||||
// different algorithm, so they do not move the cost, and pinning one keeps the fourteen
|
||||
// comparable.
|
||||
static constexpr std::int32_t kRoundingMode = 0;
|
||||
|
||||
// Two funded accounts, enough for every keylet shape and every object below.
|
||||
[[nodiscard]] Account const&
|
||||
alice() const;
|
||||
[[nodiscard]] Account const&
|
||||
bob() const;
|
||||
|
||||
// The default host: its transaction carries a two-element memo array (something for the
|
||||
// nested getters to walk to and the array-length getters to count) and its current object is
|
||||
// Alice's account root.
|
||||
[[nodiscard]] WasmHost
|
||||
host();
|
||||
|
||||
// The same, with Alice's account root pinned to slot 1, for the `le_*` getters that read
|
||||
// through a cache slot rather than the current object.
|
||||
[[nodiscard]] WasmHost
|
||||
cachedHost();
|
||||
|
||||
// An account root has no arrays, so the array-length getters that read a *ledger object*
|
||||
// need a different one. A signer list has `sfSignerEntries`; without it those calls would
|
||||
// answer `FieldNotFound` and the benchmark would time the rejection instead of the work.
|
||||
[[nodiscard]] WasmHost
|
||||
signerListHost();
|
||||
[[nodiscard]] WasmHost
|
||||
cachedSignerListHost();
|
||||
|
||||
// A host whose `trace` output is captured rather than dropped, so the log-enabled path can
|
||||
// be measured against the log-disabled one that `host()` gives.
|
||||
[[nodiscard]] WasmHost
|
||||
tracingHost();
|
||||
|
||||
// A real escrow, created through the real transactor — the current object for
|
||||
// `home_le_field`, the one getter whose cost depends on the object it reads rather than on
|
||||
// its arguments.
|
||||
[[nodiscard]] Keylet const&
|
||||
escrow() const;
|
||||
[[nodiscard]] WasmHost
|
||||
escrowHost();
|
||||
|
||||
// `sfMemos[0].sfMemoData` — a two-step locator path, the shape the nested getters are priced
|
||||
// for.
|
||||
[[nodiscard]] static FieldLocator
|
||||
memoLocator();
|
||||
|
||||
// Canonical float operands. Zeroed bytes decode as a non-canonical float and would be
|
||||
// refused before any arithmetic ran, so the whole family shares these two known-good values.
|
||||
[[nodiscard]] static Slice
|
||||
floatX();
|
||||
[[nodiscard]] static Slice
|
||||
floatY();
|
||||
|
||||
// A signed message for `check_sig`. Signing is far more expensive than the verification
|
||||
// being measured, so it happens once here rather than inside a timed loop.
|
||||
[[nodiscard]] SignedMessage const&
|
||||
signedMessage() const;
|
||||
|
||||
// A well-formed NFToken id with the fixture's known taxon, flags, fee and sequence baked in,
|
||||
// so the id-extractor getters have real fields to pull out rather than zeros.
|
||||
[[nodiscard]] uint256 const&
|
||||
nftId() const;
|
||||
|
||||
private:
|
||||
// Order matters, and that is the reason this is a constructor rather than a pile of lazy
|
||||
// statics: the accounts have to be funded before the signer list and the escrow can be built
|
||||
// on them.
|
||||
Fixtures();
|
||||
|
||||
// The transaction the default host runs, carrying the memo array.
|
||||
[[nodiscard]] TxAssembler
|
||||
memoTx();
|
||||
|
||||
WasmLedger ledger_;
|
||||
Account alice_;
|
||||
Account bob_;
|
||||
Account signerListOwner_;
|
||||
Keylet escrow_;
|
||||
SignedMessage signedMessage_;
|
||||
uint256 nftId_;
|
||||
};
|
||||
|
||||
} // namespace xrpl::test::bench
|
||||
44
src/benchmarks/libxrpl/wasm/Crossing.cpp
Normal file
44
src/benchmarks/libxrpl/wasm/Crossing.cpp
Normal file
@@ -0,0 +1,44 @@
|
||||
#include <benchmark/benchmark.h>
|
||||
#include <benchmarks/libxrpl/wasm/BenchFixtures.h>
|
||||
#include <benchmarks/libxrpl/wasm/WasmBench.h>
|
||||
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test::bench {
|
||||
namespace {
|
||||
|
||||
// The harness checking itself.
|
||||
//
|
||||
// This file holds no host function — it belongs to the wasm directory rather than
|
||||
// `host_functions/` because it measures the two reference points every per-function number is read
|
||||
// against, and neither is a host call.
|
||||
//
|
||||
// `GuestInstruction` runs a contract whose "host call" is a couple of guest instructions. Its
|
||||
// `implied_gas` and `charged_gas` are then two independent measurements of the same quantity — one
|
||||
// from wall time via `secondsPerGas`, one from the engine's own fuel meter — and they should agree
|
||||
// closely. When they diverge, `secondsPerGas` has measured something other than a guest
|
||||
// instruction and no other number in the run is trustworthy. Read this first.
|
||||
//
|
||||
// The crossing floor is the other reference point, and it lives in `host_functions/LedgerSqn`:
|
||||
// `ldgr_index` takes no input and answers from a header already in hand, so its impl is as close
|
||||
// to nothing as a host function gets, and whatever its `ThroughVm` case costs above its `Impl`
|
||||
// case is the price of leaving the guest — paid by every one of the 61 functions before any of
|
||||
// them does any work.
|
||||
//
|
||||
// So the reading order across the suite is: this file, then `LedgerSqn`'s pair for the floor, then
|
||||
// a function's own `Impl` number. Those three should account for its `ThroughVm` number; where
|
||||
// they do not, the gap is size-dependent copying, which the swept cases (`Sha512Half`,
|
||||
// `UpdateData`) expose.
|
||||
|
||||
void
|
||||
guestInstruction(benchmark::State& state)
|
||||
{
|
||||
static constexpr std::string_view kBody = "(i32.add (local.get $r) (i32.const 1))";
|
||||
// Empty import name: this case prices no host function, so there is nothing to look a
|
||||
// declaration up for and it reports no `suggested_gas`.
|
||||
benchmarkThroughVm(state, "", "", "", kBody, [] { return Fixtures::instance().host(); });
|
||||
}
|
||||
BENCHMARK(guestInstruction)->UseManualTime()->Iterations(kBenchIterations);
|
||||
|
||||
} // namespace
|
||||
} // namespace xrpl::test::bench
|
||||
182
src/benchmarks/libxrpl/wasm/README.md
Normal file
182
src/benchmarks/libxrpl/wasm/README.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# WASM host functions — gas calibration
|
||||
|
||||
These are **not tests**: nothing asserts, and a number moving is not a build failure. They answer
|
||||
the question the tests cannot — whether each `#[gas = N]` in
|
||||
`crates/xrpl-host-functions/src/lib.rs` matches what the function actually costs.
|
||||
|
||||
```bash
|
||||
cmake --build build --target xrpl.bench.wasm
|
||||
./build/xrpl.bench.wasm # everything (~2 min)
|
||||
./build/xrpl.bench.wasm --benchmark_filter=sha512Half
|
||||
./build/xrpl.bench.wasm --benchmark_repetitions=25 --benchmark_report_aggregates_only=true
|
||||
```
|
||||
|
||||
**Build Release.** Debug inflates the crossing far more than the impls: `Impl`-to-`Impl` ratios
|
||||
survive it, `suggested_gas` does not.
|
||||
|
||||
## Reading the output
|
||||
|
||||
| Counter | Meaning |
|
||||
| ------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `suggested_gas` | **the answer** — what this function should be priced at |
|
||||
| `host_function_gas` | what `lib.rs` says today, read through the `wasm_testkit` bridge so it can't drift |
|
||||
| `price_ratio` | `host_function_gas / suggested_gas`. **1.0 is correct; below 1 is underpriced** |
|
||||
| `rel_error` | relative uncertainty of `suggested_gas`. **Quote this one** — ~1.2% when quiet |
|
||||
| `unreliable` | `1` means do not act on this row |
|
||||
| `implied_gas` | the raw measurement, before the crossing is added back |
|
||||
| `charged_gas` | what the engine actually billed; confirms the right call was measured |
|
||||
| `ns_per_call` | raw wall time, for debugging a suspicious ratio |
|
||||
|
||||
Sort by `price_ratio`. **Below 1 is the direction that matters** — an underpriced call is one a
|
||||
contract can buy too cheaply, a denial-of-service vector rather than a rounding error:
|
||||
|
||||
```bash
|
||||
./build/xrpl.bench.wasm --benchmark_format=json |
|
||||
jq -r '.benchmarks[] | select(.price_ratio) | [.price_ratio, .name] | @tsv' | sort -n
|
||||
```
|
||||
|
||||
`unreliable=1` when `rel_error` exceeds 25%, or when `suggested_gas` falls below the crossing floor
|
||||
— a call whose own cost is small next to the crossing is read off the difference of two nearly
|
||||
equal numbers.
|
||||
|
||||
### With `--benchmark_repetitions`
|
||||
|
||||
Adds `_mean` / `_median` / `_stddev` / `_cv` rows. One trap worth knowing:
|
||||
|
||||
**`cv` on `suggested_gas` for an `Impl` case is not an error bar.** The crossing floor is measured
|
||||
once per process, so repetitions never resample it — and it is most of a cheap `Impl` case's value.
|
||||
Over 25 repetitions on an idle machine, `Impl` `cv` reads **1.3%** against `ThroughVm`'s 2.3%, while
|
||||
`rel_error` is 1.2% for both. The lowest number in the output is the least trustworthy one.
|
||||
|
||||
Use repetitions to confirm the machine is quiet and to get a median; quote `rel_error`. Under load
|
||||
the `Impl` cases inflate first (3.0% against 1.8% at load ~10), which makes the gap between the two
|
||||
families a usable load detector.
|
||||
|
||||
## How `suggested_gas` is measured
|
||||
|
||||
Gas is wasmi fuel — `set_fuel(gas)` meters guest instructions and host charges from one pool — so
|
||||
one gas is about one guest instruction and the question becomes a ratio. Every step is a
|
||||
subtraction, so fixed costs cancel:
|
||||
|
||||
```
|
||||
secondsPerGas = (time_busy − time_idle) / (fuel_busy − fuel_idle) # pure-wasm loop, N vs 0
|
||||
implied_gas = secondsPerCall / secondsPerGas
|
||||
crossing_floor = (ldgr_index ThroughVm − ldgr_index Impl) / secondsPerGas
|
||||
|
||||
suggested_gas = implied_gas # ThroughVm — the guest already paid the crossing
|
||||
suggested_gas = implied_gas + crossing_floor # Impl — a guest cannot call without paying it
|
||||
price_ratio = host_function_gas / suggested_gas
|
||||
|
||||
rel_error = sqrt( ( sqrt((implied·caseErr)² + (floor·floorErr)²) / suggested )² + perGasErr² )
|
||||
```
|
||||
|
||||
`secondsPerCall` is itself a subtraction: a `ThroughVm` case runs a contract making N host calls
|
||||
against a **byte-identical** one making none, so compilation, instantiation and the guest's own loop
|
||||
cancel. An `Impl` case times `kCallsPerRun` direct calls and divides.
|
||||
|
||||
`secondsPerGas` is measured **once** and shared by every case, deliberately — two routes to the same
|
||||
price must divide by the same constant, and per-case calibration was tried and swung 6x between
|
||||
runs. Its uncertainty is propagated arithmetically instead. See the comments in `WasmBench.cpp` for
|
||||
why the three terms in `rel_error` do not all combine in quadrature.
|
||||
|
||||
**`guestInstruction` is the self-test — read it first.** It runs the calibration's own loop body, so
|
||||
its `implied_gas` (wall time) and `charged_gas` (the fuel meter) are two independent measurements of
|
||||
one quantity. Quiet Release machine: **≈13.7 against 13.007, ~5% high.** A persistent gap much
|
||||
beyond that means every other number in the run shares it. It has already caught an estimator
|
||||
mismatch worth +40%, and the memory leak below.
|
||||
|
||||
**`suggested_gas` for an `Impl`-only case is a lower bound** — the crossing floor is measured on a
|
||||
call with no input, so a function that moves bytes pays more; `Sha512Half` and `UpdateData` sweep
|
||||
that per-byte term. `ThroughVm` cases are deliberately one per crossing _shape_, not one per
|
||||
function.
|
||||
|
||||
## VM overhead (`Vm.cpp`)
|
||||
|
||||
Everything above prices what a contract _asks for_. `Vm.cpp` prices getting it running at all. Of
|
||||
the seven stages `runEscrowWasm` performs — compile, store, linker, instantiate, entry-point lookup,
|
||||
call, fuel read — **only the call is metered**; the rest is wall time no transaction pays for.
|
||||
|
||||
These cases report `ns_per_op` and `gas_equivalent` (that time over the same `secondsPerGas`, so an
|
||||
unpriced stage reads on the same axis as a priced one), plus `module_bytes` and `gas_per_byte` on
|
||||
size sweeps.
|
||||
|
||||
| case | measures |
|
||||
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `preflightMinimal` | compile plus the import/export walk. The narrowest view of compilation reachable from C++: no host, no instance |
|
||||
| `preflightRejects` | the same on a module refused at the import walk. Against `preflightMinimal`, what refusing costs versus accepting |
|
||||
| `runMinimal` | a whole run of a do-nothing contract — every stage. Minus `preflightMinimal`, the stages that are not compilation |
|
||||
| `compileScaling/N` | compilation against module size: `N` unreachable filler functions, preflight only |
|
||||
| `runScaling/N` | the same modules through a whole run. Paired with `compileScaling` per size, separates size-dependent stages from fixed ones |
|
||||
| `instantiateScaling/pages` | declared memory at constant module size, so what moves is the host allocating and zeroing pages |
|
||||
|
||||
Two of those pairings are the point of the file. `runMinimal − preflightMinimal` gives the fixed
|
||||
cost of everything that is not compilation; `runScaling` against `compileScaling` shows compilation
|
||||
appearing a second time, because the transactor validates and executes with no module cache between
|
||||
them. The size sweeps matter more than the floor: a fixed cost is only a griefing concern if it is
|
||||
large, but a slope against attacker-chosen module size is one at any height.
|
||||
|
||||
Two caveats when reading a sweep. `gas_per_byte` is an **average** carrying the case's fixed cost,
|
||||
not a marginal rate — it overestimates, and falls toward the true slope as the module grows, so read
|
||||
the convergence rather than any single row. And the `/4096` points get few iterations and go noisy
|
||||
first; compare `rel_error` across the sweep before quoting the largest one.
|
||||
|
||||
The sweep stops at 4096 functions for want of a real cap to stop at — no maximum contract size is
|
||||
enforced anywhere yet, the transactor not being wired.
|
||||
|
||||
The linker rebuild and the fuel-metering overhead are **not** separable from here — C++ sees only
|
||||
`runEscrowWasm` and `preflightEscrowWasm`. Both need benchmarks inside `xrpl-wasm-vm`, where
|
||||
`compile` and `wasm_engine` are `pub(crate)`.
|
||||
|
||||
### Compiling leaks — pin your iteration counts
|
||||
|
||||
`wasm_engine()` is a process-global `LazyLock<Engine>`, and what `Module::new` adds to it is never
|
||||
released. Repeatedly preflighting one **60-byte** module:
|
||||
|
||||
| `--benchmark_repetitions` | peak RSS |
|
||||
| ------------------------- | -------- |
|
||||
| 1 | 0.41 GB |
|
||||
| 5 | 1.46 GB |
|
||||
| 15 | 4.20 GB |
|
||||
|
||||
Linear, at roughly **800 bytes per compile**. Within the suite this is why every `Vm.cpp` case pins
|
||||
`->Iterations(...)`: automatic sizing ran `preflightMinimal` ~348k times per repetition, reaching
|
||||
7.9 GB at 25 repetitions, after which every later case in the binary failed to compile — 720 errored
|
||||
rows, all blaming cases that were innocent.
|
||||
|
||||
**Outside the suite it is worth a look.** A validator compiles once to screen an `EscrowCreate`
|
||||
and again for every `EscrowFinish` that runs the contract — with no module cache between them, and
|
||||
once per apply attempt rather than once per transaction — all against that same static engine.
|
||||
Whether that is unbounded growth in production depends on wasmi internals not checked here (wasmi
|
||||
2.0.0, wasmparser 0.228): this is the C++-visible symptom, not a diagnosis.
|
||||
|
||||
## Gotchas, each of which has already cost someone an afternoon
|
||||
|
||||
- **The wasm ABI is not the trait's argument order.** `float_add(x, y, mode, out)` in Rust is
|
||||
`(x_ptr, x_len, y_ptr, y_len, out_ptr, out_len, mode)` on the wire — scalars move _after_ the
|
||||
output region. Check `register.rs`, not `lib.rs`, when writing WAT.
|
||||
- **A soft host error still "succeeds".** The run completes and gas is charged _before_ the body, so
|
||||
a wrong-argument case reports a plausible, confidently wrong number. The harness requires the
|
||||
contract's result to be `>= 0`; the tell is a `ThroughVm` case coming out _faster_ than its `Impl`.
|
||||
- **A host serves exactly one run** (`checkSelf` in `WasmVM.cpp`), so a benchmark builds a fresh host
|
||||
per run and cannot pre-cache a slot.
|
||||
- **`MAX_FIELD_BYTES` is 1024** — nothing crosses the boundary above 1 KiB, so size sweeps stop there.
|
||||
- **Do not compare timings across builds at the cheap end.** Two builds whose timed regions were
|
||||
byte-identical measured 2.18 ns and 2.52 ns for the same case — ~15% apart, from code layout alone.
|
||||
Use `price_ratio` within one run, and reason from the code when judging whether a change costs
|
||||
anything.
|
||||
- **Every case pins `->Iterations(...)`, for two different reasons.** Host-function cases must
|
||||
because with `UseManualTime` automatic sizing reads only the tiny reported residue and would ask
|
||||
for millions of iterations; `Vm.cpp` cases must because compiling leaks.
|
||||
|
||||
## Layout
|
||||
|
||||
One `.cpp` per host function under `host_functions/`, mirroring
|
||||
`src/tests/libxrpl/tx/wasm/host_functions/`, so adding a host function is a two-file checklist
|
||||
rather than a judgement call. `Crossing.cpp` holds the harness's own reference points, `Vm.cpp` the
|
||||
per-run overhead around them, `WasmBench.*` the measurement machinery, `BenchFixtures.*` the shared
|
||||
ledger (one ledger, funded once, for the whole binary).
|
||||
|
||||
The ledger and real host come from `xrpl.testkit.wasm` — a framework-free library built alongside
|
||||
the tests — so this target links **no GTest and no GMock**. See
|
||||
`src/tests/libxrpl/tx/wasm/README.md` for how that library is split, and why its setup steps throw
|
||||
rather than using `EXPECT_`.
|
||||
149
src/benchmarks/libxrpl/wasm/Vm.cpp
Normal file
149
src/benchmarks/libxrpl/wasm/Vm.cpp
Normal file
@@ -0,0 +1,149 @@
|
||||
#include <benchmark/benchmark.h>
|
||||
#include <benchmarks/libxrpl/wasm/BenchFixtures.h>
|
||||
#include <benchmarks/libxrpl/wasm/WasmBench.h>
|
||||
#include <tx/wasm/fixtures/WasmRun.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <format>
|
||||
#include <string>
|
||||
|
||||
namespace xrpl::test::bench {
|
||||
namespace {
|
||||
|
||||
// What a run costs *around* the contract, rather than what its host calls cost. Only the guest's
|
||||
// own execution is metered, so every stage measured here is wall time no transaction pays for.
|
||||
// ../README.md has what each case measures and how to read `gas_equivalent`.
|
||||
//
|
||||
// **Every case must pin `->Iterations(...)`.** Compiling a module allocates against the
|
||||
// process-global engine and is never reclaimed — roughly 800 bytes per compile — so Google
|
||||
// Benchmark's automatic sizing, which targets a wall-clock budget rather than a compile count,
|
||||
// reaches gigabytes resident and the whole binary stops being able to compile anything.
|
||||
|
||||
// The smallest module the engine accepts. Everything a run does to it is overhead by construction.
|
||||
std::string
|
||||
minimalWat()
|
||||
{
|
||||
return R"wat((module
|
||||
(memory (export "memory") 1)
|
||||
(func (export "escrow_finish") (result i32)
|
||||
(i32.const 1)))
|
||||
)wat";
|
||||
}
|
||||
|
||||
// `count` unreachable functions on top of the minimal module: bigger without doing more.
|
||||
//
|
||||
// Each body is seeded with its own index so no two are identical and none folds to a constant the
|
||||
// translator can drop — either would break the link between function count and byte count.
|
||||
// Unreachable is fine: validation and translation visit every function a module declares, and that
|
||||
// visit is exactly the cost being swept.
|
||||
std::string
|
||||
fillerWat(size_t count)
|
||||
{
|
||||
auto out = std::string{"(module\n (memory (export \"memory\") 1)\n"};
|
||||
for (auto i = 0uz; i < count; ++i)
|
||||
{
|
||||
out += std::format(
|
||||
" (func $f{0} (param i32) (result i32)\n"
|
||||
" (i32.add (i32.mul (local.get 0) (i32.const {0})) (i32.const {0})))\n",
|
||||
i);
|
||||
}
|
||||
out += " (func (export \"escrow_finish\") (result i32)\n (i32.const 1)))\n";
|
||||
return out;
|
||||
}
|
||||
|
||||
// The minimal module asking for `pages` of initial memory. `MAX_MEMORY_PAGES` is 128 (8 MiB), and a
|
||||
// contract declares this for free — the host allocates and zeroes it before the first instruction.
|
||||
std::string
|
||||
pagesWat(size_t pages)
|
||||
{
|
||||
return std::format(
|
||||
"(module\n"
|
||||
" (memory (export \"memory\") {})\n"
|
||||
" (func (export \"escrow_finish\") (result i32)\n"
|
||||
" (i32.const 1)))\n",
|
||||
pages);
|
||||
}
|
||||
|
||||
// Compiles cleanly and is then refused: `env::malloc` is not a namespace the engine serves, so
|
||||
// `check_imports` stops at it.
|
||||
std::string
|
||||
rejectedWat()
|
||||
{
|
||||
return R"wat((module
|
||||
(import "env" "malloc" (func $malloc (param i32) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(func (export "escrow_finish") (result i32)
|
||||
(i32.const 1)))
|
||||
)wat";
|
||||
}
|
||||
|
||||
void
|
||||
preflightMinimal(benchmark::State& state)
|
||||
{
|
||||
static auto const kWasm = assembleWat(minimalWat());
|
||||
benchmarkPreflight(state, kWasm);
|
||||
}
|
||||
BENCHMARK(preflightMinimal)->UseManualTime()->Iterations(kBenchIterations);
|
||||
|
||||
void
|
||||
preflightRejects(benchmark::State& state)
|
||||
{
|
||||
static auto const kWasm = assembleWat(rejectedWat());
|
||||
benchmarkPreflight(state, kWasm, /*expectAccepted*/ false);
|
||||
}
|
||||
BENCHMARK(preflightRejects)->UseManualTime()->Iterations(kBenchIterations);
|
||||
|
||||
void
|
||||
runMinimal(benchmark::State& state)
|
||||
{
|
||||
static auto const kWasm = assembleWat(minimalWat());
|
||||
benchmarkRun(state, kWasm, [] { return Fixtures::instance().host(); });
|
||||
}
|
||||
BENCHMARK(runMinimal)->UseManualTime()->Iterations(kBenchIterations);
|
||||
|
||||
void
|
||||
compileScaling(benchmark::State& state)
|
||||
{
|
||||
auto const wasm = assembleWat(fillerWat(static_cast<size_t>(state.range(0))));
|
||||
benchmarkPreflight(state, wasm, /*expectAccepted*/ true, /*sizeSweep*/ true);
|
||||
}
|
||||
BENCHMARK(compileScaling)
|
||||
->UseManualTime()
|
||||
->Iterations(kBenchIterations)
|
||||
->Arg(1)
|
||||
->Arg(8)
|
||||
->Arg(64)
|
||||
->Arg(512)
|
||||
->Arg(4096);
|
||||
|
||||
void
|
||||
runScaling(benchmark::State& state)
|
||||
{
|
||||
auto const wasm = assembleWat(fillerWat(static_cast<size_t>(state.range(0))));
|
||||
benchmarkRun(state, wasm, [] { return Fixtures::instance().host(); }, /*sizeSweep*/ true);
|
||||
}
|
||||
BENCHMARK(runScaling)
|
||||
->UseManualTime()
|
||||
->Iterations(kBenchIterations)
|
||||
->Arg(1)
|
||||
->Arg(8)
|
||||
->Arg(64)
|
||||
->Arg(512)
|
||||
->Arg(4096);
|
||||
|
||||
void
|
||||
instantiateScaling(benchmark::State& state)
|
||||
{
|
||||
auto const wasm = assembleWat(pagesWat(static_cast<size_t>(state.range(0))));
|
||||
benchmarkRun(state, wasm, [] { return Fixtures::instance().host(); });
|
||||
}
|
||||
BENCHMARK(instantiateScaling)
|
||||
->UseManualTime()
|
||||
->Iterations(kBenchIterations)
|
||||
->Arg(1)
|
||||
->Arg(8)
|
||||
->Arg(32)
|
||||
->Arg(128);
|
||||
|
||||
} // namespace
|
||||
} // namespace xrpl::test::bench
|
||||
397
src/benchmarks/libxrpl/wasm/WasmBench.cpp
Normal file
397
src/benchmarks/libxrpl/wasm/WasmBench.cpp
Normal file
@@ -0,0 +1,397 @@
|
||||
#include <benchmarks/libxrpl/wasm/WasmBench.h>
|
||||
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/tx/wasm/HostFunc.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
#include <xrpl/tx/wasm/WasmVM.h>
|
||||
|
||||
#include <benchmark/benchmark.h>
|
||||
#include <rust/cxx.h>
|
||||
#include <tx/wasm/fixtures/WasmLedger.h>
|
||||
#include <tx/wasm/fixtures/WasmRun.h>
|
||||
#include <xrpl_wasm_testkit_cxxbridge/lib.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <format>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test::bench {
|
||||
|
||||
int
|
||||
callsWithinTransferBudget(std::int64_t bytesWrittenPerCall)
|
||||
{
|
||||
// Writes nothing back to the guest, so the budget does not apply.
|
||||
if (bytesWrittenPerCall <= 0)
|
||||
{
|
||||
return kCallsPerRun;
|
||||
}
|
||||
|
||||
auto const affordable = kTransferLimitBytes / bytesWrittenPerCall;
|
||||
if (affordable < 1)
|
||||
{
|
||||
fixtureFailed("a single call would exceed the run's transfer budget");
|
||||
}
|
||||
return static_cast<int>(std::min<std::int64_t>(affordable, kCallsPerRun));
|
||||
}
|
||||
|
||||
std::string
|
||||
dataSegment(int offset, std::span<std::uint8_t const> bytes)
|
||||
{
|
||||
return std::format(" (data (i32.const {}) \"{}\")\n", offset, watEscaped(bytes));
|
||||
}
|
||||
|
||||
std::string
|
||||
dataSegment(int offset, Bytes const& bytes)
|
||||
{
|
||||
return dataSegment(offset, std::span<std::uint8_t const>{bytes.data(), bytes.size()});
|
||||
}
|
||||
|
||||
std::string
|
||||
makeLoopWat(std::string_view imports, std::string_view data, std::string_view body, int count)
|
||||
{
|
||||
static constexpr auto kTemplate = R"wat((module
|
||||
{}
|
||||
(memory (export "memory") 1)
|
||||
{}
|
||||
(func (export "escrow_finish") (result i32)
|
||||
(local $i i32)
|
||||
(local $r i32)
|
||||
(local.set $i (i32.const {}))
|
||||
(block $done
|
||||
(loop $again
|
||||
(br_if $done (i32.eqz (local.get $i)))
|
||||
(local.set $r {})
|
||||
(local.set $i (i32.sub (local.get $i) (i32.const 1)))
|
||||
(br $again)))
|
||||
(local.get $r)))
|
||||
)wat";
|
||||
|
||||
return std::format(kTemplate, imports, data, count, body);
|
||||
}
|
||||
|
||||
Timing
|
||||
timeRun(HostFunctions& host, Bytes const& wasm)
|
||||
{
|
||||
auto const start = std::chrono::steady_clock::now();
|
||||
auto outcome = runEscrowWasm(wasm, host, kBenchGas);
|
||||
auto const elapsed = std::chrono::steady_clock::now() - start;
|
||||
|
||||
benchmark::DoNotOptimize(outcome);
|
||||
return {
|
||||
.seconds = std::chrono::duration<double>(elapsed).count(),
|
||||
.gas = outcome.has_value() ? outcome->cost : std::int64_t{0}};
|
||||
}
|
||||
|
||||
StageTimer::StageTimer(benchmark::State& state, std::int64_t moduleBytes)
|
||||
: state_{state}, moduleBytes_{moduleBytes}
|
||||
{
|
||||
}
|
||||
|
||||
void
|
||||
StageTimer::add(double seconds)
|
||||
{
|
||||
state_.SetIterationTime(seconds);
|
||||
total_ += seconds;
|
||||
sumSquares_ += seconds * seconds;
|
||||
++rounds_;
|
||||
}
|
||||
|
||||
void
|
||||
StageTimer::report()
|
||||
{
|
||||
if (rounds_ == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
auto const count = static_cast<double>(rounds_);
|
||||
auto const mean = total_ / count;
|
||||
auto const variance = std::max(0.0, (sumSquares_ / count) - (mean * mean));
|
||||
auto const spread = mean > 0.0 ? std::sqrt(variance) / mean : 0.0;
|
||||
|
||||
auto const& calibration = Calibration::instance();
|
||||
auto const perGas = calibration.secondsPerGas();
|
||||
auto const equivalent = perGas > 0.0 ? mean / perGas : 0.0;
|
||||
|
||||
state_.counters["ns_per_op"] = mean * 1e9;
|
||||
// What this stage would cost if it were charged at the rate the guest inside it is charged at.
|
||||
// It is not charged, which is the point: this puts an unpriced stage in the host-function
|
||||
// table's units.
|
||||
state_.counters["gas_equivalent"] = equivalent;
|
||||
|
||||
if (moduleBytes_ > 0)
|
||||
{
|
||||
state_.counters["module_bytes"] = static_cast<double>(moduleBytes_);
|
||||
// An average over the whole operation, not the marginal rate: it carries the case's fixed
|
||||
// cost, so it overestimates and falls toward the true slope as the sweep grows.
|
||||
state_.counters["gas_per_byte"] = equivalent / static_cast<double>(moduleBytes_);
|
||||
}
|
||||
|
||||
// `gas_equivalent` divides by `secondsPerGas`, so the divisor's uncertainty is in every number
|
||||
// here too.
|
||||
auto const caseStdErr = spread / std::sqrt(count);
|
||||
auto const perGasErr = calibration.secondsPerGasRelStdErr();
|
||||
auto const totalErr = std::sqrt((caseStdErr * caseStdErr) + (perGasErr * perGasErr));
|
||||
state_.counters["rel_error"] = totalErr;
|
||||
state_.counters["unreliable"] = totalErr > kMaxRelativeSpread ? 1 : 0;
|
||||
}
|
||||
|
||||
void
|
||||
benchmarkPreflight(benchmark::State& state, Bytes const& wasm, bool expectAccepted, bool sizeSweep)
|
||||
{
|
||||
(void)Calibration::instance();
|
||||
|
||||
// Discarded: the reject case refuses on every iteration, and a real sink would put string
|
||||
// formatting and I/O inside the measurement.
|
||||
auto const journal = beast::Journal{beast::Journal::getNullSink()};
|
||||
|
||||
if (isTesSuccess(preflightEscrowWasm(wasm, journal)) != expectAccepted)
|
||||
{
|
||||
state.SkipWithError(
|
||||
expectAccepted
|
||||
? "the module was refused; the case would be measuring the reject path"
|
||||
: "the module was accepted; the case would be measuring the accept path");
|
||||
return;
|
||||
}
|
||||
|
||||
StageTimer timer{state, sizeSweep ? static_cast<std::int64_t>(wasm.size()) : 0};
|
||||
for (auto _ : state)
|
||||
{
|
||||
auto const start = std::chrono::steady_clock::now();
|
||||
auto verdict = preflightEscrowWasm(wasm, journal);
|
||||
auto const elapsed = std::chrono::steady_clock::now() - start;
|
||||
|
||||
benchmark::DoNotOptimize(verdict);
|
||||
timer.add(std::chrono::duration<double>(elapsed).count());
|
||||
}
|
||||
timer.report();
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Seconds of wall time one unit of gas buys on this machine.
|
||||
//
|
||||
// The estimator must be the *same* one the cases use — a mean, with the same clamp at zero. Since
|
||||
// `implied_gas = secondsPerCall / secondsPerGas`, any difference between how divisor and dividend
|
||||
// are estimated lands in every reported number: calibrating with a best-of while measuring with a
|
||||
// mean once biased the whole report +40%.
|
||||
//
|
||||
// `guestInstruction` in Crossing.cpp is the check that this holds — it runs this exact loop body.
|
||||
double
|
||||
measureSecondsPerGas(double& relativeStandardError)
|
||||
{
|
||||
// A couple of guest instructions, no memory traffic, nothing the engine can fold away.
|
||||
static constexpr auto kBody = std::string_view{"(i32.add (local.get $r) (i32.const 1))"};
|
||||
auto const busy = assembleWat(makeLoopWat("", "", kBody, kCallsPerRun));
|
||||
auto const idle = assembleWat(makeLoopWat("", "", kBody, 0));
|
||||
|
||||
auto fixture = WasmLedger{};
|
||||
|
||||
// Warm up, so the first-run penalty does not land on one side of the subtraction.
|
||||
for (auto i = 0U; i < 8; ++i)
|
||||
{
|
||||
timeRun(*fixture.makeHost(), busy);
|
||||
timeRun(*fixture.makeHost(), idle);
|
||||
}
|
||||
|
||||
auto total = 0.0;
|
||||
auto sumSquares = 0.0;
|
||||
// Fuel is exact and deterministic, so any pair gives the same delta.
|
||||
auto gasDelta = std::int64_t{1};
|
||||
for (auto i = 0; i < kCalibrationPairs; ++i)
|
||||
{
|
||||
auto hotHost = fixture.makeHost();
|
||||
auto const hot = timeRun(*hotHost, busy);
|
||||
auto coldHost = fixture.makeHost();
|
||||
auto const cold = timeRun(*coldHost, idle);
|
||||
|
||||
auto const delta = std::max(0.0, hot.seconds - cold.seconds);
|
||||
total += delta;
|
||||
sumSquares += delta * delta;
|
||||
gasDelta = std::max(std::int64_t{1}, hot.gas - cold.gas);
|
||||
}
|
||||
|
||||
auto const mean = total / kCalibrationPairs;
|
||||
auto const variance = std::max(0.0, (sumSquares / kCalibrationPairs) - (mean * mean));
|
||||
relativeStandardError = mean > 0.0 ? std::sqrt(variance / kCalibrationPairs) / mean : 0.0;
|
||||
|
||||
return mean / static_cast<double>(gasDelta);
|
||||
}
|
||||
|
||||
// The crossing, in gas: `ldgr_index` through the VM minus `ldgr_index` called directly.
|
||||
//
|
||||
// Both halves are means, for the reason above: the VM half has to match `benchmarkThroughVm`'s
|
||||
// estimator and the impl half `benchmarkImpl`'s. `secondsPerGas` is passed in rather than
|
||||
// re-measured so it comes from the same snapshot.
|
||||
double
|
||||
measureCrossingFloorGas(double secondsPerGas, double& relativeStandardError)
|
||||
{
|
||||
static constexpr std::string_view kImport =
|
||||
R"( (import "host_lib" "ldgr_index" (func $ldgr_index (param i32 i32) (result i32)))
|
||||
)";
|
||||
static constexpr std::string_view kBody = "(call $ldgr_index (i32.const 0) (i32.const 4))";
|
||||
|
||||
auto const loaded = assembleWat(makeLoopWat(kImport, "", kBody, kCallsPerRun));
|
||||
auto const baseline = assembleWat(makeLoopWat(kImport, "", kBody, 0));
|
||||
|
||||
auto fixture = WasmLedger{};
|
||||
|
||||
auto vmTotal = 0.0;
|
||||
auto vmSquares = 0.0;
|
||||
auto guestOverheadGas = 0.0;
|
||||
for (auto i = 0; i < kBenchIterations; ++i)
|
||||
{
|
||||
auto hotHost = fixture.makeHost();
|
||||
auto const hot = timeRun(*hotHost, loaded);
|
||||
auto coldHost = fixture.makeHost();
|
||||
auto const cold = timeRun(*coldHost, baseline);
|
||||
|
||||
auto const perCall = std::max(0.0, hot.seconds - cold.seconds) / kCallsPerRun;
|
||||
vmTotal += perCall;
|
||||
vmSquares += perCall * perCall;
|
||||
// Exact, from the fuel meter: what the guest burned per call beyond the call itself.
|
||||
guestOverheadGas =
|
||||
(static_cast<double>(hot.gas - cold.gas) / kCallsPerRun) - declaredGas("ldgr_index");
|
||||
}
|
||||
auto const vmSeconds = vmTotal / kBenchIterations;
|
||||
|
||||
// The impl side is the same call without the VM. Subtracting it leaves the crossing.
|
||||
auto implTotal = 0.0;
|
||||
auto implSquares = 0.0;
|
||||
auto host = fixture.makeHost();
|
||||
for (auto i = 0; i < kBenchIterations; ++i)
|
||||
{
|
||||
auto const start = std::chrono::steady_clock::now();
|
||||
for (auto c = 0U; c < kCallsPerRun; ++c)
|
||||
{
|
||||
auto result = host->getLedgerSqn();
|
||||
benchmark::DoNotOptimize(result);
|
||||
}
|
||||
auto const elapsed = std::chrono::steady_clock::now() - start;
|
||||
|
||||
auto const perCall = std::chrono::duration<double>(elapsed).count() / kCallsPerRun;
|
||||
implTotal += perCall;
|
||||
implSquares += perCall * perCall;
|
||||
}
|
||||
auto const implSeconds = implTotal / kBenchIterations;
|
||||
|
||||
if (secondsPerGas <= 0.0)
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
// Take the guest's loop bookkeeping off here too: `report` removes it from every `ThroughVm`
|
||||
// number, so leaving it in would make the two routes to one price disagree by that amount.
|
||||
auto const crossing = std::max(0.0, vmSeconds - implSeconds) / secondsPerGas;
|
||||
auto const floor = std::max(0.0, crossing - std::max(0.0, guestOverheadGas));
|
||||
|
||||
// Relative to the *difference*, not to either half: both contribute their error, and the
|
||||
// denominator is what survives the subtraction. Not divided by `secondsPerGas` — that error is
|
||||
// common-mode with the rest of `suggested_gas` and is applied once, to the sum, in `report`.
|
||||
auto const vmVariance = std::max(0.0, (vmSquares / kBenchIterations) - (vmSeconds * vmSeconds));
|
||||
auto const implVariance =
|
||||
std::max(0.0, (implSquares / kBenchIterations) - (implSeconds * implSeconds));
|
||||
auto const vmStdErr = std::sqrt(vmVariance / kBenchIterations);
|
||||
auto const implStdErr = std::sqrt(implVariance / kBenchIterations);
|
||||
|
||||
auto const crossingSeconds = vmSeconds - implSeconds;
|
||||
auto const crossingStdErr = std::sqrt((vmStdErr * vmStdErr) + (implStdErr * implStdErr));
|
||||
relativeStandardError = crossingSeconds > 0.0 ? crossingStdErr / crossingSeconds : 0.0;
|
||||
|
||||
return floor;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Calibration const&
|
||||
Calibration::instance()
|
||||
{
|
||||
static Calibration const kValue;
|
||||
return kValue;
|
||||
}
|
||||
|
||||
Calibration::Calibration()
|
||||
: secondsPerGas_{measureSecondsPerGas(secondsPerGasRelStdErr_)}
|
||||
, crossingFloorGas_{measureCrossingFloorGas(secondsPerGas_, crossingFloorRelStdErr_)}
|
||||
{
|
||||
}
|
||||
|
||||
double
|
||||
declaredGas(std::string_view wasmName)
|
||||
{
|
||||
return static_cast<double>(
|
||||
rs::wasm_testkit::host_function_gas(rust::Str{wasmName.data(), wasmName.size()}));
|
||||
}
|
||||
|
||||
void
|
||||
report(
|
||||
benchmark::State& state,
|
||||
double secondsPerCall,
|
||||
double chargedGas,
|
||||
double guestOverheadGas,
|
||||
double relativeSpread,
|
||||
std::int64_t rounds,
|
||||
std::string_view wasmName,
|
||||
bool throughVm)
|
||||
{
|
||||
auto const& calibration = Calibration::instance();
|
||||
auto const perGas = calibration.secondsPerGas();
|
||||
auto const measured = perGas > 0.0 ? secondsPerCall / perGas : 0.0;
|
||||
|
||||
// The timed number covers the host call *and* whatever the guest ran around it.
|
||||
// `guestOverheadGas` is exact, so taking it off removes a bias rather than trading estimates.
|
||||
auto const implied = std::max(0.0, measured - guestOverheadGas);
|
||||
auto const suggested = throughVm ? implied : implied + calibration.crossingFloorGas();
|
||||
|
||||
state.counters["implied_gas"] = implied;
|
||||
state.counters["ns_per_call"] = secondsPerCall * 1e9;
|
||||
state.counters["charged_gas"] = chargedGas;
|
||||
|
||||
if (wasmName.empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
auto const declared = declaredGas(wasmName);
|
||||
state.counters["host_function_gas"] = declared;
|
||||
state.counters["suggested_gas"] = suggested;
|
||||
// Below 1 is the direction that matters: an underpriced call is one a contract buys too
|
||||
// cheaply.
|
||||
state.counters["price_ratio"] = suggested > 0.0 ? declared / suggested : 0.0;
|
||||
|
||||
// Uncertainty **of `suggested_gas`**, not of `implied_gas` — different numbers once the floor
|
||||
// is added. `implied` and the floor are independent timings, so their absolute errors add in
|
||||
// quadrature over the sum; but both divide by `secondsPerGas`, so that error is common-mode and
|
||||
// applies once to the total. Adding it per-term would count it twice.
|
||||
//
|
||||
// For `ThroughVm` the floor term is zero and `suggested == implied`, so this reduces exactly to
|
||||
// the plain `sqrt(caseErr² + perGasErr²)`. Only `Impl` cases move — and for a cheap one the
|
||||
// floor is most of `suggested_gas`, so an error bar describing `implied` alone described
|
||||
// little.
|
||||
auto const caseStdErr =
|
||||
rounds > 0 ? relativeSpread / std::sqrt(static_cast<double>(rounds)) : relativeSpread;
|
||||
|
||||
auto const impliedErr = implied * caseStdErr;
|
||||
auto const floorErr =
|
||||
throughVm ? 0.0 : calibration.crossingFloorGas() * calibration.crossingFloorRelStdErr();
|
||||
auto const independentErr = suggested > 0.0
|
||||
? std::sqrt((impliedErr * impliedErr) + (floorErr * floorErr)) / suggested
|
||||
: caseStdErr;
|
||||
|
||||
auto const perGasErr = calibration.secondsPerGasRelStdErr();
|
||||
auto const totalErr = std::sqrt((independentErr * independentErr) + (perGasErr * perGasErr));
|
||||
state.counters["rel_error"] = totalErr;
|
||||
|
||||
// A call whose own cost is small next to the crossing is read off the difference of two nearly
|
||||
// equal numbers, so its `suggested_gas` is scatter rather than signal.
|
||||
auto const floor = calibration.crossingFloorGas();
|
||||
state.counters["unreliable"] =
|
||||
(totalErr > kMaxRelativeSpread || (floor > 0.0 && suggested < floor)) ? 1 : 0;
|
||||
}
|
||||
|
||||
} // namespace xrpl::test::bench
|
||||
369
src/benchmarks/libxrpl/wasm/WasmBench.h
Normal file
369
src/benchmarks/libxrpl/wasm/WasmBench.h
Normal file
@@ -0,0 +1,369 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/tx/wasm/HostFunc.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
#include <xrpl/tx/wasm/WasmVM.h>
|
||||
|
||||
#include <benchmark/benchmark.h>
|
||||
#include <tx/wasm/fixtures/WasmRun.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <type_traits>
|
||||
|
||||
// The gas-calibration harness. What the numbers mean and how to read a report are in ../README.md.
|
||||
|
||||
namespace xrpl::test::bench {
|
||||
|
||||
// Enough that a benchmark loop never ends early; running out would measure something shorter.
|
||||
inline constexpr std::int64_t kBenchGas = 2'000'000'000;
|
||||
|
||||
// Host calls per run: enough that the per-call cost dominates the baseline subtraction's residue.
|
||||
inline constexpr std::int32_t kCallsPerRun = 1000;
|
||||
|
||||
// Timed iterations per case. Pinned because automatic sizing cannot work here: a case reports a
|
||||
// residue of nanoseconds while the iteration producing it ran two contracts and cost milliseconds,
|
||||
// so sizing would ask for millions.
|
||||
inline constexpr std::int32_t kBenchIterations = 50;
|
||||
|
||||
// Pairs the one-off calibration averages. Higher than `kBenchIterations` because `secondsPerGas`
|
||||
// divides every reported number, and a bare wasm loop is cheap to repeat.
|
||||
inline constexpr std::int32_t kCalibrationPairs = 400;
|
||||
|
||||
// Above this relative uncertainty, `suggested_gas` is reported as unreliable.
|
||||
inline constexpr double kMaxRelativeSpread = 0.25;
|
||||
|
||||
// `TRANSFER_LIMIT_BYTES` in crates/xrpl-wasm-vm/src/vm.rs: what a run may write into guest memory
|
||||
// before `charge_transfer` starts refusing calls.
|
||||
inline constexpr std::int64_t kTransferLimitBytes = 1 << 20;
|
||||
|
||||
// How many calls a run can afford, given the bytes each has the host **write into guest memory**.
|
||||
// Output direction only — what the guest passes in is borrowed, and costs nothing against the
|
||||
// budget. Never raises the count to meet a floor; a case that cannot afford one call fails loudly.
|
||||
int
|
||||
callsWithinTransferBudget(std::int64_t bytesWrittenPerCall);
|
||||
|
||||
// One run of a contract: how long it took, and what the engine charged it.
|
||||
struct Timing
|
||||
{
|
||||
double seconds{};
|
||||
std::int64_t gas{};
|
||||
};
|
||||
|
||||
// A `(data ...)` segment placing `bytes` at `offset` in guest memory, so the timed loop measures
|
||||
// the host call rather than the guest arranging its arguments. `watEscaped` in WasmRun.h says why
|
||||
// zeroed memory will not do.
|
||||
std::string
|
||||
dataSegment(int offset, std::span<std::uint8_t const> bytes);
|
||||
|
||||
std::string
|
||||
dataSegment(int offset, Bytes const& bytes);
|
||||
|
||||
// A contract that runs `body` `count` times and returns the last result.
|
||||
std::string
|
||||
makeLoopWat(std::string_view imports, std::string_view data, std::string_view body, int count);
|
||||
|
||||
// Run pre-assembled `wasm` once through the real VM, reporting wall time and gas.
|
||||
Timing
|
||||
timeRun(HostFunctions& host, Bytes const& wasm);
|
||||
|
||||
// What this machine costs, measured once and shared by every case: two cases pricing one function
|
||||
// two ways (`Impl` + crossing floor, versus `ThroughVm`) must divide by the *same* `secondsPerGas`
|
||||
// or they disagree for reasons unrelated to the function.
|
||||
class Calibration
|
||||
{
|
||||
public:
|
||||
static Calibration const&
|
||||
instance();
|
||||
|
||||
// Seconds of wall time one unit of gas buys here.
|
||||
[[nodiscard]] double
|
||||
secondsPerGas() const
|
||||
{
|
||||
return secondsPerGas_;
|
||||
}
|
||||
|
||||
// The gas a host call costs before it does anything: region decode, bounds checks, the cxx hop.
|
||||
[[nodiscard]] double
|
||||
crossingFloorGas() const
|
||||
{
|
||||
return crossingFloorGas_;
|
||||
}
|
||||
|
||||
// Propagated into every case's `rel_error`, which is what lets one snapshot stay shared:
|
||||
// `--benchmark_repetitions` resamples per-case timings but never this divisor.
|
||||
[[nodiscard]] double
|
||||
secondsPerGasRelStdErr() const
|
||||
{
|
||||
return secondsPerGasRelStdErr_;
|
||||
}
|
||||
|
||||
// An additive term in every `Impl` case's `suggested_gas`, and most of the cheap ones.
|
||||
[[nodiscard]] double
|
||||
crossingFloorRelStdErr() const
|
||||
{
|
||||
return crossingFloorRelStdErr_;
|
||||
}
|
||||
|
||||
private:
|
||||
Calibration();
|
||||
|
||||
double secondsPerGasRelStdErr_{};
|
||||
double crossingFloorRelStdErr_{};
|
||||
double secondsPerGas_{};
|
||||
double crossingFloorGas_{};
|
||||
};
|
||||
|
||||
// What the gas table declares for a host function, by guest import name, read through the
|
||||
// `wasm_testkit` bridge so it cannot drift.
|
||||
double
|
||||
declaredGas(std::string_view wasmName);
|
||||
|
||||
// Attach the calibration counters to a finished case.
|
||||
//
|
||||
// `guestOverheadGas` is fuel the guest burned around the call — loop bookkeeping and the
|
||||
// `i32.const`s pushing arguments. From the fuel meter, so it is exact. Zero for `Impl` cases.
|
||||
void
|
||||
report(
|
||||
benchmark::State& state,
|
||||
double secondsPerCall,
|
||||
double chargedGas,
|
||||
double guestOverheadGas,
|
||||
double relativeSpread,
|
||||
std::int64_t rounds,
|
||||
std::string_view wasmName,
|
||||
bool throughVm);
|
||||
|
||||
// Accumulates one whole-operation case and turns it into counters.
|
||||
//
|
||||
// The two harnesses below subtract setup away, amortizing over `kCallsPerRun` calls against a
|
||||
// baseline. Here that is inverted: setup is the subject, timed whole, nothing amortized.
|
||||
class StageTimer
|
||||
{
|
||||
public:
|
||||
// Pass zero for `moduleBytes` unless the case belongs to a sweep that *varies* module size —
|
||||
// the per-byte counter it enables is an average over the whole operation, meaningless where the
|
||||
// module is constant.
|
||||
StageTimer(benchmark::State& state, std::int64_t moduleBytes);
|
||||
|
||||
void
|
||||
add(double seconds);
|
||||
|
||||
// Attach the counters. Call once, after the loop.
|
||||
void
|
||||
report();
|
||||
|
||||
private:
|
||||
benchmark::State& state_;
|
||||
std::int64_t moduleBytes_{};
|
||||
double total_{};
|
||||
double sumSquares_{};
|
||||
std::int64_t rounds_{};
|
||||
};
|
||||
|
||||
// Measure `preflightEscrowWasm`: compile, then the walk over the module's imports and exports.
|
||||
//
|
||||
// `expectAccepted` is what the module *should* do. A refused module stops at the first fault and is
|
||||
// far cheaper, so a case that silently flipped verdict would report a confident number for an
|
||||
// operation it never performed.
|
||||
void
|
||||
benchmarkPreflight(
|
||||
benchmark::State& state,
|
||||
Bytes const& wasm,
|
||||
bool expectAccepted = true,
|
||||
bool sizeSweep = false);
|
||||
|
||||
// Measure a whole `runEscrowWasm` — every stage, unamortized.
|
||||
template <class SetUp>
|
||||
void
|
||||
benchmarkRun(benchmark::State& state, Bytes const& wasm, SetUp&& setUp, bool sizeSweep = false)
|
||||
{
|
||||
// Force the calibration and the engine's lazy construction before the clock starts, so the
|
||||
// first case does not absorb them into its own first iteration.
|
||||
[[maybe_unused]] auto const& calibration = Calibration::instance();
|
||||
|
||||
auto probe = setUp();
|
||||
if (auto const check = runEscrowWasm(wasm, *probe, kBenchGas); !check.has_value())
|
||||
{
|
||||
state.SkipWithError("the benchmarked contract did not run to completion");
|
||||
return;
|
||||
}
|
||||
|
||||
auto timer = StageTimer{state, sizeSweep ? static_cast<std::int64_t>(wasm.size()) : 0};
|
||||
for (auto _ : state)
|
||||
{
|
||||
auto host = setUp();
|
||||
timer.add(timeRun(*host, wasm).seconds);
|
||||
}
|
||||
timer.report();
|
||||
}
|
||||
|
||||
// Measure a host function through the whole stack — guest, VM, marshalling, real impl, real ledger
|
||||
// — with everything but the host calls subtracted away.
|
||||
template <class SetUp>
|
||||
void
|
||||
benchmarkThroughVm(
|
||||
benchmark::State& state,
|
||||
std::string_view wasmName,
|
||||
std::string_view imports,
|
||||
std::string_view data,
|
||||
std::string_view body,
|
||||
SetUp&& setUp,
|
||||
int calls = kCallsPerRun)
|
||||
{
|
||||
auto const loaded = assembleWat(makeLoopWat(imports, data, body, calls));
|
||||
auto const baseline = assembleWat(makeLoopWat(imports, data, body, 0));
|
||||
|
||||
// A host serves exactly one run — `runEscrowWasm` asserts it was handed a clean one
|
||||
// (`checkSelf` in WasmVM.cpp). Hence `setUp` being a factory rather than a host.
|
||||
auto probe = setUp();
|
||||
|
||||
// A soft host error still completes the run, so require both that it completed and that the
|
||||
// last host call returned a non-negative result, which every body leaves in `$r`.
|
||||
auto const check = runEscrowWasm(loaded, *probe, kBenchGas);
|
||||
if (!check.has_value())
|
||||
{
|
||||
state.SkipWithError("the benchmarked contract did not run to completion");
|
||||
return;
|
||||
}
|
||||
if (check->result < 0)
|
||||
{
|
||||
state.SkipWithError(
|
||||
"the benchmarked host call returned error code " + std::to_string(check->result) +
|
||||
"; the case would be measuring the rejection path, not the work");
|
||||
return;
|
||||
}
|
||||
|
||||
auto totalSeconds = 0.0;
|
||||
auto sumSquares = 0.0;
|
||||
auto totalGas = 0.0;
|
||||
auto rounds = std::int64_t{0};
|
||||
for (auto _ : state)
|
||||
{
|
||||
auto hotHost = setUp();
|
||||
auto const hot = timeRun(*hotHost, loaded);
|
||||
auto coldHost = setUp();
|
||||
auto const cold = timeRun(*coldHost, baseline);
|
||||
|
||||
// Clamped: on a noisy machine a pair can invert, and a negative iteration time would make
|
||||
// Google Benchmark's statistics meaningless.
|
||||
auto const perCall = std::max(0.0, hot.seconds - cold.seconds) / calls;
|
||||
state.SetIterationTime(perCall);
|
||||
|
||||
totalSeconds += perCall;
|
||||
sumSquares += perCall * perCall;
|
||||
totalGas += static_cast<double>(hot.gas - cold.gas) / calls;
|
||||
++rounds;
|
||||
}
|
||||
|
||||
if (rounds > 0)
|
||||
{
|
||||
auto const meanSeconds = totalSeconds / rounds;
|
||||
auto const variance = std::max(0.0, (sumSquares / rounds) - (meanSeconds * meanSeconds));
|
||||
auto const spread = meanSeconds > 0.0 ? std::sqrt(variance) / meanSeconds : 0.0;
|
||||
|
||||
// Zero for the empty-name case — `guestInstruction`, which prices no host function. There
|
||||
// is no host call to separate scaffolding *from*, and subtracting the full charge would
|
||||
// leave `implied_gas = measured - charged`, ~0 by construction: it would turn the harness's
|
||||
// one self-test into a tautology that cannot fail.
|
||||
auto const chargedPerCall = totalGas / rounds;
|
||||
auto const overhead = wasmName.empty() ? 0.0 : chargedPerCall - declaredGas(wasmName);
|
||||
|
||||
report(
|
||||
state,
|
||||
meanSeconds,
|
||||
chargedPerCall,
|
||||
std::max(0.0, overhead),
|
||||
spread,
|
||||
rounds,
|
||||
wasmName,
|
||||
true);
|
||||
}
|
||||
}
|
||||
|
||||
// Measure a host function's impl alone — no guest, no VM, no marshalling. Against the `ThroughVm`
|
||||
// case for the same function, the difference is what crossing the boundary costs.
|
||||
template <class SetUp, class Call>
|
||||
void
|
||||
benchmarkImpl(benchmark::State& state, std::string_view wasmName, SetUp&& setUp, Call&& call)
|
||||
{
|
||||
auto host = setUp();
|
||||
|
||||
// A call that errors returns early and is far cheaper than one that works, so a subtly wrong
|
||||
// argument yields a confident and always *too low* price. Probed either side of the loop rather
|
||||
// than inside it, where the branch would land in the measurement: *before* catches wrong
|
||||
// arguments, *after* catches a call that stopped working once the loop exhausted something.
|
||||
//
|
||||
// The `requires` skips host functions that answer nothing (`trace`) or answer a bare value.
|
||||
auto const checkSucceeds = [&](char const* when) {
|
||||
if constexpr (requires { call(*host).has_value(); })
|
||||
{
|
||||
if (auto const probe = call(*host); !probe.has_value())
|
||||
{
|
||||
state.SkipWithError(
|
||||
std::string{"the benchmarked host call returned error code "} +
|
||||
std::to_string(static_cast<int>(probe.error())) + " " + when +
|
||||
" the timed loop; the case would be measuring the rejection path");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!checkSucceeds("before"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
auto totalSeconds = 0.0;
|
||||
auto sumSquares = 0.0;
|
||||
auto rounds = std::int64_t{0};
|
||||
for (auto _ : state)
|
||||
{
|
||||
auto const start = std::chrono::steady_clock::now();
|
||||
for (int i = 0; i < kCallsPerRun; ++i)
|
||||
{
|
||||
// `trace` answers nothing, so `ClobberMemory` stands in for `DoNotOptimize`.
|
||||
if constexpr (std::is_void_v<decltype(call(*host))>)
|
||||
{
|
||||
call(*host);
|
||||
benchmark::ClobberMemory();
|
||||
}
|
||||
else
|
||||
{
|
||||
auto result = call(*host);
|
||||
benchmark::DoNotOptimize(result);
|
||||
}
|
||||
}
|
||||
auto const elapsed = std::chrono::steady_clock::now() - start;
|
||||
|
||||
auto const perCall = std::chrono::duration<double>(elapsed).count() / kCallsPerRun;
|
||||
state.SetIterationTime(perCall);
|
||||
|
||||
totalSeconds += perCall;
|
||||
sumSquares += perCall * perCall;
|
||||
++rounds;
|
||||
}
|
||||
|
||||
if (!checkSucceeds("after"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (rounds > 0)
|
||||
{
|
||||
auto const meanSeconds = totalSeconds / rounds;
|
||||
auto const variance = std::max(0.0, (sumSquares / rounds) - (meanSeconds * meanSeconds));
|
||||
auto const spread = meanSeconds > 0.0 ? std::sqrt(variance) / meanSeconds : 0.0;
|
||||
|
||||
// Nothing charged and no guest scaffolding; `report` adds the crossing back in.
|
||||
report(state, meanSeconds, 0.0, 0.0, spread, rounds, wasmName, false);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace xrpl::test::bench
|
||||
24
src/benchmarks/libxrpl/wasm/host_functions/AccountKeylet.cpp
Normal file
24
src/benchmarks/libxrpl/wasm/host_functions/AccountKeylet.cpp
Normal file
@@ -0,0 +1,24 @@
|
||||
#include <benchmark/benchmark.h>
|
||||
#include <benchmarks/libxrpl/wasm/BenchFixtures.h>
|
||||
#include <benchmarks/libxrpl/wasm/WasmBench.h>
|
||||
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test::bench {
|
||||
namespace {
|
||||
|
||||
void
|
||||
accountKeyletImpl(benchmark::State& state)
|
||||
{
|
||||
static constexpr auto kWasmName = std::string_view{"accountroot_id"};
|
||||
|
||||
benchmarkImpl(
|
||||
state,
|
||||
kWasmName,
|
||||
[] { return Fixtures::instance().host(); },
|
||||
[](auto& host) { return host.accountKeylet(Fixtures::instance().alice().id()); });
|
||||
}
|
||||
BENCHMARK(accountKeyletImpl)->UseManualTime()->Iterations(kBenchIterations);
|
||||
|
||||
} // namespace
|
||||
} // namespace xrpl::test::bench
|
||||
31
src/benchmarks/libxrpl/wasm/host_functions/AmmKeylet.cpp
Normal file
31
src/benchmarks/libxrpl/wasm/host_functions/AmmKeylet.cpp
Normal file
@@ -0,0 +1,31 @@
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/Issue.h>
|
||||
#include <xrpl/protocol/UintTypes.h>
|
||||
|
||||
#include <benchmark/benchmark.h>
|
||||
#include <benchmarks/libxrpl/wasm/BenchFixtures.h>
|
||||
#include <benchmarks/libxrpl/wasm/WasmBench.h>
|
||||
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test::bench {
|
||||
namespace {
|
||||
|
||||
void
|
||||
ammKeyletImpl(benchmark::State& state)
|
||||
{
|
||||
static constexpr auto kWasmName = std::string_view{"amm_id"};
|
||||
|
||||
auto const usd = Asset{Issue{toCurrency("USD"), Fixtures::instance().alice().id()}};
|
||||
auto const xrp = Asset{xrpIssue()};
|
||||
|
||||
benchmarkImpl(
|
||||
state,
|
||||
kWasmName,
|
||||
[] { return Fixtures::instance().host(); },
|
||||
[&usd, &xrp](auto& host) { return host.ammKeylet(usd, xrp); });
|
||||
}
|
||||
BENCHMARK(ammKeyletImpl)->UseManualTime()->Iterations(kBenchIterations);
|
||||
|
||||
} // namespace
|
||||
} // namespace xrpl::test::bench
|
||||
24
src/benchmarks/libxrpl/wasm/host_functions/BaseFee.cpp
Normal file
24
src/benchmarks/libxrpl/wasm/host_functions/BaseFee.cpp
Normal file
@@ -0,0 +1,24 @@
|
||||
#include <benchmark/benchmark.h>
|
||||
#include <benchmarks/libxrpl/wasm/BenchFixtures.h>
|
||||
#include <benchmarks/libxrpl/wasm/WasmBench.h>
|
||||
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test::bench {
|
||||
namespace {
|
||||
|
||||
void
|
||||
baseFeeImpl(benchmark::State& state)
|
||||
{
|
||||
static constexpr auto kWasmName = std::string_view{"base_fee"};
|
||||
|
||||
benchmarkImpl(
|
||||
state,
|
||||
kWasmName,
|
||||
[] { return Fixtures::instance().host(); },
|
||||
[](auto& host) { return host.getBaseFee(); });
|
||||
}
|
||||
BENCHMARK(baseFeeImpl)->UseManualTime()->Iterations(kBenchIterations);
|
||||
|
||||
} // namespace
|
||||
} // namespace xrpl::test::bench
|
||||
@@ -0,0 +1,28 @@
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
|
||||
#include <benchmark/benchmark.h>
|
||||
#include <benchmarks/libxrpl/wasm/BenchFixtures.h>
|
||||
#include <benchmarks/libxrpl/wasm/WasmBench.h>
|
||||
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test::bench {
|
||||
namespace {
|
||||
|
||||
void
|
||||
cacheLedgerObjImpl(benchmark::State& state)
|
||||
{
|
||||
static constexpr auto kWasmName = std::string_view{"cache_le"};
|
||||
|
||||
auto const key = keylet::account(Fixtures::instance().alice().id()).key;
|
||||
|
||||
benchmarkImpl(
|
||||
state,
|
||||
kWasmName,
|
||||
[] { return Fixtures::instance().host(); },
|
||||
[&key](auto& host) { return host.cacheLedgerObj(key, 1); });
|
||||
}
|
||||
BENCHMARK(cacheLedgerObjImpl)->UseManualTime()->Iterations(kBenchIterations);
|
||||
|
||||
} // namespace
|
||||
} // namespace xrpl::test::bench
|
||||
26
src/benchmarks/libxrpl/wasm/host_functions/CheckKeylet.cpp
Normal file
26
src/benchmarks/libxrpl/wasm/host_functions/CheckKeylet.cpp
Normal file
@@ -0,0 +1,26 @@
|
||||
#include <benchmark/benchmark.h>
|
||||
#include <benchmarks/libxrpl/wasm/BenchFixtures.h>
|
||||
#include <benchmarks/libxrpl/wasm/WasmBench.h>
|
||||
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test::bench {
|
||||
namespace {
|
||||
|
||||
void
|
||||
checkKeyletImpl(benchmark::State& state)
|
||||
{
|
||||
static constexpr auto kWasmName = std::string_view{"check_id"};
|
||||
|
||||
benchmarkImpl(
|
||||
state,
|
||||
kWasmName,
|
||||
[] { return Fixtures::instance().host(); },
|
||||
[](auto& host) {
|
||||
return host.checkKeylet(Fixtures::instance().alice().id(), Fixtures::kSeq);
|
||||
});
|
||||
}
|
||||
BENCHMARK(checkKeyletImpl)->UseManualTime()->Iterations(kBenchIterations);
|
||||
|
||||
} // namespace
|
||||
} // namespace xrpl::test::bench
|
||||
@@ -0,0 +1,63 @@
|
||||
#include <xrpl/basics/Slice.h>
|
||||
|
||||
#include <benchmark/benchmark.h>
|
||||
#include <benchmarks/libxrpl/wasm/BenchFixtures.h>
|
||||
#include <benchmarks/libxrpl/wasm/WasmBench.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <format>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test::bench {
|
||||
namespace {
|
||||
|
||||
constexpr std::string_view kWasmName = "check_sig";
|
||||
|
||||
constexpr std::string_view kImport =
|
||||
R"( (import "host_lib" "check_sig" (func $check_sig (param i32 i32 i32 i32 i32 i32) (result i32)))
|
||||
)";
|
||||
|
||||
constexpr std::int32_t kMessageOffset = 0;
|
||||
constexpr std::int32_t kSignatureOffset = 256;
|
||||
constexpr std::int32_t kPubkeyOffset = 512;
|
||||
|
||||
void
|
||||
checkSignatureThroughVm(benchmark::State& state)
|
||||
{
|
||||
auto const& m = Fixtures::instance().signedMessage();
|
||||
static auto const kData = dataSegment(kMessageOffset, m.message) +
|
||||
dataSegment(kSignatureOffset, m.signature) + dataSegment(kPubkeyOffset, m.publicKey);
|
||||
static auto const kBody = std::format(
|
||||
"(call $check_sig (i32.const {}) (i32.const {}) (i32.const {}) (i32.const {}) "
|
||||
"(i32.const {}) (i32.const {}))",
|
||||
kMessageOffset,
|
||||
m.message.size(),
|
||||
kSignatureOffset,
|
||||
m.signature.size(),
|
||||
kPubkeyOffset,
|
||||
m.publicKey.size());
|
||||
|
||||
benchmarkThroughVm(
|
||||
state, kWasmName, kImport, kData, kBody, [] { return Fixtures::instance().host(); });
|
||||
}
|
||||
BENCHMARK(checkSignatureThroughVm)->UseManualTime()->Iterations(kBenchIterations);
|
||||
|
||||
void
|
||||
checkSignatureImpl(benchmark::State& state)
|
||||
{
|
||||
benchmarkImpl(
|
||||
state,
|
||||
kWasmName,
|
||||
[] { return Fixtures::instance().host(); },
|
||||
[](auto& host) {
|
||||
auto const& m = Fixtures::instance().signedMessage();
|
||||
return host.checkSignature(
|
||||
Slice{m.message.data(), m.message.size()},
|
||||
Slice{m.signature.data(), m.signature.size()},
|
||||
Slice{m.publicKey.data(), m.publicKey.size()});
|
||||
});
|
||||
}
|
||||
BENCHMARK(checkSignatureImpl)->UseManualTime()->Iterations(kBenchIterations);
|
||||
|
||||
} // namespace
|
||||
} // namespace xrpl::test::bench
|
||||
@@ -0,0 +1,32 @@
|
||||
#include <xrpl/basics/Slice.h>
|
||||
|
||||
#include <benchmark/benchmark.h>
|
||||
#include <benchmarks/libxrpl/wasm/BenchFixtures.h>
|
||||
#include <benchmarks/libxrpl/wasm/WasmBench.h>
|
||||
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test::bench {
|
||||
namespace {
|
||||
|
||||
void
|
||||
credentialKeyletImpl(benchmark::State& state)
|
||||
{
|
||||
static constexpr auto kWasmName = std::string_view{"credential_id"};
|
||||
static constexpr auto kType = std::string_view{"termsandconditions"};
|
||||
|
||||
benchmarkImpl(
|
||||
state,
|
||||
kWasmName,
|
||||
[] { return Fixtures::instance().host(); },
|
||||
[](auto& host) {
|
||||
return host.credentialKeylet(
|
||||
Fixtures::instance().alice().id(),
|
||||
Fixtures::instance().bob().id(),
|
||||
Slice{kType.data(), kType.size()});
|
||||
});
|
||||
}
|
||||
BENCHMARK(credentialKeyletImpl)->UseManualTime()->Iterations(kBenchIterations);
|
||||
|
||||
} // namespace
|
||||
} // namespace xrpl::test::bench
|
||||
@@ -0,0 +1,26 @@
|
||||
#include <xrpl/protocol/SField.h>
|
||||
|
||||
#include <benchmark/benchmark.h>
|
||||
#include <benchmarks/libxrpl/wasm/BenchFixtures.h>
|
||||
#include <benchmarks/libxrpl/wasm/WasmBench.h>
|
||||
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test::bench {
|
||||
namespace {
|
||||
|
||||
void
|
||||
currentLedgerObjArrayLenImpl(benchmark::State& state)
|
||||
{
|
||||
static constexpr auto kWasmName = std::string_view{"home_le_arr_len"};
|
||||
|
||||
benchmarkImpl(
|
||||
state,
|
||||
kWasmName,
|
||||
[] { return Fixtures::instance().signerListHost(); },
|
||||
[](auto& host) { return host.getCurrentLedgerObjArrayLen(sfSignerEntries); });
|
||||
}
|
||||
BENCHMARK(currentLedgerObjArrayLenImpl)->UseManualTime()->Iterations(kBenchIterations);
|
||||
|
||||
} // namespace
|
||||
} // namespace xrpl::test::bench
|
||||
@@ -0,0 +1,41 @@
|
||||
#include <xrpl/protocol/SField.h>
|
||||
|
||||
#include <benchmark/benchmark.h>
|
||||
#include <benchmarks/libxrpl/wasm/BenchFixtures.h>
|
||||
#include <benchmarks/libxrpl/wasm/WasmBench.h>
|
||||
|
||||
#include <format>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test::bench {
|
||||
namespace {
|
||||
|
||||
constexpr std::string_view kWasmName = "home_le_field";
|
||||
constexpr std::string_view kImport =
|
||||
R"( (import "host_lib" "home_le_field" (func $home_le_field (param i32 i32 i32) (result i32)))
|
||||
)";
|
||||
|
||||
void
|
||||
currentLedgerObjFieldThroughVm(benchmark::State& state)
|
||||
{
|
||||
static auto const kBody = std::format(
|
||||
"(call $home_le_field (i32.const {}) (i32.const 0) (i32.const 32))", sfAccount.getCode());
|
||||
|
||||
benchmarkThroughVm(
|
||||
state, kWasmName, kImport, "", kBody, [] { return Fixtures::instance().escrowHost(); });
|
||||
}
|
||||
BENCHMARK(currentLedgerObjFieldThroughVm)->UseManualTime()->Iterations(kBenchIterations);
|
||||
|
||||
void
|
||||
currentLedgerObjFieldImpl(benchmark::State& state)
|
||||
{
|
||||
benchmarkImpl(
|
||||
state,
|
||||
kWasmName,
|
||||
[] { return Fixtures::instance().escrowHost(); },
|
||||
[](auto& host) { return host.getCurrentLedgerObjField(sfAccount); });
|
||||
}
|
||||
BENCHMARK(currentLedgerObjFieldImpl)->UseManualTime()->Iterations(kBenchIterations);
|
||||
|
||||
} // namespace
|
||||
} // namespace xrpl::test::bench
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user