xrpl-ruby 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cb8b3e628ddf6a9d9a7e329e6e8c51e4b0cd4f872eb609e589d78795aa9f4782
4
- data.tar.gz: 2f5b18b34cb386901a8ad1a7549f7cca241facce58c0841f36a10a4c200413cc
3
+ metadata.gz: 1b52d066f91b01da1ccdbbe8229cadc3b40b54d79a1234f0ac96f0f131159a06
4
+ data.tar.gz: 57fc035044049efff0bae7418e48e6ffee589a2cec5d763b056b07d6c23d0b10
5
5
  SHA512:
6
- metadata.gz: f066a56166ec0aa81ec3acb34106d504962a6e1cf2cee89eb13103719467f9ded35a34d432d9901c8a81f11b7dedc93311d8ebd184dc21564585163536e18558
7
- data.tar.gz: 97397fe795b08eed3c56111b876d5476da705e2577c635f12e6aeee9ebc5ef86106fd44e525dc67c3716c11472b273bd4235764d9df2b7bf8bcc3e0d152078a7
6
+ metadata.gz: 4fd54f7b3c12b2cdf35ceecf7a226526900e31e866f16ad3b138d86ac98571e8cf501904b478fa5fa3ace4a8f49ae0e7ee160a33ba8fbd7031d0563cb59876eb
7
+ data.tar.gz: 5e61d8a4da0bf7e517b22ae77d3b5ea2580ae36f2751fba690051482c449d2778fb3be9886167e0160ad45d08cc1639afd9eb9b39fefa074163d712b06f1f64b
data/CHANGELOG.md CHANGED
@@ -5,6 +5,185 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.9.0] - 2026-11-02
11
+
12
+ Batch (XLS-56) and permission delegation (XLS-75), both enabled on Mainnet
13
+ since October, are fully supported now:
14
+ - `autofill` prepares and prices a Batch.
15
+ - Accounts sign their part with `Wallet#sign_batch`.
16
+ - `Wallet.combine_batch_signers` merges the parts.
17
+ - Delegated transactions are proven on the Testnet.
18
+
19
+ All Testnet specs pass on the release commit: a Batch of one account, a swap
20
+ between two accounts, a signer list, tickets, and delegation with and
21
+ without permission.
22
+
23
+ The codec now also reads UInt Strings the way rippled 3.4.1 does, which is
24
+ the one breaking change. It fixes a serialisation bug that could make a
25
+ multisigned transaction invalid: about one `Signers` entry in 256 lost its
26
+ end marker. Codec fixtures are unchanged: 39/39 transactions and 263/263
27
+ ledger entries, both directions.
28
+
29
+ ### Breaking
30
+
31
+ - **UInt Strings are read the way rippled reads them.** rippled 3.4.1
32
+ (`STParsedJSON`) and ripple-binary-codec read these field types like this:
33
+ - **UInt8, UInt16, UInt32:** a whole decimal number.
34
+ - **UInt64:** hex of any length, except in the base-10 fields
35
+ (`MPTAmount`, `MaximumAmount`, `OutstandingAmount`, `LockedAmount`,
36
+ `ConfidentialOutstandingAmount`), which are decimal.
37
+ - **Int32, Int64:** a signed decimal number.
38
+
39
+ A leading `+` is accepted where rippled accepts it (UInt32, Int32,
40
+ Int64), not for UInt8, UInt16 or UInt64.
41
+
42
+ This codec differed in two places, and both now follow rippled:
43
+ - A UInt8/16/32 String in its full width was read as hex. `"00000065"`
44
+ as a `Sequence` was 101 here and is 65 in rippled; `"0000000A"` was 10
45
+ and now raises.
46
+ - An unpadded UInt64 String was read as decimal. `"100"` as `IndexNext`
47
+ was 100 and is `0x100` in rippled; `"36d6"` was 36.
48
+
49
+ Names count only for their own field: `TransactionType` and
50
+ `LedgerEntryType` take type names, `TransactionResult` result codes,
51
+ `PermissionValue` permission names. Before, any field of the same width
52
+ took them, so `TicketSequence: "TrustSet"` was ticket 21 and
53
+ `TransferFee: "Payment"` was 0.
54
+
55
+ Integers and the names in their own fields are unchanged, as is everything
56
+ the codec decodes: it renders UInt8/16/32 as Integers and UInt64 as 16 hex
57
+ digits, which round-trips. Affected is only code that passes
58
+ - UInt8/16/32 values as hex Strings,
59
+ - unpadded UInt64 values meant as decimal, or
60
+ - a name in a field it does not belong to, such as
61
+ `TicketSequence: "TrustSet"` or `TransferFee: "Payment"`, which now
62
+ raises, or
63
+ - an Int64 as a hex String: `Int64.from("DEAD")` was 57005 and now raises.
64
+ Int64 also decodes to a decimal String (`"-9"`) instead of 16 hex digits
65
+ (`"FFFFFFFFFFFFFFF7"`), so it round-trips. rippled 3.4.1 has no Int64
66
+ field, so this concerns direct use of the type only.
67
+
68
+ Where ripple-binary-codec accepts a value,
69
+ `json_to_binary` now produces the same bytes; it also accepts decimal
70
+ UInt8/16 Strings, which rippled reads and ripple-binary-codec refuses.
71
+
72
+ ### Added
73
+
74
+ - **Permission delegation is verified on the Testnet** (XLS-75,
75
+ PermissionDelegationV1_1 enabled on Mainnet since 2026-10-08). The library
76
+ needed no change:
77
+ - `Wallet#sign` signs a transaction whose `Account` is another account.
78
+ - `autofill` takes the `Sequence` from `Account`.
79
+ - The codec translates `PermissionValue` names (since 0.8.0).
80
+
81
+ `spec/integration/xrpl/delegation_spec.rb` covers the whole flow:
82
+ - a `DelegateSet`,
83
+ - then a `Payment` with `Delegate`, signed by the delegate,
84
+ - checked against rippled 3.4.1: the account pays the amount and the
85
+ delegate pays the fee,
86
+ - and a delegate without the permission is refused with
87
+ `terNO_DELEGATE_PERMISSION`.
88
+
89
+ The README has a section on it, including the warning from xrpl.org not to
90
+ delegate `PaymentBurn` before `fixCleanup3_4_0` is enabled.
91
+ - **Batch signing across accounts.** `Wallet#sign_batch` produces the
92
+ `BatchSigners` entry for an account with inner transactions in the Batch:
93
+ the wallet's own account, another account it is the regular key of
94
+ (`batch_account:`), or one signer of a signer list (`multisign:`). For a
95
+ delegated inner transaction the `Delegate` signs.
96
+ `Wallet.combine_batch_signers` merges the signed copies, given as Hashes
97
+ or hex. It sorts the entries by numeric AccountID, drops the outer
98
+ account's entry, and merges signatures from one signer list into a single
99
+ entry. It refuses copies of different Batches and an outer transaction that
100
+ is already signed.
101
+ `BinaryCodec.signing_batch_data` builds the payload rippled 3.4.1 verifies:
102
+ `BCH\0`, the outer account, the sequence value (the `TicketSequence` when
103
+ `Sequence` is 0), Flags, the inner transaction IDs and the signer accounts.
104
+ Signatures match the xrpl.js vectors byte for byte, and a swap between two
105
+ accounts validates on the Testnet. `examples/batch.rb` shows both flows.
106
+ - **`BinaryCodec.transaction_id`** gives the ID of any transaction, signed or
107
+ unsigned (SHA512Half of `TXN\0` and its serialisation), and
108
+ `BinaryCodec.inner_transaction_ids` the IDs of a Batch's inner
109
+ transactions. `Wallet#sign` uses it for its `hash`.
110
+ - **`autofill` prepares a Batch** (XLS-56, BatchV1_1 enabled on Mainnet
111
+ since 2026-10-09). Every inner transaction gets `tfInnerBatchTxn`, `Fee`
112
+ `"0"` and an empty `SigningPubKey`; sequences follow `autofillBatchTxn` in
113
+ xrpl.js: the outer account's inner transactions continue after the outer
114
+ `Sequence`, every other account starts at its own, a given `Sequence` or
115
+ `TicketSequence` is kept, and the account's following inner transactions
116
+ continue after an explicit `Sequence` (xrpl.js hands it out a second time). What the ledger would refuse raises
117
+ `ArgumentError` before anything is signed: not exactly one mode flag,
118
+ fewer than two or more than eight inner transactions, an entry that is not
119
+ a `RawTransaction` wrapper, a nested Batch, an inner signature, fee or key.
120
+ The rules live in `XRPL::Batch`, which needs no network.
121
+
122
+ ### Fixed
123
+
124
+ - **A Sequence next to a TicketSequence is refused in a Batch.** On a ticket
125
+ `Sequence` is 0, and rippled refuses anything else (`temSEQ_AND_TICKET`).
126
+ `autofill` kept a nonzero one on an inner transaction and counted it, so
127
+ the account's later inner transactions got sequences that were too high.
128
+ It now raises `ArgumentError` before handing out any sequence, for the
129
+ outer transaction as well as the inner ones. Every inner transaction is
130
+ checked before the first sequence is fetched. A `Sequence` given as a
131
+ String is read the way the codec encodes it, also in the BatchSigner
132
+ payload, rather than with `#to_i`; a name or a malformed String raises.
133
+ - **`autofill` gives a transaction on a ticket `Sequence` 0.** With a
134
+ `TicketSequence` and no `Sequence`, it fetched the account's sequence,
135
+ which rippled refuses next to a ticket. This affected every transaction
136
+ type, not only a Batch.
137
+ - **Batch sequences around tickets.** Two cases of `autofill` were wrong
138
+ for a Batch:
139
+ - An inner transaction on a ticket got no `Sequence`. The field is
140
+ required and is 0 on a ticket, and rippled refuses the Batch without it.
141
+ - An inner `TicketCreate` did not reserve the sequences of the tickets it
142
+ creates, so the next inner transaction of that account repeated one of
143
+ them.
144
+
145
+ Both now follow rippled: a transaction on a ticket consumes no sequence,
146
+ and a `TicketCreate` takes one per ticket after its own, or from the
147
+ current sequence when it is itself on a ticket. Found through the Testnet
148
+ tests of xrpl-php, which fixed the first case the same day.
149
+ - **A Batch over several accounts was charged too little.** The fee is
150
+ `(2 + n) × base fee` plus the inner fees (XLS-56 §2.2), with n the
151
+ signatures in `BatchSigners`; 0.8.0 left n out unless it came in as
152
+ `signers_count:`. `Fee.calculate` now counts `BatchSigners` itself, a
153
+ signer-list entry by its nested `Signers`. Because the signatures cover
154
+ the finished Batch, its fee is set before they exist:
155
+ `autofill(batch, batch_signatures: 2)` says how many will come. It is a
156
+ keyword, not a field, so it never reaches the codec. The 2 XRP cap applies
157
+ to a Batch as it does in xrpl.js.
158
+
159
+ - **An STArray element could lose its ObjectEndMarker on encode.** The
160
+ codec closed an element with `0xE1` only when its last byte was not
161
+ already `0xE1`, so the marker went missing whenever an element ended in
162
+ that byte by chance: a `Signer` or `RawTransaction` whose last AccountID
163
+ ends in `0xE1` (about one address in 256), a `Memo` whose data does, or an
164
+ element ending in a nested object (`AffectedNodes`). Such a transaction
165
+ was serialised, and therefore signed, differently from rippled and was
166
+ rejected. Every element now gets its marker; the binaries match
167
+ ripple-binary-codec byte for byte. The codec fixtures contain no such
168
+ element, which is why they never showed it.
169
+ - **Decoding wrote a nested object's end marker twice** when re-serialising
170
+ it, which surfaced as stray `ArrayEndMarker` / `ObjectEndMarker` keys in
171
+ decoded metadata.
172
+
173
+ ### Changed
174
+
175
+ - **UInt fields refuse malformed values.** A String that is not a number in
176
+ the field's reading (see Breaking) raises `ArgumentError`. Before, it went
177
+ through `#to_i`:
178
+ - `"101junk"` became 101, `"12.5"` became 12.
179
+ - `"abc"`, `"-1"` and `"0x10"` became 0.
180
+
181
+ A number outside the field's range also raises now. Before, `2**32` as a
182
+ UInt32 came out five bytes long, and a negative number became 0.
183
+ - **`STObject.from_parser` raises on malformed input** instead of ending
184
+ the object silently. The last `rescue => e; break` of the codec is gone;
185
+ a truncated blob used to decode into a partial object.
186
+
8
187
  ## [0.8.0] - 2026-09-24
9
188
 
10
189
  The codec now targets **rippled 3.4.0** (released 2026-09-17) and passes the
data/README.md CHANGED
@@ -128,7 +128,7 @@ payment.flag_names # => ["tfPartialPayment"]
128
128
  `autofill` sets the fee the transaction type actually needs, following the
129
129
  same rules as xrpl.js: an `EscrowFinish` pays for the size of its
130
130
  `Fulfillment`, `AccountDelete`, `AMMCreate` and `VaultCreate` cost the owner
131
- reserve, a `Batch` pays for its inner transactions, and multisigning adds one
131
+ reserve, a `Batch` pays for its signatures and its inner transactions, and multisigning adds one
132
132
  base fee per signature. Ordinary fees are capped at 2 XRP; pass
133
133
  `max_fee_drops:` to `Client.new` to change that. The rules are available on
134
134
  their own as `XRPL::Fee.calculate`.
@@ -153,6 +153,109 @@ entry.index # the entry's hash, as rippled reports it
153
153
  Every flag rippled defines is a constant on its type, e.g.
154
154
  `XRPL::LedgerEntry::AccountRoot::LSF_DEPOSIT_AUTH`.
155
155
 
156
+ ## Batch
157
+
158
+ A `Batch` (XLS-56, enabled on Mainnet since 2026-10-09) applies up to eight
159
+ inner transactions together. `autofill` prepares them: the `tfInnerBatchTxn`
160
+ flag, `Fee "0"`, an empty `SigningPubKey` and their sequences. It also refuses
161
+ a Batch the ledger would refuse. A Batch of one account is signed like any
162
+ other transaction:
163
+
164
+ ```ruby
165
+ batch = client.autofill({
166
+ 'TransactionType' => 'Batch',
167
+ 'Account' => alice.classic_address,
168
+ 'Flags' => XRPL::Batch::FLAGS['Batch']['tfAllOrNothing'],
169
+ 'RawTransactions' => [
170
+ { 'RawTransaction' => { 'TransactionType' => 'Payment', 'Account' => alice.classic_address,
171
+ 'Destination' => bob.classic_address, 'Amount' => '1000000' } },
172
+ { 'RawTransaction' => { 'TransactionType' => 'Payment', 'Account' => alice.classic_address,
173
+ 'Destination' => carol.classic_address, 'Amount' => '1000000' } }
174
+ ]
175
+ })
176
+ client.submit_and_wait(batch, wallet: alice, autofill: false)
177
+ ```
178
+
179
+ When inner transactions come from other accounts, each of those accounts
180
+ signs the finished Batch. The outer account signs it last. The signatures
181
+ cover the inner transaction IDs, so the Batch has to be complete before
182
+ anyone signs, and that includes its fee. `batch_signatures:` tells `autofill`
183
+ how many signatures will be collected, one per other account:
184
+
185
+ ```ruby
186
+ batch = client.autofill(swap, batch_signatures: 1) # Alice pays Bob, Bob pays Alice
187
+ signed = Wallet::Wallet.combine_batch_signers([bob.sign_batch(batch)])
188
+ client.submit_and_wait(signed, wallet: alice, autofill: false)
189
+ ```
190
+
191
+ - `sign_batch(batch, batch_account:)` signs for another account whose
192
+ regular key the wallet holds.
193
+ - `multisign: true` signs as one member of that account's signer list.
194
+ - `combine_batch_signers` sorts the entries the way rippled requires and
195
+ drops the outer account's entry. It also merges signatures from one signer
196
+ list into a single entry.
197
+ - For an inner transaction with a `Delegate`, the delegate signs.
198
+
199
+ **An outer `tesSUCCESS` does not mean the inner transactions succeeded.**
200
+ Under `tfOnlyOne`, `tfUntilFailure` or `tfIndependent` some of them can fail
201
+ while the Batch itself succeeds. Look up each one by its ID and check its own
202
+ result:
203
+
204
+ ```ruby
205
+ BinaryCodec.inner_transaction_ids(signed).each do |id|
206
+ result = client.tx_response(transaction: id).dig('result', 'meta', 'TransactionResult')
207
+ # an inner transaction that was not applied is not found at all
208
+ end
209
+ ```
210
+
211
+ `examples/batch.rb` runs both flows against the Testnet.
212
+
213
+ ## Delegation
214
+
215
+ An account can let another account send certain transactions on its behalf
216
+ (XLS-75, enabled on Mainnet since 2026-10-08). A `DelegateSet` grants the
217
+ permissions. A permission is either a transaction type, such as `"Payment"`,
218
+ or one of the granular permissions, such as `"TrustlineAuthorize"` or
219
+ `"PaymentMint"`. The codec translates both names.
220
+
221
+ > **Do not delegate `"PaymentBurn"` until the `fixCleanup3_4_0` amendment is
222
+ > enabled on the target network.** Before that fix, a delegate holding
223
+ > `PaymentBurn` can in certain circumstances also mint fungible tokens
224
+ > (trust line tokens or MPTs). The `feature` method of a node reports
225
+ > whether it is enabled there; [Known Amendments](https://xrpl.org/resources/known-amendments)
226
+ > has the Mainnet status. Source:
227
+ > [Permission Values](https://xrpl.org/docs/references/protocol/data-types/permission-values).
228
+
229
+ ```ruby
230
+ client.submit_and_wait({
231
+ 'TransactionType' => 'DelegateSet',
232
+ 'Account' => isaac.classic_address,
233
+ 'Authorize' => alice.classic_address,
234
+ 'Permissions' => [{ 'Permission' => { 'PermissionValue' => 'Payment' } }]
235
+ }, wallet: isaac)
236
+ ```
237
+
238
+ The delegate then sends the transaction with `Account` set to the delegating
239
+ account and `Delegate` set to itself, and signs it with its own wallet:
240
+
241
+ ```ruby
242
+ client.submit_and_wait({
243
+ 'TransactionType' => 'Payment',
244
+ 'Account' => isaac.classic_address,
245
+ 'Delegate' => alice.classic_address,
246
+ 'Destination' => bob.classic_address,
247
+ 'Amount' => '1000000'
248
+ }, wallet: alice)
249
+ ```
250
+
251
+ - **Sequence:** `autofill` takes it from `Account`, as rippled expects.
252
+ - **Signature and fee:** both belong to the delegate. Isaac pays the amount,
253
+ Alice pays the fee.
254
+ - **Without the permission:** the transaction comes back as
255
+ `terNO_DELEGATE_PERMISSION`.
256
+ - **Inside a Batch:** for a delegated inner transaction, the delegate signs
257
+ the `BatchSigners` entry (see above).
258
+
156
259
  ## Payment channel claims
157
260
 
158
261
  A claim is signed off-ledger and redeemed later with a `PaymentChannelClaim`
@@ -1,8 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest'
4
+
3
5
  module BinaryCodec
4
6
  # Hash prefixes for serialization.
5
7
  HASH_PREFIX = {
8
+ transaction_id: 0x54584E00, # 'TXN\0'
6
9
  transaction_sig: 0x53545800, # 'STX\0'
7
10
  transaction_multi_sig: 0x534D5400, # 'SMT\0'
8
11
  validation: 0x56414C00, # 'VAL\0'
@@ -59,6 +62,97 @@ module BinaryCodec
59
62
  Uint64.from(Integer(amount)).to_bytes
60
63
  end
61
64
 
65
+ # The ID of a transaction: SHA512Half of 'TXN\0' and its serialisation.
66
+ # For a signed transaction that is its hash; for an unsigned one, such as
67
+ # the inner transaction of a Batch, it is the ID rippled gives it.
68
+ #
69
+ # @param transaction [Hash, String] the transaction, or its serialisation as hex
70
+ # @return [String] the ID, 64 uppercase hex characters
71
+ def transaction_id(transaction)
72
+ hex = transaction.is_a?(String) ? transaction : json_to_binary(transaction)
73
+ prefix = int_to_bytes(HASH_PREFIX[:transaction_id], 4).pack('C*')
74
+ Digest::SHA512.digest(prefix + [hex].pack('H*'))[0, 32].unpack1('H*').upcase
75
+ end
76
+
77
+ # The IDs of a Batch's inner transactions, in order.
78
+ #
79
+ # @param batch [Hash] the Batch
80
+ # @return [Array<String>] one ID per entry of RawTransactions
81
+ def inner_transaction_ids(batch)
82
+ Array(batch['RawTransactions']).map do |wrapper|
83
+ inner = wrapper.is_a?(::Hash) ? wrapper['RawTransaction'] : nil
84
+ inner = inner.to_h if defined?(XRPL::Transaction) && inner.is_a?(XRPL::Transaction)
85
+ raise ArgumentError, 'Every entry of RawTransactions needs a RawTransaction' unless inner.is_a?(::Hash)
86
+
87
+ transaction_id(inner)
88
+ end
89
+ end
90
+
91
+ # The bytes a BatchSigner signs over (rippled serializeBatch plus the
92
+ # signer suffix, rippled 3.4.1 STTx::checkBatchSingleSign and
93
+ # checkBatchMultiSign): 'BCH\0', the outer Account, the outer sequence
94
+ # value, Flags, the number of inner transactions and their IDs, then the
95
+ # BatchSigner's Account and, when multisigning, the signing account.
96
+ #
97
+ # Like encodeForSigningBatch in xrpl.js, this encodes what it is given: a
98
+ # Batch without a Sequence encodes the sequence value 0. Wallet#sign_batch
99
+ # refuses to sign one, because rippled requires the Sequence.
100
+ #
101
+ # @param batch [Hash] the Batch, with Account, Flags and its inner
102
+ # transactions complete
103
+ # @param batch_account [String] the account of the BatchSigner entry
104
+ # @param signer_account [String, nil] the account of the signer in the
105
+ # entry's Signers, when the entry is multisigned
106
+ # @return [Array<Integer>] the signing data
107
+ def signing_batch_data(batch, batch_account:, signer_account: nil)
108
+ unless batch['Account'] && batch.key?('Flags')
109
+ raise ArgumentError, 'A Batch needs Account and Flags before it can be signed'
110
+ end
111
+
112
+ ids = inner_transaction_ids(batch)
113
+ bytes = int_to_bytes(HASH_PREFIX[:batch], 4) +
114
+ account_bytes(batch['Account']) +
115
+ int_to_bytes(batch_sequence_value(batch), 4) +
116
+ int_to_bytes(Integer(batch['Flags']), 4) +
117
+ int_to_bytes(ids.size, 4)
118
+ ids.each { |id| bytes += hex_to_bytes(id) }
119
+ bytes += account_bytes(batch_account)
120
+ bytes += account_bytes(signer_account) if signer_account
121
+ bytes
122
+ end
123
+
124
+ # rippled's SeqProxy value: the Sequence, or the TicketSequence when the
125
+ # Sequence is 0 because a ticket is used.
126
+ #
127
+ # @param batch [Hash]
128
+ # @return [Integer]
129
+ def batch_sequence_value(batch)
130
+ sequence = uint32_value(batch['Sequence'])
131
+ return sequence if sequence.positive?
132
+
133
+ uint32_value(batch['TicketSequence'])
134
+ end
135
+
136
+ # A UInt32 field's value as the codec serialises it, so a check made on
137
+ # it agrees with the bytes that are signed. A String is a decimal
138
+ # number, as rippled reads it, not #to_i: "101junk" raises rather than
139
+ # becoming 101.
140
+ #
141
+ # Only numbers: a UInt32 also takes a permission name ("TrustSet" is
142
+ # 21), which is meant for PermissionValue and would be a wrong ticket or
143
+ # sequence here.
144
+ #
145
+ # @param value [Integer, String, nil]
146
+ # @return [Integer] 0 for nil
147
+ def uint32_value(value)
148
+ return 0 if value.nil?
149
+ if value.is_a?(String) && !value.match?(/\A\+?\d+\z/)
150
+ raise ArgumentError, "A sequence needs a number, got #{value.inspect}"
151
+ end
152
+
153
+ Uint32.from(value).value_of
154
+ end
155
+
62
156
  # Generates signing data for a transaction.
63
157
  # @param transaction [Hash] The transaction to serialize.
64
158
  # @param prefix [Integer] The prefix to add to the serialized data.
@@ -81,5 +175,11 @@ module BinaryCodec
81
175
 
82
176
  prefix_bytes + serialized_bytes
83
177
  end
178
+
179
+ private
180
+
181
+ def account_bytes(address)
182
+ AddressCodec::AddressCodec.new.decode_account_id(address).to_a
183
+ end
84
184
  end
85
185
  end
@@ -47,7 +47,13 @@ module BinaryCodec
47
47
  end
48
48
 
49
49
  field_header = field.header
50
- associated_value = field.associated_type.from(value)
50
+ # A UInt reads a String by its field: a UInt64 is hex, or decimal in a
51
+ # base-10 field.
52
+ associated_value = if field.associated_type <= Uint
53
+ field.associated_type.from(value, field.name)
54
+ else
55
+ field.associated_type.from(value)
56
+ end
51
57
 
52
58
  @sink.put(field_header.to_bytes)
53
59
 
@@ -3,6 +3,7 @@
3
3
  module BinaryCodec
4
4
  class STArray < SerializedType
5
5
  ARRAY_END_MARKER = 0xF1
6
+ OBJECT_END_MARKER = 0xE1
6
7
 
7
8
  def initialize(byte_buf = nil)
8
9
  super(byte_buf || [])
@@ -35,7 +36,11 @@ module BinaryCodec
35
36
 
36
37
  obj = STObject.from(field_value, nil, definitions)
37
38
  bytes.concat(obj.to_bytes)
38
- bytes.concat([0xE1]) unless bytes.last == 0xE1
39
+ # Every element is closed by its own ObjectEndMarker. Testing the
40
+ # last byte for 0xE1 instead dropped it whenever the element
41
+ # happened to end in that byte - an AccountID, a Memo, or a nested
42
+ # object, whose own marker is 0xE1 too.
43
+ bytes.concat([OBJECT_END_MARKER])
39
44
  else
40
45
  raise StandardError, "STArray item must be a Hash, got #{item.class}"
41
46
  end
@@ -70,7 +75,7 @@ module BinaryCodec
70
75
  # Reconstruct the serialized item: [FieldHeader][STObject][ObjectEndMarker]
71
76
  bytes.concat(field_header.to_bytes)
72
77
  bytes.concat(obj.to_bytes)
73
- bytes.concat([0xE1]) unless bytes.last == 0xE1
78
+ bytes.concat([OBJECT_END_MARKER])
74
79
  end
75
80
 
76
81
  # The ArrayEndMarker has to go back in. Without it the reconstructed
@@ -100,20 +100,16 @@ module BinaryCodec
100
100
  list = BytesList.new
101
101
  bytes = BinarySerializer.new(list)
102
102
 
103
+ # Malformed input raises rather than ending the object early: a silent
104
+ # break here returned a truncated object that nothing could trace.
103
105
  until parser.end?
104
- begin
105
- field = parser.read_field
106
- break if field.name == OBJECT_END_MARKER
106
+ field = parser.read_field
107
+ break if field.name == OBJECT_END_MARKER
107
108
 
108
- associated_value = parser.read_field_value(field)
109
+ associated_value = parser.read_field_value(field)
109
110
 
110
- bytes.write_field_and_value(field, associated_value)
111
- bytes.put(OBJECT_END_MARKER_BYTE) if field.type == ST_OBJECT
112
- rescue => e
113
- # If we fail to read a field (e.g. unknown header), we might have hit
114
- # the end of the object without an end marker, or just malformed data.
115
- break
116
- end
111
+ # Closes a nested STObject with its ObjectEndMarker itself.
112
+ bytes.write_field_and_value(field, associated_value)
117
113
  end
118
114
 
119
115
  STObject.new(list.to_bytes)
@@ -24,9 +24,20 @@ module BinaryCodec
24
24
  end
25
25
 
26
26
  # Creates a new Uint instance from a value.
27
+ #
28
+ # A String is read the way rippled 3.4.1 reads it (STParsedJSON):
29
+ # UInt8, UInt16 and UInt32 as a whole decimal number; UInt64 as hex of
30
+ # any length, except in the base-10 fields (BASE10_UINT64_FIELDS),
31
+ # which are decimal. The fields that carry a name also take the name.
32
+ # ripple-binary-codec reads them the same way. Anything else, and any
33
+ # value outside the type's range, raises ArgumentError. Names count only
34
+ # for their own field (NAMED_FIELDS) when the field is given.
35
+ #
27
36
  # @param value [Uint, String, Integer] The value to convert.
37
+ # @param field_name [String, nil] the field, which decides how a UInt64
38
+ # String is read; without it, as hex
28
39
  # @return [Uint] The created instance.
29
- def self.from(value)
40
+ def self.from(value, field_name = nil)
30
41
  return value if value.is_a?(self)
31
42
 
32
43
  if value.is_a?(String)
@@ -34,25 +45,58 @@ module BinaryCodec
34
45
  # LedgerEntryType (UInt16), TransactionResult (UInt8) and the
35
46
  # PermissionValue of a DelegateSet (UInt32). A name is never a valid
36
47
  # number, so the lookup cannot capture a numeric string.
37
- code = NAMED_VALUES.fetch(self, [])
38
- .filter_map { |table| Definitions.instance.public_send(table)[value] }
39
- .first
48
+ # With the field known, only that field's names count, so a
49
+ # Sequence cannot take "TrustSet" or a TransferFee "Payment".
50
+ tables = field_name ? Array(NAMED_FIELDS[field_name]) : NAMED_VALUES.fetch(self, [])
51
+ code = tables.filter_map { |table| Definitions.instance.public_send(table)[value] }.first
40
52
  return new(int_to_bytes(code, width)) if code
41
53
 
42
- # Handle hex strings or numeric strings
43
- if valid_hex?(value) && value.length == self.width * 2
44
- return new(hex_to_bytes(value))
45
- end
46
- return new(int_to_bytes(value.to_i, width))
54
+ return new(int_to_bytes(in_range!(parse_string(value, field_name)), width))
47
55
  end
48
56
 
49
- if value.is_a?(Integer)
50
- return new(int_to_bytes(value, width))
51
- end
57
+ return new(int_to_bytes(in_range!(value), width)) if value.is_a?(Integer)
52
58
 
53
59
  raise StandardError, "Cannot construct #{self} from the value given"
54
60
  end
55
61
 
62
+ # How a String becomes a number for this width. UInt8/16/32: decimal.
63
+ # UInt64: hex, or decimal in a base-10 field. The wider types keep the
64
+ # codec's earlier reading: hex in their full width, otherwise decimal.
65
+ def self.parse_string(value, field_name)
66
+ if width == 8
67
+ if BASE10_UINT64_FIELDS.include?(field_name)
68
+ return Integer(value, 10) if value.match?(/\A\d+\z/)
69
+
70
+ raise ArgumentError, "#{field_name} is base 10 and needs a whole decimal number, got #{value.inspect}"
71
+ end
72
+ return Integer(value, 16) if value.match?(/\A\h{1,16}\z/)
73
+
74
+ raise ArgumentError, "#{name} needs up to 16 hex digits, got #{value.inspect}"
75
+ elsif width > 8 && valid_hex?(value) && value.length == width * 2
76
+ Integer(value, 16)
77
+ elsif value.match?(/\A\d+\z/) || (width == 4 && value.match?(/\A\+\d+\z/))
78
+ # A leading '+' only for UInt32: rippled reads it with
79
+ # beast::lexicalCast, which skips it. Its UInt8 and UInt16 parsers
80
+ # take a String that does not start with a digit as a name and
81
+ # refuse "+5"; UInt64 above uses std::from_chars and takes no sign;
82
+ # the wider types are hex in rippled.
83
+ Integer(value, 10)
84
+ else
85
+ raise ArgumentError, "#{name} needs a whole decimal number, got #{value.inspect}"
86
+ end
87
+ end
88
+ private_class_method :parse_string
89
+
90
+ # A value that does not fit the width used to come out a byte too long
91
+ # (2**32 as a UInt32) or, if negative, as 0.
92
+ def self.in_range!(number)
93
+ max = (1 << (width * 8)) - 1
94
+ return number if number.between?(0, max)
95
+
96
+ raise ArgumentError, "#{name} takes 0 to #{max}, got #{number}"
97
+ end
98
+ private_class_method :in_range!
99
+
56
100
  # Creates a Uint instance from a parser.
57
101
  # @param parser [BinaryParser] The parser to read from.
58
102
  # @param _hint [Integer, nil] Unused hint.
@@ -167,8 +211,15 @@ module BinaryCodec
167
211
  # Creates a new Int32 instance from a value.
168
212
  # @param value [Int32, Integer] The value to convert.
169
213
  # @return [Int32] The created instance.
170
- def self.from(value)
214
+ def self.from(value, _field_name = nil)
171
215
  return value if value.is_a?(self)
216
+ # rippled reads an Int32 String as a signed decimal number, and
217
+ # nothing else: not as the hex a UInt64 would take.
218
+ if value.is_a?(String)
219
+ raise ArgumentError, "#{name} needs a signed decimal number, got #{value.inspect}" unless value.match?(/\A[+-]?\d+\z/)
220
+
221
+ value = Integer(value, 10)
222
+ end
172
223
  if value.is_a?(Integer)
173
224
  # Ensure it fits in 32-bit signed
174
225
  if value < -2147483648 || value > 2147483647
@@ -191,11 +242,26 @@ module BinaryCodec
191
242
  val > 0x7FFFFFFFFFFFFFFF ? val - 0x10000000000000000 : val
192
243
  end
193
244
 
245
+ # The signed value as a decimal String: not the 16 hex digits of a
246
+ # UInt64, so the decoder's output goes back in through .from; and a
247
+ # String, as rippled renders 64-bit values, so a JSON reader that holds
248
+ # numbers as doubles (JavaScript) cannot round it.
249
+ def to_json(_definitions = nil, _field_name = nil)
250
+ value_of.to_s
251
+ end
252
+
194
253
  # Creates a new Int64 instance from a value.
195
254
  # @param value [Int64, Integer] The value to convert.
196
255
  # @return [Int64] The created instance.
197
- def self.from(value)
256
+ def self.from(value, _field_name = nil)
198
257
  return value if value.is_a?(self)
258
+ # rippled reads an Int64 String as a signed decimal number, and
259
+ # nothing else: not as the hex a UInt64 would take.
260
+ if value.is_a?(String)
261
+ raise ArgumentError, "#{name} needs a signed decimal number, got #{value.inspect}" unless value.match?(/\A[+-]?\d+\z/)
262
+
263
+ value = Integer(value, 10)
264
+ end
199
265
  if value.is_a?(Integer)
200
266
  if value < -9223372036854775808 || value > 9223372036854775807
201
267
  raise StandardError, "Value #{value} out of range for Int64"
data/lib/wallet/wallet.rb CHANGED
@@ -124,14 +124,10 @@ module Wallet
124
124
  # 4. Serialize the signed transaction
125
125
  tx_blob = BinaryCodec.json_to_binary(signed_tx)
126
126
 
127
- # 5. Generate the hash (transaction ID)
128
- # For transactions, the hash is SHA512Half of the serialized transaction with a prefix
129
- hash_prefix = [0x54, 0x58, 0x4E, 0x00].pack('C*') # 'TXN\0'
130
- hash = Digest::SHA512.digest(hash_prefix + [tx_blob].pack('H*'))[0...32].unpack1('H*').upcase
131
-
127
+ # 5. The transaction ID: SHA512Half of 'TXN\0' and the blob
132
128
  return {
133
129
  'tx_blob' => tx_blob,
134
- 'hash' => hash
130
+ 'hash' => BinaryCodec.transaction_id(tx_blob)
135
131
  }
136
132
  end
137
133
 
@@ -194,6 +190,152 @@ module Wallet
194
190
  KeyPairs::KeyPairs.new.verify(bytes_to_hex(data), signature, public_key)
195
191
  end
196
192
 
193
+ # Signs a Batch (XLS-56) for an account with inner transactions in it:
194
+ # the BatchSigners entry that account contributes. The outer account does
195
+ # not need one; it signs the whole Batch with #sign once the entries are
196
+ # in (see .combine_batch_signers).
197
+ #
198
+ # The Batch must be complete: Account, Sequence, Flags and the inner
199
+ # transactions as XRPL::Client#autofill leaves them, because the
200
+ # signature covers the inner transaction IDs.
201
+ #
202
+ # @param batch [Hash, XRPL::Transaction] the Batch
203
+ # @param batch_account [String, nil] the account to sign for; by default
204
+ # this wallet's. Another account when this wallet is its regular key,
205
+ # or the account whose signer list this wallet belongs to.
206
+ # @param multisign [Boolean, String] true to sign as one signer of
207
+ # batch_account's signer list, as this wallet's own account; or the
208
+ # signer account, when this wallet is that signer's regular key
209
+ # @return [Hash] a copy of the Batch whose BatchSigners holds this one entry
210
+ # @raise [ArgumentError] for anything but a Batch, or an account with no
211
+ # inner transaction in it
212
+ def sign_batch(batch, batch_account: nil, multisign: false)
213
+ batch = self.class.batch_hash(batch)
214
+ # rippled requires a Sequence, and the signature covers its value (or
215
+ # the TicketSequence). Signed without one, the entry matches no Batch
216
+ # that can be submitted: autofill first, then sign.
217
+ unless BinaryCodec.batch_sequence_value(batch).positive?
218
+ raise ArgumentError, 'A Batch needs its Sequence or TicketSequence before it is signed; autofill it first'
219
+ end
220
+
221
+ batch_account ||= @classic_address
222
+ unless self.class.batch_signer_accounts(batch).include?(batch_account)
223
+ raise ArgumentError, "#{batch_account} has no inner transaction to sign for in this Batch"
224
+ end
225
+
226
+ entry = { 'Account' => batch_account }
227
+ if multisign
228
+ signer_account = multisign.is_a?(String) ? multisign : @classic_address
229
+ data = BinaryCodec.signing_batch_data(batch, batch_account: batch_account, signer_account: signer_account)
230
+ entry['Signers'] = [{ 'Signer' => {
231
+ 'Account' => signer_account,
232
+ 'SigningPubKey' => @public_key,
233
+ 'TxnSignature' => @key_pairs.sign(bytes_to_hex(data), @private_key, @algorithm)
234
+ } }]
235
+ else
236
+ data = BinaryCodec.signing_batch_data(batch, batch_account: batch_account)
237
+ entry['SigningPubKey'] = @public_key
238
+ entry['TxnSignature'] = @key_pairs.sign(bytes_to_hex(data), @private_key, @algorithm)
239
+ end
240
+
241
+ batch.merge('BatchSigners' => [{ 'BatchSigner' => entry }])
242
+ end
243
+
244
+ # The accounts that sign a Batch's BatchSigners: the Account of every
245
+ # inner transaction, or its Delegate when it has one, since the delegate
246
+ # acts for the account. Each account once, in order of appearance. The
247
+ # outer account is included; its entry is dropped when combining.
248
+ #
249
+ # @param batch [Hash, XRPL::Transaction] the Batch
250
+ # @return [Array<String>]
251
+ def self.batch_signer_accounts(batch)
252
+ batch_hash(batch)['RawTransactions'].map do |wrapper|
253
+ inner = wrapper['RawTransaction'] || {}
254
+ inner['Delegate'] || inner['Account']
255
+ end.compact.uniq
256
+ end
257
+
258
+ # Merges the copies of a Batch that each signer returned from
259
+ # #sign_batch into one Batch carrying all BatchSigners entries, ready for
260
+ # the outer account to sign with #sign.
261
+ #
262
+ # Entries are sorted by numeric AccountID, as rippled requires. The outer
263
+ # account's entry is dropped: its signature is the outer one. Signatures
264
+ # of the same account's signer list are merged into one entry with its
265
+ # Signers sorted the same way; an identical entry given twice is kept once.
266
+ #
267
+ # @param batches [Array<Hash, String>] the signed copies, as Hashes or
268
+ # serialised hex
269
+ # @return [Hash] the Batch with the combined BatchSigners
270
+ # @raise [ArgumentError] when the copies are not of the same Batch, one
271
+ # carries no BatchSigners, or the outer transaction is already signed
272
+ def self.combine_batch_signers(batches)
273
+ batches = Array(batches).map { |b| b.is_a?(String) ? BinaryCodec.binary_to_json(b) : batch_hash(b) }
274
+ raise ArgumentError, 'combine_batch_signers needs at least one signed Batch' if batches.empty?
275
+
276
+ batches.each do |b|
277
+ raise ArgumentError, 'The Batch must not be signed yet; the outer account signs last' if b['TxnSignature'] || b['Signers']
278
+ raise ArgumentError, 'Every Batch to combine needs BatchSigners' if Array(b['BatchSigners']).empty?
279
+ end
280
+ unsigned = batches.map { |b| b.reject { |key, _| key == 'BatchSigners' } }
281
+ unless unsigned.uniq.size == 1
282
+ raise ArgumentError, 'The Batches to combine have to be the same apart from BatchSigners'
283
+ end
284
+
285
+ outer = batches.first['Account']
286
+ by_account = {}
287
+ batches.flat_map { |b| b['BatchSigners'] }.each do |wrapper|
288
+ entry = wrapper['BatchSigner']
289
+ account = entry['Account']
290
+ next if account == outer
291
+
292
+ by_account[account] = merge_batch_signer(by_account[account], entry)
293
+ end
294
+
295
+ entries = by_account.values.sort_by { |entry| account_sort_key(entry['Account']) }
296
+ unsigned.first.merge('BatchSigners' => entries.map { |entry| { 'BatchSigner' => entry } })
297
+ end
298
+
299
+ # The Batch as a Hash, its inner transactions included: a model, or a
300
+ # Hash whose RawTransaction entries are models, comes back as plain
301
+ # Hashes, so the copy that sign_batch returns can be serialised.
302
+ #
303
+ # @api private
304
+ def self.batch_hash(batch)
305
+ batch = batch.to_h if !batch.is_a?(::Hash) && batch.respond_to?(:to_h)
306
+ unless batch.is_a?(::Hash) && batch['TransactionType'] == 'Batch'
307
+ raise ArgumentError, 'sign_batch needs a Batch transaction'
308
+ end
309
+ return batch unless batch['RawTransactions'].is_a?(Array)
310
+
311
+ raw = batch['RawTransactions'].map do |wrapper|
312
+ inner = wrapper.is_a?(::Hash) ? wrapper['RawTransaction'] : nil
313
+ inner.is_a?(XRPL::Transaction) ? wrapper.merge('RawTransaction' => inner.to_h) : wrapper
314
+ end
315
+ batch.merge('RawTransactions' => raw)
316
+ end
317
+
318
+ # Two entries for the same account: identical ones collapse, signer-list
319
+ # entries merge their Signers, anything else is a conflict.
320
+ def self.merge_batch_signer(existing, entry)
321
+ return entry if existing.nil? || existing == entry
322
+
323
+ unless existing['Signers'] && entry['Signers']
324
+ raise ArgumentError, "Conflicting BatchSigners entries for #{entry['Account']}"
325
+ end
326
+
327
+ signers = (existing['Signers'] + entry['Signers'])
328
+ .uniq { |s| s['Signer']['Account'] }
329
+ .sort_by { |s| account_sort_key(s['Signer']['Account']) }
330
+ existing.merge('Signers' => signers)
331
+ end
332
+
333
+ def self.account_sort_key(address)
334
+ AddressCodec::AddressCodec.new.decode_account_id(address).to_a
335
+ end
336
+
337
+ private_class_method :merge_batch_signer, :account_sort_key
338
+
197
339
  # Derives the X-address for this wallet.
198
340
  # @param tag [Integer, false, nil] The destination tag.
199
341
  # @param test_network [Boolean] Whether the address is for a test network.
data/lib/xrpl/batch.rb ADDED
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XRPL
4
+ # Prepares the inner transactions of a Batch (XLS-56) before it is signed.
5
+ #
6
+ # The ledger requires every inner transaction to carry the tfInnerBatchTxn
7
+ # flag, a Fee of "0" and an empty SigningPubKey: the outer transaction pays
8
+ # the fees, and the outer signature together with BatchSigners authorises
9
+ # the inner transactions. They must also carry their final Sequence, because
10
+ # the BatchSigners sign over the inner transaction IDs. So the Batch has to
11
+ # be complete before anyone signs it.
12
+ #
13
+ # The module is pure: XRPL::Client#autofill passes in a way to fetch an
14
+ # account's sequence. What the ledger would refuse is refused here, before
15
+ # anything is signed or sent.
16
+ module Batch
17
+ FLAGS = Model::DEFINITIONS['TRANSACTION_FLAGS']
18
+
19
+ # The four modes; a Batch names exactly one of them.
20
+ MODE_FLAGS = FLAGS.fetch('Batch').values.reduce(0, :|)
21
+
22
+ # Marks a transaction as the inner transaction of a Batch.
23
+ TF_INNER_BATCH_TXN = FLAGS.fetch('universal').fetch('tfInnerBatchTxn')
24
+
25
+ MIN_INNER = 2
26
+ MAX_INNER = 8
27
+
28
+ module_function
29
+
30
+ # @param batch [Hash] the Batch, with its outer Sequence (or
31
+ # TicketSequence) already set; it is not modified
32
+ # @param sequence_for [#call] takes an account and returns its next
33
+ # sequence; only called for accounts whose inner transactions lack a
34
+ # Sequence and a TicketSequence, once per account
35
+ # @return [Hash] a copy of the Batch with its inner transactions filled in
36
+ # @raise [ArgumentError] when the ledger would refuse the Batch
37
+ def prepare(batch, sequence_for:)
38
+ check_mode!(batch['Flags'])
39
+ check_sequence_and_ticket!(batch, 'The Batch')
40
+ raw = batch['RawTransactions']
41
+ unless raw.is_a?(Array) && raw.size.between?(MIN_INNER, MAX_INNER)
42
+ count = raw.is_a?(Array) ? raw.size : 0
43
+ raise ArgumentError, "A Batch carries #{MIN_INNER} to #{MAX_INNER} inner transactions, this one has #{count}"
44
+ end
45
+
46
+ # Per account: the next sequence to hand out, and the lowest one still
47
+ # free after the explicit sequences seen so far. An inner transaction
48
+ # without a sequence gets the higher of the two, so it never repeats
49
+ # one given explicitly earlier in the Batch.
50
+ next_sequence = {}
51
+ floor = Hash.new(0)
52
+ take = lambda do |account|
53
+ [next_sequence[account] || first_sequence(batch, account, sequence_for), floor[account]].max
54
+ end
55
+
56
+ # Everything is checked before the first sequence is handed out, so a
57
+ # Batch the ledger would refuse never reaches sequence_for.
58
+ inners = raw.each_with_index.map do |wrapper, index|
59
+ unwrap(wrapper, index).tap { |inner| check_inner!(inner, index) }
60
+ end
61
+
62
+ prepared = inners.map do |inner|
63
+ inner['Flags'] = Integer(inner['Flags'] || 0) | TF_INNER_BATCH_TXN
64
+ inner['Fee'] = '0'
65
+ inner['SigningPubKey'] = ''
66
+
67
+ account = inner['Account']
68
+ # A TicketCreate also takes a sequence for every ticket it creates.
69
+ reserved = inner['TransactionType'] == 'TicketCreate' ? Integer(inner['TicketCount'] || 0) : 0
70
+
71
+ if !inner['TicketSequence'].nil? && BinaryCodec.uint32_value(inner['Sequence']).zero?
72
+ # On a ticket: Sequence is a required field and 0 here. Only a
73
+ # TicketCreate still moves the account's sequence on, by the
74
+ # tickets it creates, starting at the current one.
75
+ inner['Sequence'] = 0
76
+ next_sequence[account] = take.call(account) + reserved if reserved.positive?
77
+ elsif inner['Sequence'].nil?
78
+ sequence = take.call(account)
79
+ inner['Sequence'] = sequence
80
+ next_sequence[account] = sequence + 1 + reserved
81
+ else
82
+ floor[account] = [floor[account], BinaryCodec.uint32_value(inner['Sequence']) + 1 + reserved].max
83
+ end
84
+
85
+ { 'RawTransaction' => inner }
86
+ end
87
+
88
+ batch.merge('RawTransactions' => prepared)
89
+ end
90
+
91
+ # The number of signatures a BatchSigners array carries: one per entry,
92
+ # or, for an entry signed by a signer list, one per nested signer.
93
+ #
94
+ # @param batch_signers [Array<Hash>, nil]
95
+ # @return [Integer]
96
+ def signature_count(batch_signers)
97
+ Array(batch_signers).sum do |entry|
98
+ signer = entry.is_a?(Hash) ? (entry['BatchSigner'] || entry) : {}
99
+ nested = signer['Signers']
100
+ nested.is_a?(Array) && !nested.empty? ? nested.size : 1
101
+ end
102
+ end
103
+
104
+ def check_mode!(flags)
105
+ mode = Integer(flags || 0) & MODE_FLAGS
106
+ return if mode.positive? && (mode & (mode - 1)).zero?
107
+
108
+ raise ArgumentError, 'A Batch needs exactly one of tfAllOrNothing, tfOnlyOne, ' \
109
+ 'tfUntilFailure and tfIndependent in Flags'
110
+ end
111
+
112
+ def unwrap(wrapper, index)
113
+ inner = wrapper.is_a?(Hash) && wrapper.keys == ['RawTransaction'] ? wrapper['RawTransaction'] : nil
114
+ inner = inner.to_h if inner.is_a?(XRPL::Transaction)
115
+ unless inner.is_a?(Hash)
116
+ raise ArgumentError, "RawTransactions[#{index}] must be { 'RawTransaction' => transaction }"
117
+ end
118
+
119
+ inner.dup
120
+ end
121
+
122
+ # On a ticket, Sequence is 0; rippled refuses a nonzero Sequence next to
123
+ # a TicketSequence (temSEQ_AND_TICKET). Refused before any sequence is
124
+ # handed out, so it cannot push the account's later sequences up.
125
+ def check_sequence_and_ticket!(transaction, label)
126
+ # Reading both values here refuses a malformed or out-of-range one in
127
+ # the checking pass, not after sequences were already fetched.
128
+ sequence = BinaryCodec.uint32_value(transaction['Sequence'])
129
+ BinaryCodec.uint32_value(transaction['TicketSequence'])
130
+ return if transaction['TicketSequence'].nil? || sequence.zero?
131
+
132
+ raise ArgumentError, "#{label} has both a Sequence and a TicketSequence; " \
133
+ 'on a ticket, Sequence is 0 (temSEQ_AND_TICKET)'
134
+ end
135
+
136
+ def check_inner!(inner, index)
137
+ prefix = "RawTransactions[#{index}]"
138
+ raise ArgumentError, "#{prefix} has no Account" if inner['Account'].nil?
139
+
140
+ check_sequence_and_ticket!(inner, prefix)
141
+ raise ArgumentError, "#{prefix} is a Batch; a Batch cannot nest" if inner['TransactionType'] == 'Batch'
142
+ if inner.key?('TxnSignature') || inner.key?('Signers')
143
+ raise ArgumentError, "#{prefix} must not be signed; the Batch is signed as a whole"
144
+ end
145
+ if inner.key?('Fee') && inner['Fee'].to_s != '0'
146
+ raise ArgumentError, "#{prefix} must have Fee \"0\"; the Batch pays the fees"
147
+ end
148
+ return unless inner.key?('SigningPubKey') && inner['SigningPubKey'].to_s != ''
149
+
150
+ raise ArgumentError, "#{prefix} must have an empty SigningPubKey"
151
+ end
152
+
153
+ # The outer account's inner transactions follow the outer one, which
154
+ # consumes its Sequence; when it uses a ticket instead, the account's
155
+ # sequence is still free. Every other account starts at its own.
156
+ def first_sequence(batch, account, sequence_for)
157
+ outer_sequence = BinaryCodec.uint32_value(batch['Sequence'])
158
+ if account == batch['Account'] && outer_sequence.positive? && batch['TicketSequence'].nil?
159
+ outer_sequence + 1
160
+ else
161
+ Integer(sequence_for.call(account))
162
+ end
163
+ end
164
+
165
+ private_class_method :check_mode!, :check_sequence_and_ticket!, :unwrap, :check_inner!, :first_sequence
166
+ end
167
+ end
data/lib/xrpl/client.rb CHANGED
@@ -326,15 +326,31 @@ module XRPL
326
326
  #
327
327
  # The fee follows the transaction type (see XRPL::Fee): an EscrowFinish
328
328
  # pays for its Fulfillment, AccountDelete, AMMCreate and VaultCreate cost
329
- # the owner reserve, a Batch pays for its inner transactions.
329
+ # the owner reserve, a Batch pays for its signatures and its inner
330
+ # transactions.
331
+ #
332
+ # A Batch also gets its inner transactions prepared (see XRPL::Batch):
333
+ # tfInnerBatchTxn, Fee "0", an empty SigningPubKey and their sequences.
334
+ # The BatchSigners sign over the finished Batch, so its fee has to be set
335
+ # before they exist; +batch_signatures:+ says how many there will be.
330
336
  #
331
337
  # @param transaction [Hash, XRPL::Transaction] the transaction to complete.
332
338
  # @param signers_count [Integer] number of signatures for multisign fee scaling.
339
+ # @param batch_signatures [Integer] for a Batch: the signatures its
340
+ # BatchSigners will carry, one per other account (or one per signer of
341
+ # an account signing with a signer list). Zero for a Batch of one account.
333
342
  # @return [Hash] a copy of the transaction with the missing fields filled in.
334
- def autofill(transaction, signers_count: 0)
343
+ # @raise [ArgumentError] for a Batch the ledger would refuse.
344
+ def autofill(transaction, signers_count: 0, batch_signatures: 0)
335
345
  tx = self.class.to_transaction_hash(transaction).dup
346
+ # On a ticket, Sequence is 0; the account's own would be refused
347
+ # (temSEQ_AND_TICKET).
348
+ tx['Sequence'] = 0 if tx['Sequence'].nil? && !tx['TicketSequence'].nil?
336
349
  tx['Sequence'] ||= fetch_sequence(tx.fetch('Account'))
337
- tx['Fee'] ||= calculate_fee(tx, signers_count)
350
+ if tx['TransactionType'] == 'Batch'
351
+ tx = Batch.prepare(tx, sequence_for: ->(account) { fetch_sequence(account) })
352
+ end
353
+ tx['Fee'] ||= calculate_fee(tx, signers_count, batch_signatures)
338
354
  tx['LastLedgerSequence'] ||= current_ledger_index + LEDGER_OFFSET
339
355
  tx
340
356
  end
@@ -449,11 +465,12 @@ module XRPL
449
465
  Integer(sequence)
450
466
  end
451
467
 
452
- def calculate_fee(tx, signers_count)
468
+ def calculate_fee(tx, signers_count, batch_signatures = 0)
453
469
  Fee.calculate(
454
470
  tx,
455
471
  base_fee: base_fee_drops,
456
472
  signers_count: signers_count.to_i,
473
+ batch_signatures: batch_signatures.to_i,
457
474
  max_fee: @max_fee_drops,
458
475
  owner_reserve: -> { owner_reserve_drops }
459
476
  ).to_s
data/lib/xrpl/fee.rb CHANGED
@@ -6,7 +6,7 @@ module XRPL
6
6
  # rippled charges most transactions the network's base fee, but not all of
7
7
  # them: an EscrowFinish pays for the size of its Fulfillment, AccountDelete,
8
8
  # AMMCreate and VaultCreate cost an owner reserve rather than a fee, a Batch
9
- # pays for its inner transactions, and the confidential MPT transactions
9
+ # pays for its signatures and its inner transactions, and the confidential MPT transactions
10
10
  # cost ten base fees. Multisigning adds one base fee per signature. The
11
11
  # rules are the ones xrpl.js applies in its autofill.
12
12
  #
@@ -40,8 +40,12 @@ module XRPL
40
40
  # @param owner_reserve [Integer, #call, nil] the owner reserve in drops,
41
41
  # or something that fetches it; only consulted for the reserve-priced
42
42
  # types
43
+ # @param batch_signatures [Integer] for a Batch: the signatures its
44
+ # BatchSigners will carry once collected. Ignored when the Batch already
45
+ # carries BatchSigners, which are counted instead.
43
46
  # @return [Integer] the fee in drops, rounded up
44
- def calculate(transaction, base_fee:, signers_count: 0, max_fee: MAX_FEE_DROPS, owner_reserve: nil)
47
+ def calculate(transaction, base_fee:, signers_count: 0, max_fee: MAX_FEE_DROPS, owner_reserve: nil,
48
+ batch_signatures: 0)
45
49
  tx = transaction.respond_to?(:to_h) ? transaction.to_h : transaction
46
50
  type = tx['TransactionType'] || tx[:TransactionType]
47
51
  base_fee = Integer(base_fee)
@@ -60,7 +64,8 @@ module XRPL
60
64
  calculate(raw['RawTransaction'] || raw, base_fee: base_fee, max_fee: max_fee,
61
65
  owner_reserve: owner_reserve)
62
66
  end
63
- fee = base_fee * 2 + inner
67
+ # XLS-56 §2.2: (2 + signatures in BatchSigners) × base fee + inner fees
68
+ fee = base_fee * (2 + batch_signature_count(tx, batch_signatures)) + inner
64
69
  elsif CONFIDENTIAL_MPT_TYPES.include?(type)
65
70
  fee += base_fee * CONFIDENTIAL_MPT_MULTIPLIER
66
71
  end
@@ -72,6 +77,13 @@ module XRPL
72
77
  fee.ceil
73
78
  end
74
79
 
80
+ def batch_signature_count(batch, expected)
81
+ signers = batch['BatchSigners']
82
+ return Integer(expected || 0) if signers.nil? || signers.empty?
83
+
84
+ Batch.signature_count(signers)
85
+ end
86
+
75
87
  def resolve_owner_reserve(owner_reserve, type)
76
88
  reserve = owner_reserve.respond_to?(:call) ? owner_reserve.call : owner_reserve
77
89
  raise ArgumentError, "#{type} costs the owner reserve, which was not given" if reserve.nil?
data/lib/xrpl/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module XRPL
4
- VERSION = '0.8.0'
4
+ VERSION = '0.9.0'
5
5
  end
data/lib/xrpl-ruby.rb CHANGED
@@ -44,5 +44,6 @@ require_relative 'xrpl/model'
44
44
  require_relative 'xrpl/transaction'
45
45
  require_relative 'xrpl/ledger_entry'
46
46
  require_relative 'xrpl/fee'
47
+ require_relative 'xrpl/batch'
47
48
  require_relative 'xrpl/client'
48
49
  require_relative 'xrpl/faucet'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: xrpl-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexander Busse
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-24 00:00:00.000000000 Z
11
+ date: 2026-11-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -169,6 +169,7 @@ files:
169
169
  - lib/key-pairs/secp256k1.rb
170
170
  - lib/wallet/wallet.rb
171
171
  - lib/xrpl-ruby.rb
172
+ - lib/xrpl/batch.rb
172
173
  - lib/xrpl/client.rb
173
174
  - lib/xrpl/faucet.rb
174
175
  - lib/xrpl/fee.rb