diff --git a/src/preloaded/process/dev_arweave_scheduler.erl b/src/preloaded/process/dev_arweave_scheduler.erl
index 9c9c9c600..0b5aae8e6 100644
--- a/src/preloaded/process/dev_arweave_scheduler.erl
+++ b/src/preloaded/process/dev_arweave_scheduler.erl
@@ -12,10 +12,10 @@
%%% process ID, and the process message occupies slot 0 of its own
%%% schedule.
%%%
Every L1 transaction whose `target' field is the process ID is a
-%%% message in the process's schedule. Slots follow the canonical weave
-%%% order (ascending weave offset, which is ascending block order). The
-%%% transaction's weave `offset' -- not a scheduler-assigned nonce -- is
-%%% its on-chain position, and it is recorded on the assignment.
+%%% message in the process's schedule. Slots follow the transaction's
+%%% weave `offset' as their primary on-chain position, and equal offsets
+%%% are ordered by the block's transaction list. The offset -- not a
+%%% scheduler-assigned nonce -- is recorded on the assignment.
%%% Discovery is local-first: the node indexes the relevant
%%% blocks itself with `~copycat@1.0' and then queries its own
%%% `~query@1.0' GraphQL endpoint (`transactions(recipients:
@@ -421,10 +421,11 @@ no_result_cache(Opts) ->
%%
%% In `target' mode the recipient match is served from the node's own
%% `~query@1.0' GraphQL endpoint, and each match is annotated with its offset --
-%% the sort key and the `offset' recorded on the assignment. No gateway is
-%% queried by default. In `all' mode there is nothing to match, so no query is
-%% run: the range's block headers are walked directly (see
-%% `enumerate_blocks/4').
+%% the sort key and the `offset' recorded on the assignment. If multiple
+%% matches share an offset, the range's block headers are walked to put only
+%% that ambiguous set back into block order. In `all' mode there is nothing to
+%% match, so no query is run: the range's block headers are walked directly
+%% (see `enumerate_blocks/4').
%%
%% Returns `{Extra, TXID}' pairs in the order they are to be assigned, where
%% `Extra' is the sequencing detail recorded on each assignment. Bundled data
@@ -439,14 +440,63 @@ discover(ProcID, _Mode, From, To, Opts) ->
maybe
ok ?= ensure_offsets(From, To, Opts),
{ok, IDs} ?= query_recipients(ProcID, From, To, Opts),
- {ok,
+ target_offsets(ProcID, From, To, base_layer_offsets(IDs, Opts), Opts)
+ end.
+
+%% @doc Return target-mode matches in offset order unless an equal-offset group
+%% needs block order to break the tie.
+target_offsets(_ProcID, _From, _To, [], _Opts) -> {ok, []};
+target_offsets(ProcID, From, To, OffsetPairs, Opts) ->
+ case has_offset_ties(OffsetPairs) of
+ false ->
+ {ok, offset_assignments(OffsetPairs)};
+ true ->
+ maybe
+ {ok, Located} ?= enumerate_blocks(ProcID, From, To, Opts),
+ {ok, target_groups(OffsetPairs, Located)}
+ end
+ end.
+
+has_offset_ties(OffsetPairs) ->
+ Offsets = [Offset || {Offset, _TXID} <- OffsetPairs],
+ length(Offsets) =/= length(lists:usort(Offsets)).
+
+offset_assignments(OffsetPairs) ->
+ [
+ {#{ <<"offset">> => Offset }, TXID}
+ ||
+ {Offset, TXID} <- OffsetPairs
+ ].
+
+target_groups([], _Located) -> [];
+target_groups([{Offset, _TXID} = Pair | Rest], Located) ->
+ {Same, Next} = lists:splitwith(fun({NextOffset, _}) -> NextOffset =:= Offset end, Rest),
+ Group = [Pair | Same],
+ case Same of
+ [] -> offset_assignments(Group);
+ _ -> target_blocks(Group, Located)
+ end ++ target_groups(Next, Located).
+
+%% @doc Keep equal-offset target-mode matches in canonical block order, while
+%% preserving each transaction's weave offset as assignment metadata.
+target_blocks(OffsetPairs, Located) ->
+ Offsets =
+ maps:from_list(
[
- {#{ <<"offset">> => Offset }, TXID}
+ {TXID, Offset}
||
- {Offset, TXID} <- base_layer_offsets(IDs, Opts)
+ {Offset, TXID} <- OffsetPairs
]
- }
- end.
+ ),
+ lists:filtermap(
+ fun({_Height, TXID}) ->
+ case maps:find(TXID, Offsets) of
+ {ok, Offset} -> {true, {#{ <<"offset">> => Offset }, TXID}};
+ error -> false
+ end
+ end,
+ Located
+ ).
%% @doc Enumerate every transaction in a block range from the block headers
%% themselves, in canonical chain order: blocks ascending by height, then each
@@ -634,11 +684,10 @@ to_transactions({error, Reason}, _Opts) ->
}
}.
-%% @doc Annotate each matched transaction with its weave offset from the
-%% local index, keeping only the base-layer (`tx@1.0') transactions and
-%% dropping bundled data items (indexed under the `ans104@1.0' codec) and any
-%% that are not yet locally indexed. The result is sorted by offset, which is
-%% the canonical weave order.
+%% @doc Annotate each matched transaction with its weave offset from the local
+%% index, keeping only the base-layer (`tx@1.0') transactions and dropping
+%% bundled data items (indexed under the `ans104@1.0' codec) and any that are
+%% not yet locally indexed.
base_layer_offsets(IDs, Opts) ->
Store = hb_store_arweave:store_from_opts(Opts),
lists:keysort(
@@ -1085,6 +1134,44 @@ base_layer_offsets_test() ->
base_layer_offsets([Late, Bundled, Early, Unindexed], Opts)
).
+%% @doc Target mode uses the query result as a candidate set, but block order
+%% as the sequencing order. Equal offsets are not enough to order data-free
+%% transactions.
+target_blocks_equal_offset_test() ->
+ Store = hb_test_utils:test_store(),
+ hb_store:start(Store),
+ Opts = #{ <<"store">> => [Store] },
+ ProcID = hb_util:human_id(crypto:strong_rand_bytes(32)),
+ First = hb_util:human_id(crypto:strong_rand_bytes(32)),
+ Second = hb_util:human_id(crypto:strong_rand_bytes(32)),
+ Other = hb_util:human_id(crypto:strong_rand_bytes(32)),
+ Later = hb_util:human_id(crypto:strong_rand_bytes(32)),
+ ?assertEqual(
+ {ok,
+ [
+ {#{ <<"offset">> => 99 }, First},
+ {#{ <<"offset">> => 100 }, Second}
+ ]},
+ target_offsets(ProcID, 10, 10, [{99, First}, {100, Second}], #{})
+ ),
+ ok = write_test_block(10, [Other, First, Second, Later], Opts),
+ ?assertEqual(
+ {ok,
+ [
+ {#{ <<"offset">> => 99 }, First},
+ {#{ <<"offset">> => 100 }, Other},
+ {#{ <<"offset">> => 100 }, Second},
+ {#{ <<"offset">> => 101 }, Later}
+ ]},
+ target_offsets(
+ ProcID,
+ 10,
+ 10,
+ [{99, First}, {100, Second}, {100, Other}, {101, Later}],
+ Opts
+ )
+ ).
+
%% @doc The sequencing mode is read from the process message, and anything the
%% device does not implement leaves the process sequenced as normal rather than
%% wedging it: the process message cannot be corrected once it is on-chain.
diff --git a/src/preloaded/process/dev_arweave_swap.erl b/src/preloaded/process/dev_arweave_swap.erl
index 7c59c7aa6..fbd4ccb19 100644
--- a/src/preloaded/process/dev_arweave_swap.erl
+++ b/src/preloaded/process/dev_arweave_swap.erl
@@ -264,7 +264,7 @@ register_interest(Base, Body, Height, Opts) ->
<<"deposit">> := Deposit,
<<"deadline">> := Deadline
} ?= Order,
- {ok, Paid} ?= amount(<<"reward">>, Body, Opts),
+ {ok, Paid} ?= hb_util:safe_int(tx_field(Body, <<"reward">>, 0, Opts)),
true ?= Paid >= Fee,
true ?= balance(Base, Buyer, Opts) >= Deposit,
Until = Height + Deadline,