Skip to content

Commit b314aa5

Browse files
JamesPiechotaclaude
andcommitted
test: cover per-subtree bundle state across the message@1.0 APIs
Add dev_message_bundle_test_vectors: a battery exercising the `bundle'/`hint-device' machinery over a three-level signed message tree. For every 3x3x3 permutation of per-level bundle flags (true / false / none) it checks verify/3, id/3 and convert/4 -- the per-node path always verifies, a forced request bundle is harmless, and a converted tree's load state matches what was requested. Also update the dev_codec_ans104 / dev_codec_tx `test_bundle_*' cases and dev_bundler_task tests for the `bundle'-on-target-spec convention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 04ce8df commit b314aa5

4 files changed

Lines changed: 278 additions & 25 deletions

File tree

src/dev_bundler_task.erl

Lines changed: 81 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -283,14 +283,14 @@ build_signed_tx_on_arbundles_js_test() ->
283283
?assert(ar_bundles:verify_item(BundledItem)),
284284
% Convert both dataitems to structured messages
285285
ItemStructured = hb_message:convert(Item,
286-
#{ <<"device">> => <<"structured@1.0">>, <<"bundle">> => true },
287-
#{ <<"device">> => <<"ans104@1.0">>, <<"bundle">> => true },
286+
<<"structured@1.0">>,
287+
<<"ans104@1.0">>,
288288
TestOpts),
289289
?event(debug_test, {item_structured, ItemStructured}),
290290
?assert(hb_message:verify(ItemStructured, all, TestOpts)),
291291
BundledItemStructured = hb_message:convert(BundledItem,
292-
#{ <<"device">> => <<"structured@1.0">>, <<"bundle">> => true },
293-
#{ <<"device">> => <<"ans104@1.0">>, <<"bundle">> => true },
292+
<<"structured@1.0">>,
293+
<<"ans104@1.0">>,
294294
TestOpts),
295295
?event(debug_test, {bundled_item_structured, BundledItemStructured}),
296296
?assert(hb_message:verify(BundledItemStructured, all, TestOpts)),
@@ -303,15 +303,15 @@ build_signed_tx_on_arbundles_js_test() ->
303303
?assert(ar_tx:verify(SignedTX)),
304304
% Convert the signed TX to a structured message
305305
StructuredTX = hb_message:convert(SignedTX,
306-
#{ <<"device">> => <<"structured@1.0">>, <<"bundle">> => true },
307-
#{ <<"device">> => <<"tx@1.0">>, <<"bundle">> => true },
306+
<<"structured@1.0">>,
307+
<<"tx@1.0">>,
308308
TestOpts),
309309
% ?event(debug_test, {structured_tx, StructuredTX}),
310310
?assert(hb_message:verify(StructuredTX, all, TestOpts)),
311311
% Convert back to an L1 TX
312312
SignedTXRoundtrip = hb_message:convert(StructuredTX,
313-
#{ <<"device">> => <<"tx@1.0">>, <<"bundle">> => true },
314-
#{ <<"device">> => <<"structured@1.0">>, <<"bundle">> => true },
313+
<<"tx@1.0">>,
314+
#{ <<"device">> => <<"structured@1.0">>, <<"hint-device">> => <<"tx@1.0">> },
315315
TestOpts),
316316
?event(debug_test, {signed_tx_roundtrip, SignedTXRoundtrip}),
317317
?assert(ar_tx:verify(SignedTXRoundtrip)),
@@ -408,6 +408,79 @@ bundle_convert_minimal_test() ->
408408
hb_mock_server:stop(ServerHandle)
409409
end.
410410

411+
%% @doc Drive a nested tree of items signed in mixed bundle states through
412+
%% the bundler flow: each child is signed with bundle=true OR bundle=false,
413+
%% then we build the bundle TX, sign it, convert through structured@1.0 and
414+
%% back to tx@1.0, and assert nothing was inflated and every commitment
415+
%% still verifies. This exercises the full `hint-device' plumbing across a
416+
%% mixed tree, mirroring the production scenario that motivated the fix.
417+
bundle_convert_mixed_tree_verify_test() ->
418+
Anchor = rand:bytes(32),
419+
Price = 12345,
420+
{ServerHandle, NodeOpts} = hb_mock_server:start_arweave_gateway(#{
421+
price => {200, integer_to_binary(Price)},
422+
tx_anchor => {200, hb_util:encode(Anchor)}
423+
}),
424+
TestOpts = NodeOpts#{
425+
<<"priv-wallet">> => ar_wallet:new(),
426+
<<"store">> => hb_test_utils:test_store()
427+
},
428+
try
429+
%% Build three items. The first carries a child signed bundle=false,
430+
%% the second a child signed bundle=true, the third has no nested
431+
%% child at all. The L1 bundle TX therefore contains items that
432+
%% would individually each round-trip with a different bundle state.
433+
InnerFalse = hb_message:commit(
434+
#{ <<"leaf-tag">> => <<"leaf-false">>,
435+
<<"leaf-list">> => [1, 2, 3] },
436+
TestOpts,
437+
#{ <<"device">> => <<"ans104@1.0">>, <<"bundle">> => false }),
438+
?assert(hb_message:verify(InnerFalse, all, TestOpts)),
439+
InnerTrue = hb_message:commit(
440+
#{ <<"leaf-tag">> => <<"leaf-true">>,
441+
<<"leaf-list">> => [4, 5, 6] },
442+
TestOpts,
443+
#{ <<"device">> => <<"ans104@1.0">>, <<"bundle">> => true }),
444+
?assert(hb_message:verify(InnerTrue, all, TestOpts)),
445+
ItemA = hb_message:commit(
446+
#{ <<"item-tag">> => <<"a">>, <<"inner">> => InnerFalse },
447+
TestOpts,
448+
#{ <<"device">> => <<"ans104@1.0">>, <<"bundle">> => true }),
449+
?assert(hb_message:verify(ItemA, all, TestOpts)),
450+
ItemB = hb_message:commit(
451+
#{ <<"item-tag">> => <<"b">>, <<"inner">> => InnerTrue },
452+
TestOpts,
453+
#{ <<"device">> => <<"ans104@1.0">>, <<"bundle">> => false }),
454+
?assert(hb_message:verify(ItemB, all, TestOpts)),
455+
ItemC = hb_message:commit(
456+
#{ <<"item-tag">> => <<"c">> },
457+
TestOpts,
458+
#{ <<"device">> => <<"ans104@1.0">>, <<"bundle">> => false }),
459+
?assert(hb_message:verify(ItemC, all, TestOpts)),
460+
{ok, SignedTX} = build_signed_tx([ItemA, ItemB, ItemC], TestOpts),
461+
?assert(ar_tx:verify(SignedTX)),
462+
Committed = hb_message:convert(
463+
SignedTX, <<"structured@1.0">>, <<"tx@1.0">>, TestOpts),
464+
?event(debug_test, {committed, {explicit, Committed}}),
465+
?assert(hb_message:verify(Committed, all, TestOpts)),
466+
%% Convert back to TX (same path build_proofs uses) and check that
467+
%% the data did not inflate.
468+
TX = hb_message:convert(
469+
Committed, <<"tx@1.0">>, <<"structured@1.0">>, TestOpts),
470+
?assert(ar_tx:verify(TX)),
471+
SignedSize = byte_size(SignedTX#tx.data),
472+
RecoveredSize = byte_size(TX#tx.data),
473+
Delta = RecoveredSize - SignedSize,
474+
?assertEqual(0, Delta, {
475+
inflation_detected_on_mixed_tree,
476+
#{signed_size => SignedSize,
477+
recovered_size => RecoveredSize,
478+
delta_bytes => Delta}
479+
})
480+
after
481+
hb_mock_server:stop(ServerHandle)
482+
end.
483+
411484
%% Hardcoded item, structurally identical to one observed in a broken
412485
%% production bundle (TXID -BTiilFCWd2kB3oOdCpPDJLGXhjeNxIeMH3kerPXKCM).
413486
%% AO "Assignment" message with `body`, two commitments (HMAC + RSA-PSS),

src/dev_codec_ans104.erl

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -816,17 +816,18 @@ test_bundle_commitment(Commit, Encode, Decode) ->
816816
hb_util:atom(hb_ao:get(<<"bundle">>, CommittedCommitment, false, Opts)),
817817
Label),
818818

819-
Encoded = hb_message:convert(Committed,
819+
Encoded = hb_message:convert(Committed,
820820
#{ <<"device">> => <<"ans104@1.0">>, <<"bundle">> => ToBool(Encode) },
821-
<<"structured@1.0">>, Opts),
821+
<<"structured@1.0">>,
822+
Opts),
822823
?event(debug_test, {encoded, Label, {explicit, Encoded}}),
823824
?assert(ar_bundles:verify_item(Encoded), Label),
824825
%% IF the input message is unbundled, #tx.data should be empty.
825826
?assertEqual(ToBool(Commit), Encoded#tx.data /= <<>>, Label),
826827

827-
Decoded = hb_message:convert(Encoded,
828+
Decoded = hb_message:convert(Encoded,
828829
#{ <<"device">> => <<"structured@1.0">>, <<"bundle">> => ToBool(Decode) },
829-
#{ <<"device">> => <<"ans104@1.0">>, <<"bundle">> => ToBool(Encode) },
830+
<<"ans104@1.0">>,
830831
Opts),
831832
?event(debug_test, {decoded, Label, {explicit, Decoded}}),
832833
?assert(hb_message:verify(Decoded, all, Opts), Label),
@@ -859,16 +860,17 @@ test_bundle_uncommitted(Encode, Decode) ->
859860
ToBool = fun(unbundled) -> false; (bundled) -> true end,
860861
Label = lists:flatten(io_lib:format("~p -> ~p", [Encode, Decode])),
861862

862-
Encoded = hb_message:convert(Structured,
863+
Encoded = hb_message:convert(Structured,
863864
#{ <<"device">> => <<"ans104@1.0">>, <<"bundle">> => ToBool(Encode) },
864-
<<"structured@1.0">>, Opts),
865+
<<"structured@1.0">>,
866+
Opts),
865867
?event(debug_test, {encoded, Label, {explicit, Encoded}}),
866868
%% IF the input message is unbundled, #tx.data should be empty.
867869
?assertEqual(ToBool(Encode), Encoded#tx.data /= <<>>, Label),
868870

869-
Decoded = hb_message:convert(Encoded,
871+
Decoded = hb_message:convert(Encoded,
870872
#{ <<"device">> => <<"structured@1.0">>, <<"bundle">> => ToBool(Decode) },
871-
#{ <<"device">> => <<"ans104@1.0">>, <<"bundle">> => ToBool(Encode) },
873+
<<"ans104@1.0">>,
872874
Opts),
873875
?event(debug_test, {decoded, Label, {explicit, Decoded}}),
874876
case Encode of
@@ -878,3 +880,4 @@ test_bundle_uncommitted(Encode, Decode) ->
878880
?assertEqual([1, 2, 3], maps:get(<<"list">>, Decoded, Opts), Label)
879881
end,
880882
ok.
883+

src/dev_codec_tx.erl

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1440,17 +1440,18 @@ test_bundle_commitment(Commit, Encode, Decode) ->
14401440
hb_util:atom(hb_ao:get(<<"bundle">>, CommittedCommitment, false, Opts)),
14411441
Label),
14421442

1443-
Encoded = hb_message:convert(Committed,
1443+
Encoded = hb_message:convert(Committed,
14441444
#{ <<"device">> => <<"tx@1.0">>, <<"bundle">> => ToBool(Encode) },
1445-
<<"structured@1.0">>, Opts),
1445+
<<"structured@1.0">>,
1446+
Opts),
14461447
?event(debug_test, {encoded, Label, {explicit, Encoded}}),
14471448
?assert(ar_tx:verify(Encoded), Label),
14481449
%% IF the input message is unbundled, #tx.data should be empty.
14491450
?assertEqual(ToBool(Commit), Encoded#tx.data /= <<>>, Label),
14501451

1451-
Decoded = hb_message:convert(Encoded,
1452+
Decoded = hb_message:convert(Encoded,
14521453
#{ <<"device">> => <<"structured@1.0">>, <<"bundle">> => ToBool(Decode) },
1453-
#{ <<"device">> => <<"tx@1.0">>, <<"bundle">> => ToBool(Encode) },
1454+
<<"tx@1.0">>,
14541455
Opts),
14551456
?event(debug_test, {decoded, Label, {explicit, Decoded}}),
14561457
?assert(hb_message:verify(Decoded, all, Opts), Label),
@@ -1482,16 +1483,17 @@ test_bundle_uncommitted(Encode, Decode) ->
14821483
ToBool = fun(unbundled) -> false; (bundled) -> true end,
14831484
Label = lists:flatten(io_lib:format("~p -> ~p", [Encode, Decode])),
14841485

1485-
Encoded = hb_message:convert(Structured,
1486+
Encoded = hb_message:convert(Structured,
14861487
#{ <<"device">> => <<"tx@1.0">>, <<"bundle">> => ToBool(Encode) },
1487-
<<"structured@1.0">>, Opts),
1488+
<<"structured@1.0">>,
1489+
Opts),
14881490
?event(debug_test, {encoded, Label, {explicit, Encoded}}),
1489-
%% IF the input message is unbundled, #tx.data should be empty.
1491+
%% If the input message is unbundled, #tx.data should be empty.
14901492
?assertEqual(ToBool(Encode), Encoded#tx.data /= <<>>, Label),
14911493

1492-
Decoded = hb_message:convert(Encoded,
1494+
Decoded = hb_message:convert(Encoded,
14931495
#{ <<"device">> => <<"structured@1.0">>, <<"bundle">> => ToBool(Decode) },
1494-
#{ <<"device">> => <<"tx@1.0">>, <<"bundle">> => ToBool(Encode) },
1496+
<<"tx@1.0">>,
14951497
Opts),
14961498
?event(debug_test, {decoded, Label, {explicit, Decoded}}),
14971499
case Encode of
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
%%% @doc A battery of test vectors exercising the `bundle' / `hint-device'
2+
%%% machinery of the `message@1.0' device across a three-level message tree.
3+
%%%
4+
%%% The tree is built bottom-up; each level is a committed (signed) message
5+
%%% holding the level below it as a sub-message:
6+
%%%
7+
%%% <pre>
8+
%%% L1 (root) -- l2 --> L2 (middle) -- l3 --> L3 (leaf) -- inner --> #{}
9+
%%% </pre>
10+
%%%
11+
%%% Each level is committed with its own `bundle' choice -- `true', `false'
12+
%%% or `none' (committed with no `bundle' flag at all). The flag decides
13+
%%% whether that level's sub-message is held inline (loaded) or as a link
14+
%%% (offloaded) in the level's signed TABM form:
15+
%%%
16+
%%% - L1's flag controls `l2', L2's flag controls `l3', and L3's flag
17+
%%% controls L3's plain sub-map `inner'.
18+
%%%
19+
%%% `none' is observably identical to `false': committing with no flag
20+
%%% offloads children exactly as `false' does.
21+
%%%
22+
%%% For every 3x3x3 permutation of build flags the suite checks:
23+
%%%
24+
%%% - verify/3 with no forced bundle: the reliable path -- every level
25+
%%% verifies in the state it was committed in.
26+
%%% - verify/3 with a forced bundle (`true'|`false'): the edge case -- a
27+
%%% `bundle' on the verify request is harmless. verify builds its
28+
%%% source spec like commit/3 (mirroring the request `bundle' but also
29+
%%% setting `hint-device'), so the per-node hints override the forced
30+
%%% value and the tree still verifies. Tested for completeness.
31+
%%% - id/3: the root's id equals its sole commitment's key.
32+
%%% - convert/4: the tree round-trips through the `ans104@1.0' codec --
33+
%%% the standard structured<->codec path -- and still verifies at every
34+
%%% level. A `bundle' on the request is per-node-overridden, so the
35+
%%% committed shape survives the round-trip.
36+
-module(dev_message_bundle_test_vectors).
37+
-include_lib("eunit/include/eunit.hrl").
38+
-include("include/hb.hrl").
39+
40+
%% @doc Fresh, isolated options for a single vector: a new wallet and a new
41+
%% in-memory store, so vectors cannot interfere with one another.
42+
fresh_opts() ->
43+
#{
44+
<<"priv-wallet">> => hb:wallet(),
45+
<<"store">> => hb_test_utils:test_store()
46+
}.
47+
48+
%% @doc Commit a message with the `ans104@1.0' codec. `Bundle' is `true',
49+
%% `false', or `none' to commit with no `bundle' flag at all.
50+
commit(Msg, none, Opts) ->
51+
hb_message:commit(Msg, Opts, #{ <<"device">> => <<"ans104@1.0">> });
52+
commit(Msg, Bundle, Opts) ->
53+
hb_message:commit(
54+
Msg,
55+
Opts,
56+
#{ <<"device">> => <<"ans104@1.0">>, <<"bundle">> => Bundle }
57+
).
58+
59+
%% @doc Build a signed three-level tree with the given per-level flags.
60+
build_tree(B1, B2, B3, Opts) ->
61+
L3 =
62+
commit(
63+
#{
64+
<<"l3-tag">> => <<"l3-value">>,
65+
<<"inner">> => #{ <<"deep">> => <<"deep-value">> }
66+
},
67+
B3,
68+
Opts
69+
),
70+
L2 = commit(#{ <<"l2-tag">> => <<"l2-value">>, <<"l3">> => L3 }, B2, Opts),
71+
commit(#{ <<"l1-tag">> => <<"l1-value">>, <<"l2">> => L2 }, B1, Opts).
72+
73+
%%% Test vector generator.
74+
75+
%% @doc The {API, RequestBundle} operations run against every tree shape.
76+
operations() ->
77+
[
78+
{verify, none},
79+
{verify, true},
80+
{verify, false},
81+
{id, none},
82+
{convert, none},
83+
{convert, true},
84+
{convert, false}
85+
].
86+
87+
%% @doc Generate the full grid: 3x3x3 tree shapes x the operation list.
88+
bundle_vectors_test_() ->
89+
{timeout, 240,
90+
[
91+
{
92+
test_label(B1, B2, B3, Api, ReqBundle),
93+
fun() -> run(B1, B2, B3, Api, ReqBundle) end
94+
}
95+
||
96+
B1 <- [true, false, none],
97+
B2 <- [true, false, none],
98+
B3 <- [true, false, none],
99+
{Api, ReqBundle} <- operations()
100+
]
101+
}.
102+
103+
test_label(B1, B2, B3, Api, ReqBundle) ->
104+
lists:flatten(
105+
io_lib:format(
106+
"L1=~p L2=~p L3=~p ~p req-bundle=~p",
107+
[B1, B2, B3, Api, ReqBundle]
108+
)
109+
).
110+
111+
%% @doc Build the tree and exercise the chosen API.
112+
run(B1, B2, B3, Api, ReqBundle) ->
113+
Opts = fresh_opts(),
114+
Tree = build_tree(B1, B2, B3, Opts),
115+
% Every freshly built tree must verify via the reliable per-node path,
116+
% whatever per-level bundle permutation it was signed with.
117+
?assert(hb_message:verify(Tree, all, Opts)),
118+
exercise(Api, ReqBundle, B1, B2, B3, Tree, Opts).
119+
120+
%%% Per-API exercises.
121+
122+
%% `verify': verification always uses the per-node path -- each subtree is
123+
%% checked in the bundle state it was committed in. A `bundle' on the
124+
%% request is mirrored as commit/3 does, but `hint-device' is set too, so
125+
%% the per-node hints override it. A validly-built tree therefore always
126+
%% verifies at every level, with or without a forced request bundle.
127+
exercise(verify, ReqBundle, _B1, _B2, _B3, Tree, Opts) ->
128+
Spec = verify_spec(ReqBundle),
129+
?assert(hb_message:verify(Tree, Spec, Opts)),
130+
L2 = hb_maps:get(<<"l2">>, Tree, undefined, Opts),
131+
?assert(hb_message:verify(L2, Spec, Opts)),
132+
L3 = hb_maps:get(<<"l3">>, L2, undefined, Opts),
133+
?assert(hb_message:verify(L3, Spec, Opts));
134+
135+
%% `id': the root was committed exactly once, so `id/3' with `all'
136+
%% committers accumulates to that single commitment -- the id must equal
137+
%% the key under which it is stored in the root's commitments map.
138+
exercise(id, _ReqBundle, _B1, _B2, _B3, Tree, Opts) ->
139+
Id = hb_message:id(Tree, all, Opts),
140+
Commitments = hb_maps:get(<<"commitments">>, Tree, #{}, Opts),
141+
?assertEqual([Id], maps:keys(Commitments));
142+
143+
%% `convert': round-trip the tree through the `ans104@1.0' codec -- the
144+
%% standard structured<->codec path. Each subtree converts in the state its
145+
%% own commitment dictates (per-node), so a `bundle' flag on the request is
146+
%% overridden and the committed shape is preserved. The round-tripped tree
147+
%% must therefore still verify at every level.
148+
exercise(convert, ReqBundle, _B1, _B2, _B3, Tree, Opts) ->
149+
Encoded = hb_message:convert(Tree, convert_target(ReqBundle), Opts),
150+
Restored =
151+
hb_message:convert(
152+
Encoded,
153+
<<"structured@1.0">>,
154+
<<"ans104@1.0">>,
155+
Opts
156+
),
157+
?assert(hb_message:verify(Restored, all, Opts)),
158+
L2 = hb_maps:get(<<"l2">>, Restored, undefined, Opts),
159+
?assert(hb_message:verify(L2, all, Opts)),
160+
L3 = hb_maps:get(<<"l3">>, L2, undefined, Opts),
161+
?assert(hb_message:verify(L3, all, Opts)).
162+
163+
%% @doc The verify spec for a request-bundle value: `all' committers, plus
164+
%% the forced `bundle' flag when one is given.
165+
verify_spec(none) ->
166+
all;
167+
verify_spec(ReqBundle) ->
168+
#{ <<"committers">> => <<"all">>, <<"bundle">> => ReqBundle }.
169+
170+
%% @doc The convert target for a request-bundle value: the bare `ans104@1.0'
171+
%% codec, plus a forced `bundle' flag when one is given.
172+
convert_target(none) ->
173+
<<"ans104@1.0">>;
174+
convert_target(ReqBundle) ->
175+
#{ <<"device">> => <<"ans104@1.0">>, <<"bundle">> => ReqBundle }.

0 commit comments

Comments
 (0)