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 +4 -4
- data/CHANGELOG.md +179 -0
- data/README.md +104 -1
- data/lib/binary-codec/binary_codec.rb +100 -0
- data/lib/binary-codec/serdes/binary_serializer.rb +7 -1
- data/lib/binary-codec/types/st_array.rb +7 -2
- data/lib/binary-codec/types/st_object.rb +7 -11
- data/lib/binary-codec/types/uint.rb +80 -14
- data/lib/wallet/wallet.rb +148 -6
- data/lib/xrpl/batch.rb +167 -0
- data/lib/xrpl/client.rb +21 -4
- data/lib/xrpl/fee.rb +15 -3
- data/lib/xrpl/version.rb +1 -1
- data/lib/xrpl-ruby.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1b52d066f91b01da1ccdbbe8229cadc3b40b54d79a1234f0ac96f0f131159a06
|
|
4
|
+
data.tar.gz: 57fc035044049efff0bae7418e48e6ffee589a2cec5d763b056b07d6c23d0b10
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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([
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
break if field.name == OBJECT_END_MARKER
|
|
106
|
+
field = parser.read_field
|
|
107
|
+
break if field.name == OBJECT_END_MARKER
|
|
107
108
|
|
|
108
|
-
|
|
109
|
+
associated_value = parser.read_field_value(field)
|
|
109
110
|
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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.
|
|
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' =>
|
|
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
|
|
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
|
-
|
|
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['
|
|
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
|
-
|
|
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
data/lib/xrpl-ruby.rb
CHANGED
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.
|
|
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-
|
|
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
|