antd 0.1.0 → 0.3.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: 44a5ae69094c720b34c8d440aa9072ab3d83e192fc790351c9d08ab8d4ef6f8f
4
- data.tar.gz: '0329577dd7ac059a32b8f6bd47369e0452d3f13679f8c37e5da245359772b339'
3
+ metadata.gz: d8d53161e99f51956a6ba0b004669faf4a0d28e7e6998e8abe9fdbec35f83bd2
4
+ data.tar.gz: d9a2b3e9cb282eaf712ca3783c92cff130dd1958106a62c1c88637e686c223a1
5
5
  SHA512:
6
- metadata.gz: d36cc92042a95ea03a429f5a706fe208d5b4c9c9ff030bcaf7fe72014e7e50feb15a0dd8b26426183de8581d9a90357bdc326dac959ca7ca590a3efaf5726914
7
- data.tar.gz: ec4d4aadcc549fde1a6214030934aedefe9ca948dc4c72177bfa82d237c85819eb4edf0890e8940444d63113208760fa222850ce423a9d9a77e1e5fd15a06d0b
6
+ metadata.gz: b621b9cf29751e81e87dbb03f3377898732ae3eb56bd10e165d39d6c597f1fcf67ccb31ffdffc5aee5fe7210fa58658e2080d37f3d54fd4759c1a93f9e9f50ee
7
+ data.tar.gz: b1f17116698f10e199db336aaf4bd415c6e750f102299408457692aeb602f06d238c339ce4a58fcfa679261d7d74fef8122edcaa03496b0a1b0dcee4bb1b01a7
data/README.md CHANGED
@@ -18,7 +18,7 @@ gem install antd
18
18
 
19
19
  ## Compatibility
20
20
 
21
- This gem talks to a running [antd](https://github.com/WithAutonomi/ant-sdk/tree/main/antd) daemon; it does not join the network itself. Ruby 3.1+. Tested against antd 0.12.x. The REST client has no runtime dependencies; the gRPC transport (`Antd::GrpcClient`) needs the `grpc` gem, which is deliberately not a runtime dependency of this gem — add `gem "grpc"` yourself to use it.
21
+ This gem talks to a running [antd](https://github.com/WithAutonomi/ant-sdk/tree/main/antd) daemon; it does not join the network itself. Ruby 3.1+. Tested against antd 0.12.x; the pointer methods need antd 0.16.0 or later, the first release with pointers. The REST client has no runtime dependencies; the gRPC transport (`Antd::GrpcClient`) needs the `grpc` gem, which is deliberately not a runtime dependency of this gem — add `gem "grpc"` yourself to use it.
22
22
 
23
23
  ## Quick Start
24
24
 
@@ -53,18 +53,15 @@ Install the gRPC gem (listed as an optional development dependency):
53
53
  gem install grpc grpc-tools
54
54
  ```
55
55
 
56
- Generate the Ruby protobuf/gRPC stubs from the proto definitions:
56
+ The Ruby protobuf/gRPC stubs in `lib/antd/v1/` are generated from the proto
57
+ definitions and committed. After changing the protos, regenerate them and
58
+ commit the result (CI fails when they drift):
57
59
 
58
60
  ```bash
59
- grpc_tools_ruby_protoc \
60
- -I../../antd/proto \
61
- --ruby_out=lib --grpc_out=lib \
62
- antd/v1/common.proto antd/v1/health.proto antd/v1/data.proto \
63
- antd/v1/chunks.proto antd/v1/files.proto
61
+ gem install grpc-tools -v 1.84.0 # the version the script pins
62
+ antd-ruby/scripts/generate-protos.sh
64
63
  ```
65
64
 
66
- The generated files are expected under `lib/antd/v1/`.
67
-
68
65
  ### Usage
69
66
 
70
67
  ```ruby
@@ -87,7 +84,24 @@ puts "Retrieved: #{data}"
87
84
  ```
88
85
 
89
86
  The `GrpcClient` raises the same `Antd::AntdError` hierarchy as the REST
90
- client, translating gRPC status codes to the appropriate error subclass.
87
+ client, translating gRPC status codes to the appropriate error subclass
88
+ (an `ABORTED` whose status details — `GRPC::BadStatus#details`, the text
89
+ the daemon sent — start with `Partial upload:` becomes
90
+ `Antd::PartialUploadError`, with the chunk counts, `retryable` and
91
+ `retention_known` parsed from that text — see
92
+ [Partial uploads](#partial-uploads); any other
93
+ `ABORTED`, including one that only mentions `Partial upload:` later in its
94
+ text, stays a generic `Antd::AntdError`).
95
+
96
+ The pointer and close-group errors share gRPC status codes with older ones, so
97
+ the daemon names them in an `x-antd-error-code` trailer
98
+ (`GRPC::BadStatus#metadata`). The client reads it on every call and, when it
99
+ agrees with the status code, raises the same typed error as REST:
100
+ `FAILED_PRECONDITION` + `POINTER_FINAL` → `PointerFinalError`, + `POINTER_FORKED`
101
+ → `PointerForkedError`, `ABORTED` + `CONFLICT` → `ConflictError`, and
102
+ `UNAVAILABLE` + `CLOSE_GROUP_SHORTFALL` → `CloseGroupShortfallError` (`retryable`
103
+ when the details close with `(paid attempt retained...)`). Without the trailer,
104
+ `FAILED_PRECONDITION` stays a `PaymentError` and `UNAVAILABLE` a `NetworkError`.
91
105
 
92
106
  > **Note:** Wallet operations (address, balance, approve) and payment_mode are available via REST only.
93
107
 
@@ -146,6 +160,62 @@ client = Antd::Client.new(base_url: "http://custom-host:9090", timeout: 30)
146
160
  | `file_get_public(address, dest_path)` | Download a public file by address |
147
161
  | `file_cost(path, is_public, payment_mode: :auto)` | Estimate upload cost — returns `UploadCostEstimate` with size, chunks, gas, payment mode |
148
162
 
163
+ ### Pointers
164
+
165
+ A pointer is a small, owner-signed, mutable reference. Its address is derived
166
+ from the owner's key and never changes; what it points at (a chunk, such as a
167
+ public file's data map, or another pointer) can be changed by the owner, and
168
+ the owner can hand it over to another pointer for good. The daemon holds the
169
+ owner keys, loaded at startup with `antd --pointer-keys <file-or-dir>` (files
170
+ in the format `ant pointer keygen` writes), so every write names the pointer by
171
+ its address (`owner`), never by a key. An address the daemon holds no key for
172
+ is a `BadRequestError`.
173
+
174
+ **Every write pays** (about the price of one chunk), and every attempt pays
175
+ again. The SDK never retries a request, and you should not retry
176
+ `pointer_create`, `pointer_update`, `pointer_transfer` or `pointer_finalize`
177
+ automatically either. Reads are free to retry; one during an update or churn
178
+ can fail with `CloseGroupShortfallError`.
179
+
180
+ Both `Antd::Client` and `Antd::GrpcClient` have these methods:
181
+
182
+ | Method | Description |
183
+ |--------|-------------|
184
+ | `pointer_keys` | Addresses of the pointers this daemon holds keys for, so can write — `Array<String>` |
185
+ | `pointer_create(owner, target, kind: nil)` | Create a pointer, paid by the daemon wallet — returns `PointerWriteResult` (`address`, `state_id`, `counter`). `AlreadyExistsError`, before paying, if it exists. `kind` is `PointerKind::CHUNK` (the default) or `PointerKind::POINTER` |
186
+ | `pointer_update(owner, target, kind: nil)` | Point a pointer at a new target, creating it if needed — `PointerWriteResult`. `PointerFinalError`, usually before paying, once it was handed over or frozen |
187
+ | `pointer_transfer(owner, recipient)` | Hand a pointer over for good to the recipient's pointer, which must exist. Irreversible. Returns `PointerTransferResult` with the close group's `finality` (nil, with `finality_error`, if that check failed; the transfer stands) |
188
+ | `pointer_get(address)` | Read a pointer — `PointerRecord` (`counter`, `target`, `kind`/`kind_tag`, `is_final` / `final?`, `transferred_to`, the owner's public key). `NotFoundError` if there is none |
189
+ | `pointer_resolve(address)` | Follow a chain of pointers to the target at its end — `PointerTarget` (`kind`, `kind_tag`, `target`) |
190
+ | `pointer_finality(address)` | Ask the whole close group where a pointer stands — `PointerFinality`. Check `final?` before relying on a transfer |
191
+ | `pointer_controller(address)` | Follow transfers to the pointer whose owner now decides — `PointerController` (`pointer`, `transfers`, `frozen`) |
192
+ | `pointer_prepare(owner, op, target: nil, kind: nil, recipient: nil, include_signed_quotes: false)` | External signer: the daemon signs a `PointerOp::CREATE`, `UPDATE` or `TRANSFER` and quotes it — `PointerPrepareResult` (wave-batch `payments` for `payForQuotes()`; `signed_quotes` when asked). Needs no daemon wallet. Other writes to the pointer raise `ConflictError` until it is finalized or expires (one hour) |
193
+ | `pointer_finalize(upload_id, tx_hashes)` | External signer: store the prepared write once paid — `PointerFinalizeResult`. A write that falls short after payment raises `CloseGroupShortfallError` with `retryable`: repeat the same call to store it against the same payment |
194
+
195
+ Counters are `Integer`s. The daemon sends them as decimal strings, because a
196
+ final state (a transfer or a freeze) sits at `Antd::POINTER_FINAL_COUNTER`
197
+ (2^64 − 1), past what a JSON number keeps exactly in many languages. A counter
198
+ that is not a decimal u64 raises `InternalError`; it never reads as 0.
199
+
200
+ `PointerFinality#status` is one of the `Antd::PointerFinalityStatus` strings:
201
+ `OPEN` (with the corroborated `counter`, or nil), `SETTLING`, `UNCONFIRMED` and
202
+ `FINAL` (with the one final `state`), or `FORKED` (with every final state in
203
+ `states` and the `majority`). Only `FINAL` means a transfer is beyond dispute.
204
+ A status a newer daemon adds is kept verbatim (`status_known?` is false), so
205
+ give a `case` over it an `else` branch. Unset fields read as `nil` on both
206
+ transports: REST sends `null`, gRPC an empty string or no message.
207
+
208
+ The daemon accepts addresses with a `0x` prefix but always returns bare hex;
209
+ normalise before comparing.
210
+
211
+ ```ruby
212
+ owner = client.pointer_keys.first
213
+ client.pointer_create(owner, client.data_put_public("v1").address) # pays
214
+ client.pointer_update(owner, client.data_put_public("v2").address) # pays again
215
+ puts client.data_get_public(client.pointer_resolve(owner).target) # "v2"
216
+ puts client.pointer_finality(owner).status # "open"
217
+ ```
218
+
149
219
  ## Error Handling
150
220
 
151
221
  All errors inherit from `Antd::AntdError` and can be caught by type:
@@ -172,6 +242,151 @@ end
172
242
  | `TooLargeError` | 413 | Payload too large |
173
243
  | `InternalError` | 500 | Server error |
174
244
  | `NetworkError` | 502 | Network unreachable |
245
+ | `PartialUploadError` | 502 (`code: "PARTIAL_UPLOAD"`) | Finalize stored some chunks but not all — subclass of `NetworkError` |
246
+ | `ServiceUnavailableError` | 503 | Wallet not configured |
247
+ | `PointerFinalError` | 409 (`code: "POINTER_FINAL"`) / gRPC `FAILED_PRECONDITION` | The pointer is transferred or frozen; nothing can change it |
248
+ | `PointerForkedError` | 409 (`code: "POINTER_FORKED"`) / gRPC `FAILED_PRECONDITION` | The owner signed two final states and no majority holds either — subclass of `ForkError` |
249
+ | `ConflictError` | 409 (`code: "CONFLICT"`) / gRPC `ABORTED` | A pointer write while a prepared write to it awaits payment, or an update that lost to a newer state |
250
+ | `CloseGroupShortfallError` | 503 (`code: "CLOSE_GROUP_SHORTFALL"`) / gRPC `UNAVAILABLE` | Too few of the close group answered or stored; `retryable` is true only for a pointer finalize that kept the paid state |
251
+
252
+ The pointer errors are read from the error body's `code` (gRPC: the
253
+ `x-antd-error-code` trailer), so a 409 with one of those codes is not an
254
+ `AlreadyExistsError`. `CloseGroupShortfallError` is not only for pointers: a
255
+ daemon with the pointer API reports a close group that fell short on any call
256
+ (a chunk store, say) this way, where older daemons sent a 500
257
+ (`InternalError`). It is not a `ServiceUnavailableError`, so rescue it by name.
258
+
259
+ ### Partial uploads
260
+
261
+ A `finalize_upload` / `finalize_merkle_upload` / `finalize_chunk_upload` call
262
+ can fail *after* the external signer has paid: some chunks store, others miss
263
+ quorum after the daemon's own retries. The daemon reports this as HTTP `502`
264
+ with `code: "PARTIAL_UPLOAD"` (gRPC `ABORTED`), and the SDK raises
265
+ `Antd::PartialUploadError` carrying `chunks_stored`, `chunks_failed`,
266
+ `total_chunks`, `retryable` and `retention_known`. The on-chain payment
267
+ persists and the stored chunks stay on the network; what to do next depends
268
+ on what the daemon said about the paid attempt:
269
+
270
+ - **`retryable`** (antd >= 0.14.0; implies `retention_known`) — the daemon
271
+ kept the paid attempt under the same `upload_id`. Call the **same finalize
272
+ method again with the same `upload_id` and payment artefacts** to store the
273
+ remainder against the same payment: no re-prepare, no second signature, no
274
+ double payment. Bound the loop — a persistent failure raises this error on
275
+ every call — so cap the attempts and treat a `chunks_failed` that stops
276
+ shrinking as stuck. The retained attempt expires with the daemon's
277
+ pending-upload TTL.
278
+ - **`retention_known && !retryable`** — the daemon confirmed nothing was
279
+ retained (a merkle finalize with deliberately unpaid batches). Re-prepare
280
+ the same content: already-stored chunks are skipped, so the retry pays only
281
+ for the remainder.
282
+ - **`!retention_known`** — retention is unknown: the error did not say, in a
283
+ form the SDK could read, whether the paid attempt was kept. The daemon may
284
+ still hold it (it records the resume handle before it returns the error).
285
+ Stop automatic recovery, keep the `upload_id` and the original payment
286
+ artefacts (tx hashes, or the merkle winner pool hash), and reconcile before
287
+ re-preparing or paying again. **Never pay again on this signal alone.**
288
+ Daemons older than 0.14.0 never send `retryable`, so their REST partial
289
+ uploads read as unknown.
290
+
291
+ Over REST, `retention_known` is true only when the body's `retryable` is a
292
+ JSON boolean (`true` or `false`); absent, `null` or any other type reads as
293
+ unknown. Over gRPC there is no structured body, so the counts and flags are
294
+ parsed from the status text,
295
+ `Partial upload: <stored>/<total> chunks stored, <failed> failed after retries: <reason> (<hint>)`.
296
+ `retention_known` is true only when the text starts with that counts
297
+ pattern, all three counts parse (each within the daemon's u64 range), and the
298
+ text ends with one of the daemon's two hints: `(paid attempt retained...)`
299
+ sets `retryable`, and `(stored chunks persist; re-prepare the same content...)`
300
+ means the daemon confirmed nothing was retained (daemons older than 0.14.0
301
+ write only this one). If the counts cannot be read, all three are `0` and
302
+ both flags are `false`. Readable counts with a missing, truncated or
303
+ unrecognised hint keep the counts, but both flags stay `false`: retention is
304
+ unknown, so stop and reconcile rather than re-prepare.
305
+
306
+ `PartialUploadError` subclasses `NetworkError` (the 502 mapping), so existing
307
+ `rescue Antd::NetworkError` blocks keep catching it; rescue the subclass first
308
+ to handle it specifically.
309
+
310
+ ```ruby
311
+ MAX_ATTEMPTS = 5
312
+ last_failed = nil
313
+ attempt = 0
314
+ begin
315
+ attempt += 1
316
+ result = client.finalize_upload(prep.upload_id, tx_hashes)
317
+ rescue Antd::PartialUploadError => e
318
+ # Not resumable. With retention_known, nothing was retained: re-prepare.
319
+ # Without it, retention is unknown: stop, keep upload_id + tx_hashes and
320
+ # reconcile before re-preparing or paying again.
321
+ raise unless e.retryable
322
+ stuck = !last_failed.nil? && e.chunks_failed >= last_failed
323
+ raise if attempt >= MAX_ATTEMPTS || stuck # bounded: same payment, same upload_id
324
+ last_failed = e.chunks_failed
325
+ sleep(2 * attempt)
326
+ retry
327
+ end
328
+ ```
329
+
330
+ See [`docs/external-signer-flow.md` section 6](../docs/external-signer-flow.md#6-retry-a-partial-store--same-upload_id-same-payment)
331
+ for the daemon contract and `examples/07_external_signer.rb` for a
332
+ `finalize_with_retry` helper.
333
+
334
+ ## Manifests
335
+
336
+ A manifest (ADR-0006) describes a set of files in one `.ant` file or
337
+ `ant://` link: each entry has an optional path and size, and either an
338
+ embedded DataMap (from a private upload; the manifest is then the key to the
339
+ file) or a public file's DataMap address. The daemon builds and decodes
340
+ manifests, so the SDK never handles the format itself. These methods need
341
+ antd 0.16.0 or later.
342
+
343
+ **Nothing is uploaded or paid for.** Building one from DataMaps and addresses
344
+ you already hold is free. Embedding roots (on build) and `resolve` (on
345
+ decode) read from the network, so either can time out; retrying is safe.
346
+
347
+ Both `Antd::Client` and `Antd::GrpcClient` have these methods:
348
+
349
+ | Method | Description |
350
+ |--------|-------------|
351
+ | `build_manifest(entries, name: nil, torrent_info_hashes: nil, embed_roots: true)` | Build a manifest — returns `BuiltManifest` (`manifest`, the raw `.ant` bytes; `link`, or nil when it would exceed 1,500 bytes; `entries`, sorted by effective name). Each entry is a `ManifestEntryInput` or a Hash with `path`, `size`, and exactly one of `data_map` (hex) or `address` (hex); both or neither raises `BadRequestError` (400, the daemon's wording) before anything is sent. By default the daemon resolves each shrunk DataMap and embeds its root when it is at most 64 KiB; `embed_roots: false` embeds them as given |
352
+ | `decode_manifest(manifest, resolve: false)` | Decode `.ant` bytes — returns `DecodedManifest` (`source`, `name`, `torrent_info_hash_v1`/`_v2`, `entries`) |
353
+ | `decode_manifest_link(link, resolve: false)` | Decode an `ant://manifest/` link, a file link or a bare 64-hex address (a `ManifestSource::FILE_LINK`: one public entry, no path) |
354
+
355
+ Each entry comes back as a `ManifestEntryInfo`: `effective_name` (the path,
356
+ or the content address when there is none), `path`, `size` (what the
357
+ manifest records: the creator's claim), `kind` (`ManifestEntryKind::EMBEDDED`
358
+ or `PUBLIC`), `content_address`, `data_map` (decode only, embedded entries
359
+ only, in the hex form `data_get` and `file_get` take), `exact_size`, and
360
+ `error`. With `resolve: true` the daemon fetches each DataMap (not the
361
+ content) to fill in `exact_size`; an entry that fails gets an `error` instead
362
+ of failing the call.
363
+
364
+ `manifest` is always raw bytes: the SDK base64-encodes it for REST and sends
365
+ it as-is over gRPC. Sizes are `Integer`s, and anything a manifest leaves unset
366
+ is `nil` on both transports, so a missing `size` or `exact_size` is `nil`,
367
+ never `0`. An entry the daemon rejects (a bad path such as `../x`, a malformed
368
+ DataMap or address) raises `BadRequestError` (gRPC `INVALID_ARGUMENT`) with
369
+ the daemon's message, which names `entries[i]`. The SDK's own checks (both or
370
+ neither of `data_map` and `address`, an empty manifest or link) raise the same
371
+ error with the daemon's exact text, so the two cannot be told apart.
372
+
373
+ Over gRPC, a manifest can be up to 64 MiB, past gRPC's 4 MiB default, so the
374
+ client talks to ManifestService on a channel of its own with the send and
375
+ receive limits raised to `Antd::MANIFEST_GRPC_MAX_MESSAGE` (256 MiB). The
376
+ other services keep the defaults.
377
+
378
+ ```ruby
379
+ a = client.data_put_public("one").address # pays
380
+ b = client.data_put_public("two").address # pays
381
+ built = client.build_manifest(
382
+ [{ path: "notes/one.txt", address: a }, { path: "notes/two.txt", address: b }],
383
+ name: "notes"
384
+ ) # free
385
+ File.binwrite("notes.ant", built.manifest)
386
+ client.decode_manifest_link(built.link, resolve: true).entries.each do |e|
387
+ puts "#{e.effective_name}: #{e.exact_size} bytes"
388
+ end
389
+ ```
175
390
 
176
391
  ## Examples
177
392
 
@@ -182,3 +397,6 @@ See the [examples/](examples/) directory:
182
397
  - `03_chunks.rb` — Chunk put/get
183
398
  - `04_files.rb` — File upload and download
184
399
  - `06_private_data.rb` — Private data put/get
400
+ - `07_external_signer.rb` — External-signer prepare/pay/finalize with a bounded partial-upload retry
401
+ - `08_pointers.rb` — Create a pointer, update it, resolve it and check its finality. Run with `ANTD_POINTER_OWNER=<address>` (one of `pointer_keys`); `ANTD_BASE_URL` overrides the daemon URL. Each write pays
402
+ - `09_manifests.rb` — Store two pieces of public data, build a manifest of them, print its link and entries, and decode the link with `resolve`. `ANTD_BASE_URL` overrides the daemon URL. The two uploads pay; the manifest calls do not
data/lib/antd/client.rb CHANGED
@@ -217,6 +217,16 @@ module Antd
217
217
  # @param tx_hashes [Hash<String, String>] map of quote_hash to tx_hash
218
218
  # @return [String] network address of the stored chunk
219
219
  # (matches +PrepareChunkResult#address+)
220
+ # @raise [PartialUploadError] when some chunks stored and others did not
221
+ # (HTTP 502, +code: "PARTIAL_UPLOAD"+). The payment persists. If
222
+ # +retryable+, call this method again with the same arguments to store
223
+ # the remainder against the same payment (antd >= 0.14.0). If
224
+ # +retention_known+ but not +retryable+, nothing was retained:
225
+ # re-prepare the same content, which skips stored chunks. If
226
+ # +retention_known+ is false, retention is unknown: stop, keep the
227
+ # upload_id and payment artefacts, and reconcile before re-preparing
228
+ # or paying again. See +PartialUploadError+ and
229
+ # docs/external-signer-flow.md section 6.
220
230
  def finalize_chunk_upload(upload_id, tx_hashes)
221
231
  j = do_json(:post, "/v1/chunks/finalize", {
222
232
  upload_id: upload_id,
@@ -365,6 +375,16 @@ module Antd
365
375
  # @param upload_id [String] the upload ID from prepare_upload
366
376
  # @param tx_hashes [Hash<String, String>] map of quote_hash to tx_hash
367
377
  # @return [FinalizeUploadResult]
378
+ # @raise [PartialUploadError] when some chunks stored and others did not
379
+ # (HTTP 502, +code: "PARTIAL_UPLOAD"+). The payment persists. If
380
+ # +retryable+, call this method again with the same arguments to store
381
+ # the remainder against the same payment (antd >= 0.14.0). If
382
+ # +retention_known+ but not +retryable+, nothing was retained:
383
+ # re-prepare the same content, which skips stored chunks. If
384
+ # +retention_known+ is false, retention is unknown: stop, keep the
385
+ # upload_id and payment artefacts, and reconcile before re-preparing
386
+ # or paying again. See +PartialUploadError+ and
387
+ # docs/external-signer-flow.md section 6.
368
388
  def finalize_upload(upload_id, tx_hashes)
369
389
  j = do_json(:post, "/v1/upload/finalize", {
370
390
  upload_id: upload_id,
@@ -378,6 +398,16 @@ module Antd
378
398
  # @param winner_pool_hash [String] hash of the winning pool commitment
379
399
  # @param store_data_map [Boolean] whether to store the data map on-network
380
400
  # @return [FinalizeUploadResult]
401
+ # @raise [PartialUploadError] when some chunks stored and others did not
402
+ # (HTTP 502, +code: "PARTIAL_UPLOAD"+). The payment persists. If
403
+ # +retryable+, call this method again with the same arguments to store
404
+ # the remainder against the same payment (antd >= 0.14.0). If
405
+ # +retention_known+ but not +retryable+, nothing was retained:
406
+ # re-prepare the same content, which skips stored chunks. If
407
+ # +retention_known+ is false, retention is unknown: stop, keep the
408
+ # upload_id and payment artefacts, and reconcile before re-preparing
409
+ # or paying again. See +PartialUploadError+ and
410
+ # docs/external-signer-flow.md section 6.
381
411
  def finalize_merkle_upload(upload_id, winner_pool_hash, store_data_map: false)
382
412
  j = do_json(:post, "/v1/upload/finalize", {
383
413
  upload_id: upload_id,
@@ -387,8 +417,250 @@ module Antd
387
417
  parse_finalize_response(j)
388
418
  end
389
419
 
420
+ # --- Pointers ---
421
+ #
422
+ # Owner-signed, paid, mutable references (antd 0.16.0 or
423
+ # later). The daemon signs with owner keys it loaded at
424
+ # startup (+antd --pointer-keys <file-or-dir>+), so a write names the
425
+ # pointer by its address (+owner+); +pointer_keys+ lists the pointers it
426
+ # can write.
427
+ #
428
+ # Every write pays, about the price of one chunk, and every attempt pays
429
+ # again. This client never retries a request, and callers must not retry
430
+ # +pointer_create+, +pointer_update+, +pointer_transfer+ or
431
+ # +pointer_finalize+ automatically (the one exception: a finalize that
432
+ # raised +CloseGroupShortfallError+ with +retryable+). Reads are free to
433
+ # retry.
434
+ #
435
+ # Addresses are 64 hex characters. The daemon accepts a 0x prefix but
436
+ # always returns bare hex, so normalise before comparing.
437
+
438
+ # The addresses of the pointers this daemon holds owner keys for. Only
439
+ # these can be written.
440
+ # @return [Array<String>]
441
+ def pointer_keys
442
+ j = do_json(:get, "/v1/pointers/keys")
443
+ (j["keys"] || []).map { |k| k["address"] || "" }
444
+ end
445
+
446
+ # Create the pointer at +owner+, pointing at +target+, paid by the daemon
447
+ # wallet. Pays; do not retry automatically.
448
+ # @param owner [String] address of the pointer to write
449
+ # @param target [String] address to point at
450
+ # @param kind [String, nil] +PointerKind::CHUNK+ (the daemon's default when
451
+ # nil) or +PointerKind::POINTER+
452
+ # @return [PointerWriteResult]
453
+ # @raise [AlreadyExistsError] before paying, if the pointer exists
454
+ # @raise [BadRequestError] if the daemon holds no key for +owner+
455
+ def pointer_create(owner, target, kind: nil)
456
+ pointer_write("/v1/pointers", owner, target, kind)
457
+ end
458
+
459
+ # Point the pointer at +owner+ at +target+, paid by the daemon wallet,
460
+ # creating it if it does not exist. Pays; do not retry automatically.
461
+ # @param owner [String]
462
+ # @param target [String]
463
+ # @param kind [String, nil] see +pointer_create+
464
+ # @return [PointerWriteResult]
465
+ # @raise [PointerFinalError] if the pointer was handed over or frozen
466
+ # (usually before paying; see +PointerFinalError+)
467
+ # @raise [ConflictError] if a prepared write to it awaits payment (refused
468
+ # before paying), or the update lost to a newer state while in flight
469
+ # (possibly after paying); read the pointer before writing again
470
+ def pointer_update(owner, target, kind: nil)
471
+ pointer_write("/v1/pointers/update", owner, target, kind)
472
+ end
473
+
474
+ # Hand the pointer at +owner+ over to the pointer at +recipient+, for
475
+ # good, paid by the daemon wallet. Readers of +owner+'s address are
476
+ # redirected to the recipient's pointer, which only its owner can move.
477
+ # Irreversible once stored. Pays; do not retry automatically.
478
+ #
479
+ # Refused before paying if +recipient+ does not exist or leads back
480
+ # (+BadRequestError+), and usually if the pointer is already final
481
+ # (+PointerFinalError+). A +CloseGroupShortfallError+ means too few of the
482
+ # close group answered or stored it. That can happen before paying (the
483
+ # transfer checks every peer first) or after, so check the pointer with
484
+ # +pointer_finality+ before paying again.
485
+ #
486
+ # @param owner [String]
487
+ # @param recipient [String] address of the recipient's pointer
488
+ # @return [PointerTransferResult]
489
+ def pointer_transfer(owner, recipient)
490
+ j = do_json(:post, "/v1/pointers/transfer", { owner: owner, recipient: recipient })
491
+ PointerTransferResult.new(
492
+ address: j["address"] || "",
493
+ state_id: j["state_id"] || "",
494
+ finality: j["finality"].is_a?(Hash) ? parse_pointer_finality(j["finality"]) : nil,
495
+ finality_error: presence(j["finality_error"])
496
+ )
497
+ end
498
+
499
+ # Read the pointer at +address+.
500
+ # @param address [String]
501
+ # @return [PointerRecord]
502
+ # @raise [NotFoundError] if no pointer exists there
503
+ def pointer_get(address)
504
+ j = do_json(:get, "/v1/pointers/#{address}")
505
+ PointerRecord.new(
506
+ address: j["address"] || "",
507
+ owner: j["owner"] || "",
508
+ counter: Antd.parse_pointer_counter("counter", j["counter"]),
509
+ kind: j["kind"] || "",
510
+ kind_tag: (j["kind_tag"] || 0).to_i,
511
+ target: j["target"] || "",
512
+ state_id: j["state_id"] || "",
513
+ is_final: j["is_final"] == true,
514
+ transferred_to: presence(j["transferred_to"])
515
+ )
516
+ end
517
+
518
+ # Follow a chain of pointers from +address+ to the target at its end.
519
+ # @param address [String]
520
+ # @return [PointerTarget]
521
+ # @raise [NotFoundError] if a pointer on the chain does not exist
522
+ def pointer_resolve(address)
523
+ j = do_json(:get, "/v1/pointers/#{address}/resolve")
524
+ PointerTarget.new(kind: j["kind"] || "", kind_tag: (j["kind_tag"] || 0).to_i, target: j["target"] || "")
525
+ end
526
+
527
+ # Ask the whole close group where the pointer at +address+ stands. Check
528
+ # it before relying on a transfer: only +PointerFinality#final?+ means the
529
+ # transfer is beyond dispute.
530
+ # @param address [String]
531
+ # @return [PointerFinality]
532
+ def pointer_finality(address)
533
+ parse_pointer_finality(do_json(:get, "/v1/pointers/#{address}/finality"))
534
+ end
535
+
536
+ # Follow the transfers from +address+ to the pointer whose owner now
537
+ # decides what it resolves to.
538
+ # @param address [String]
539
+ # @return [PointerController]
540
+ def pointer_controller(address)
541
+ j = do_json(:get, "/v1/pointers/#{address}/controller")
542
+ PointerController.new(
543
+ pointer: j["pointer"] || "",
544
+ transfers: (j["transfers"] || []).grep(String),
545
+ frozen: j["frozen"] == true
546
+ )
547
+ end
548
+
549
+ # External signer: sign a pointer write with the daemon's loaded owner key
550
+ # and quote it, without paying. The external signer pays +payments+ with
551
+ # +payForQuotes()+ and then calls +pointer_finalize+. The daemon needs no
552
+ # wallet. While the prepared write awaits payment (up to an hour), other
553
+ # writes to the same pointer raise +ConflictError+.
554
+ #
555
+ # @param owner [String] address of the pointer to write
556
+ # @param op [String] +PointerOp::CREATE+, +UPDATE+ or +TRANSFER+
557
+ # @param target [String, nil] create / update: the address to point at
558
+ # @param kind [String, nil] create / update: see +pointer_create+
559
+ # @param recipient [String, nil] transfer: the recipient's pointer
560
+ # @param include_signed_quotes [Boolean] also return each quote in full
561
+ # signed form (+PointerPrepareResult#signed_quotes+)
562
+ # @return [PointerPrepareResult]
563
+ def pointer_prepare(owner, op, target: nil, kind: nil, recipient: nil, include_signed_quotes: false)
564
+ body = { owner: owner, op: op }
565
+ body[:target] = target unless target.nil?
566
+ body[:kind] = kind unless kind.nil?
567
+ body[:recipient] = recipient unless recipient.nil?
568
+ body[:include_signed_quotes] = true if include_signed_quotes
569
+ j = do_json(:post, "/v1/pointers/prepare", body)
570
+ PointerPrepareResult.new(
571
+ upload_id: j["upload_id"] || "",
572
+ op: j["op"] || "",
573
+ address: j["address"] || "",
574
+ state_id: j["state_id"] || "",
575
+ counter: Antd.parse_pointer_counter("counter", j["counter"]),
576
+ payment_type: j["payment_type"] || "",
577
+ payments: (j["payments"] || []).map do |p|
578
+ PaymentInfo.new(quote_hash: p["quote_hash"], rewards_address: p["rewards_address"], amount: p["amount"])
579
+ end,
580
+ total_amount: j["total_amount"] || "",
581
+ payment_vault_address: j["payment_vault_address"] || "",
582
+ payment_token_address: j["payment_token_address"] || "",
583
+ rpc_url: j["rpc_url"] || "",
584
+ signed_quotes: (j["signed_quotes"] || []).map do |q|
585
+ SignedQuoteEntry.new(
586
+ quote_hash: q["quote_hash"] || "",
587
+ quote: q["quote"] || "",
588
+ commitment_sidecar: presence(q["commitment_sidecar"])
589
+ )
590
+ end
591
+ )
592
+ end
593
+
594
+ # External signer: store a prepared pointer write once it is paid.
595
+ #
596
+ # A write that falls short after payment raises +CloseGroupShortfallError+
597
+ # with +retryable+ set: the daemon kept the paid state, so call this again
598
+ # with the same +upload_id+ (+tx_hashes+ is then ignored) to store it
599
+ # against the same payment. Bound that loop; the session expires with the
600
+ # daemon's one-hour pending TTL. Any other failure ends the session.
601
+ #
602
+ # @param upload_id [String] from +pointer_prepare+
603
+ # @param tx_hashes [Hash<String, String>] each +quote_hash+ from
604
+ # +payments+ to the +payForQuotes()+ transaction hash
605
+ # @return [PointerFinalizeResult]
606
+ def pointer_finalize(upload_id, tx_hashes)
607
+ j = do_json(:post, "/v1/pointers/finalize", { upload_id: upload_id, tx_hashes: tx_hashes })
608
+ PointerFinalizeResult.new(
609
+ address: j["address"] || "",
610
+ state_id: j["state_id"] || "",
611
+ counter: Antd.parse_pointer_counter("counter", j["counter"]),
612
+ finality: j["finality"].is_a?(Hash) ? parse_pointer_finality(j["finality"]) : nil
613
+ )
614
+ end
615
+
390
616
  private
391
617
 
618
+ # POST a pointer create / update. +kind+ is omitted when nil, so the
619
+ # daemon applies its default (chunk).
620
+ def pointer_write(path, owner, target, kind)
621
+ body = { owner: owner, target: target }
622
+ body[:kind] = kind unless kind.nil?
623
+ j = do_json(:post, path, body)
624
+ PointerWriteResult.new(
625
+ address: j["address"] || "",
626
+ state_id: j["state_id"] || "",
627
+ counter: Antd.parse_pointer_counter("counter", j["counter"])
628
+ )
629
+ end
630
+
631
+ # Parse a pointer finality JSON object. The daemon sends +null+ for every
632
+ # unset optional (+counter+, +state+, +majority+, +transferred_to+); each
633
+ # reads as nil.
634
+ def parse_pointer_finality(j)
635
+ PointerFinality.new(
636
+ address: j["address"] || "",
637
+ group: (j["group"] || 0).to_i,
638
+ answered: (j["answered"] || 0).to_i,
639
+ status: j["status"] || "",
640
+ counter: j["counter"].nil? ? nil : Antd.parse_pointer_counter("finality counter", j["counter"]),
641
+ state: j["state"].is_a?(Hash) ? parse_pointer_final_state(j["state"]) : nil,
642
+ states: (j["states"] || []).grep(Hash).map { |s| parse_pointer_final_state(s) },
643
+ majority: j["majority"].is_a?(Hash) ? parse_pointer_final_state(j["majority"]) : nil,
644
+ transferred_to: presence(j["transferred_to"])
645
+ )
646
+ end
647
+
648
+ def parse_pointer_final_state(j)
649
+ PointerFinalState.new(
650
+ state_id: j["state_id"] || "",
651
+ kind: j["kind"] || "",
652
+ kind_tag: (j["kind_tag"] || 0).to_i,
653
+ target: j["target"] || "",
654
+ holders: (j["holders"] || 0).to_i,
655
+ transferred_to: presence(j["transferred_to"])
656
+ )
657
+ end
658
+
659
+ # A wire string, or nil when absent, +null+ or empty.
660
+ def presence(value)
661
+ value.is_a?(String) && !value.empty? ? value : nil
662
+ end
663
+
392
664
  # Parse a /v1/upload/finalize JSON response into a FinalizeUploadResult.
393
665
  #
394
666
  # +data_map_address+ is populated only when prepare was called with
@@ -483,6 +755,10 @@ module Antd
483
755
  end
484
756
 
485
757
  # Perform a JSON HTTP request and return the parsed response body.
758
+ #
759
+ # A non-2xx response is raised via +Antd.error_for_response+, which maps
760
+ # the body's +code+ / +error+ (and the PARTIAL_UPLOAD counts) onto the
761
+ # typed +AntdError+ hierarchy.
486
762
  def do_json(method, path, body = nil)
487
763
  uri = build_uri(path)
488
764
  http = Net::HTTP.new(uri.host, uri.port)
@@ -505,14 +781,7 @@ module Antd
505
781
  code = response.code.to_i
506
782
 
507
783
  unless (200...300).cover?(code)
508
- msg = response.body.to_s
509
- begin
510
- parsed = JSON.parse(msg)
511
- msg = parsed["error"] if parsed["error"]
512
- rescue JSON::ParserError
513
- # use raw body as message
514
- end
515
- raise Antd.error_for_status(code, msg)
784
+ raise Antd.error_for_response(code, response.body)
516
785
  end
517
786
 
518
787
  return {} if response.body.nil? || response.body.empty?
@@ -524,9 +793,9 @@ module Antd
524
793
  # they arrive. Reuses the same base-URL / timeout / error-mapping plumbing
525
794
  # as +do_json+, but never buffers the success body in memory.
526
795
  #
527
- # On a non-2xx response the (short) body is read fully, parsed for an
528
- # +{"error":...}+ field, and raised via +Antd.error_for_status+ — mirroring
529
- # +do_json+. On 2xx, chunks are streamed straight to the block.
796
+ # On a non-2xx response the (short) body is read fully and raised via
797
+ # +Antd.error_for_response+ — mirroring +do_json+. On 2xx, chunks are
798
+ # streamed straight to the block.
530
799
  def do_stream(method, path, body = nil)
531
800
  uri = build_uri(path)
532
801
  http = Net::HTTP.new(uri.host, uri.port)
@@ -550,14 +819,7 @@ module Antd
550
819
  code = response.code.to_i
551
820
 
552
821
  unless (200...300).cover?(code)
553
- msg = response.body.to_s
554
- begin
555
- parsed = JSON.parse(msg)
556
- msg = parsed["error"] if parsed["error"]
557
- rescue JSON::ParserError
558
- # use raw body as message
559
- end
560
- raise Antd.error_for_status(code, msg)
822
+ raise Antd.error_for_response(code, response.body)
561
823
  end
562
824
 
563
825
  response.read_body do |chunk|
@@ -599,14 +861,7 @@ module Antd
599
861
  code = response.code.to_i
600
862
 
601
863
  unless (200...300).cover?(code)
602
- msg = response.body.to_s
603
- begin
604
- parsed = JSON.parse(msg)
605
- msg = parsed["error"] if parsed["error"]
606
- rescue JSON::ParserError
607
- # use raw body as message
608
- end
609
- raise Antd.error_for_status(code, msg)
864
+ raise Antd.error_for_response(code, response.body)
610
865
  end
611
866
 
612
867
  buffer = +""