antd 0.2.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: ab6786553e76394691c28a2968384dd279738e876ecf961e74a0b7b46904cc0e
4
- data.tar.gz: a04836fdba38a5457a3cfda27c3adde84bc8175a87dcd1c58e719fb315cc1f0a
3
+ metadata.gz: d8d53161e99f51956a6ba0b004669faf4a0d28e7e6998e8abe9fdbec35f83bd2
4
+ data.tar.gz: d9a2b3e9cb282eaf712ca3783c92cff130dd1958106a62c1c88637e686c223a1
5
5
  SHA512:
6
- metadata.gz: '0806f4881413dd24b52a385319ee23ca30de479dea639b54ef0088bd31f8f0cdba73c0d458843082f697e6a92a3bb4c1f5c34dd77532d38d8032dcb4845f3c78'
7
- data.tar.gz: 12fffe89716557c9dc2716a99f5f8b39a4958251387050f8828f45ff69628e2350e5aa6dc370f55a89d3abfc06fbb214770bf9cd1c31e516e794fc09daaf9fd4
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
@@ -96,6 +93,16 @@ the daemon sent — start with `Partial upload:` becomes
96
93
  `ABORTED`, including one that only mentions `Partial upload:` later in its
97
94
  text, stays a generic `Antd::AntdError`).
98
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`.
105
+
99
106
  > **Note:** Wallet operations (address, balance, approve) and payment_mode are available via REST only.
100
107
 
101
108
  ## Prerequisites
@@ -153,6 +160,62 @@ client = Antd::Client.new(base_url: "http://custom-host:9090", timeout: 30)
153
160
  | `file_get_public(address, dest_path)` | Download a public file by address |
154
161
  | `file_cost(path, is_public, payment_mode: :auto)` | Estimate upload cost — returns `UploadCostEstimate` with size, chunks, gas, payment mode |
155
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
+
156
219
  ## Error Handling
157
220
 
158
221
  All errors inherit from `Antd::AntdError` and can be caught by type:
@@ -180,6 +243,18 @@ end
180
243
  | `InternalError` | 500 | Server error |
181
244
  | `NetworkError` | 502 | Network unreachable |
182
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.
183
258
 
184
259
  ### Partial uploads
185
260
 
@@ -256,6 +331,63 @@ See [`docs/external-signer-flow.md` section 6](../docs/external-signer-flow.md#6
256
331
  for the daemon contract and `examples/07_external_signer.rb` for a
257
332
  `finalize_with_retry` helper.
258
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
+ ```
390
+
259
391
  ## Examples
260
392
 
261
393
  See the [examples/](examples/) directory:
@@ -266,3 +398,5 @@ See the [examples/](examples/) directory:
266
398
  - `04_files.rb` — File upload and download
267
399
  - `06_private_data.rb` — Private data put/get
268
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
@@ -417,8 +417,250 @@ module Antd
417
417
  parse_finalize_response(j)
418
418
  end
419
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
+
420
616
  private
421
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
+
422
664
  # Parse a /v1/upload/finalize JSON response into a FinalizeUploadResult.
423
665
  #
424
666
  # +data_map_address+ is populated only when prepare was called with
data/lib/antd/errors.rb CHANGED
@@ -58,6 +58,66 @@ module Antd
58
58
  def initialize(message) = super(message, status_code: 503)
59
59
  end
60
60
 
61
+ # The pointer is at its final state, transferred or frozen: nothing can
62
+ # update it or hand it over again (HTTP 409 with +code: "POINTER_FINAL"+;
63
+ # gRPC FAILED_PRECONDITION with +x-antd-error-code: POINTER_FINAL+).
64
+ # Usually refused before anything is paid: the daemon reads the pointer
65
+ # first. A write that was already paid for when the network refused it (the
66
+ # pointer became final while the write was in flight) keeps that payment, so
67
+ # read the pointer before paying again.
68
+ class PointerFinalError < AntdError
69
+ def initialize(message) = super(message, status_code: 409)
70
+ end
71
+
72
+ # The pointer's owner signed two different final states and no one of them
73
+ # is held by a majority of the close group, so there is no answer to give
74
+ # (HTTP 409 with +code: "POINTER_FORKED"+; gRPC FAILED_PRECONDITION with
75
+ # +x-antd-error-code: POINTER_FORKED+). Subclasses +ForkError+, so
76
+ # +rescue Antd::ForkError+ catches it too.
77
+ class PointerForkedError < ForkError
78
+ end
79
+
80
+ # The request conflicts with the resource's current state (HTTP 409 with
81
+ # +code: "CONFLICT"+; gRPC ABORTED with +x-antd-error-code: CONFLICT+). For
82
+ # pointers, two cases share this code, and it does not say which one
83
+ # happened or whether anything was paid: a write while a prepared write to
84
+ # the same pointer awaits payment is refused before paying (finalize the
85
+ # prepared write, or wait for it to expire); an update that lost to a newer
86
+ # state while it was in flight may already have been paid for. Read the
87
+ # pointer again and check what was paid before submitting another paid
88
+ # write.
89
+ class ConflictError < AntdError
90
+ def initialize(message) = super(message, status_code: 409)
91
+ end
92
+
93
+ # Too few of an address's close group answered, or took a write, for the
94
+ # result to be the network's (HTTP 503 with +code: "CLOSE_GROUP_SHORTFALL"+;
95
+ # gRPC UNAVAILABLE with +x-antd-error-code: CLOSE_GROUP_SHORTFALL+).
96
+ #
97
+ # Reads are free to retry; one during an update in flight or churn usually
98
+ # clears. A paid write is not, unless +retryable+ is true: a pointer
99
+ # finalize then kept the paid state under the same +upload_id+, and
100
+ # repeating that finalize stores it against the same payment. Over REST
101
+ # +retryable+ is the body's +retryable+ (true only for JSON +true+); over
102
+ # gRPC it is read from the "(paid attempt retained...)" hint that closes
103
+ # the status details.
104
+ #
105
+ # Not a +ServiceUnavailableError+ (which means a daemon without a wallet):
106
+ # rescue it by name. Daemons without the pointer API reported a shortfall as
107
+ # a 500, an +InternalError+.
108
+ class CloseGroupShortfallError < AntdError
109
+ attr_reader :retryable
110
+
111
+ # @param message [String]
112
+ # @param retryable [Boolean] whether a pointer finalize kept the paid state
113
+ def initialize(message, retryable: false)
114
+ @retryable = retryable == true
115
+ super(message, status_code: 503)
116
+ end
117
+
118
+ alias retryable? retryable
119
+ end
120
+
61
121
  # A finalize stored some chunks while others remained unstored after the
62
122
  # daemon's retries (HTTP 502 with +code: "PARTIAL_UPLOAD"+; gRPC ABORTED).
63
123
  # The on-chain payment persists and the stored chunks stay on the network.
@@ -222,13 +282,77 @@ module Antd
222
282
  }
223
283
  end
224
284
 
285
+ # Metadata key the daemon (0.16.0 or later) sets on gRPC
286
+ # statuses whose code is shared with older errors, carrying the REST +code+.
287
+ ANTD_ERROR_CODE_METADATA = "x-antd-error-code"
288
+
289
+ # The gRPC status code the daemon pairs each tagged code with
290
+ # (antd/src/error.rs). A tag on any other status code is not trusted.
291
+ GRPC_STATUS_FOR_ERROR_CODE = {
292
+ "POINTER_FINAL" => 9, # FAILED_PRECONDITION
293
+ "POINTER_FORKED" => 9, # FAILED_PRECONDITION
294
+ "CONFLICT" => 10, # ABORTED
295
+ "CLOSE_GROUP_SHORTFALL" => 14 # UNAVAILABLE
296
+ }.freeze
297
+ private_constant :GRPC_STATUS_FOR_ERROR_CODE
298
+
299
+ # The hint that closes a CLOSE_GROUP_SHORTFALL message when a pointer
300
+ # finalize kept the paid state: "(paid attempt retained...)" at the very
301
+ # end of the details, as for a retained partial upload.
302
+ CLOSE_GROUP_RETAINED_TAIL = /\(#{Regexp.escape(PARTIAL_UPLOAD_RETAINED_HINT)}[^()]*\)\z/
303
+ private_constant :CLOSE_GROUP_RETAINED_TAIL
304
+
305
+ # The typed error for a daemon error +code+ that shares its HTTP status (and
306
+ # gRPC code) with older errors: the 409s POINTER_FINAL, POINTER_FORKED and
307
+ # CONFLICT, and CLOSE_GROUP_SHORTFALL's 503. nil for any other code, which
308
+ # keeps the status-based mapping.
309
+ #
310
+ # @param code [Object] the daemon's error code
311
+ # @param message [String]
312
+ # @param retryable [Boolean] CLOSE_GROUP_SHORTFALL only
313
+ # @return [AntdError, nil]
314
+ def self.error_for_code(code, message, retryable: false)
315
+ case code
316
+ when "POINTER_FINAL" then PointerFinalError.new(message)
317
+ when "POINTER_FORKED" then PointerForkedError.new(message)
318
+ when "CONFLICT" then ConflictError.new(message)
319
+ when "CLOSE_GROUP_SHORTFALL" then CloseGroupShortfallError.new(message, retryable: retryable)
320
+ end
321
+ end
322
+
323
+ # The typed error for a gRPC status the daemon tagged with
324
+ # +x-antd-error-code+ (in the trailing metadata, +GRPC::BadStatus#metadata+),
325
+ # when the tag agrees with the status code: FAILED_PRECONDITION for
326
+ # POINTER_FINAL / POINTER_FORKED, ABORTED for CONFLICT, UNAVAILABLE for
327
+ # CLOSE_GROUP_SHORTFALL. nil for an untagged status, a tag this SDK does not
328
+ # know, or a tag on another status code; the caller then keeps the
329
+ # code-based mapping (so an untagged FAILED_PRECONDITION is still a
330
+ # +PaymentError+).
331
+ #
332
+ # @param code [Integer] gRPC status code
333
+ # @param details [String, nil] the status details as the daemon sent them,
334
+ # read for CLOSE_GROUP_SHORTFALL's retained hint
335
+ # @param message [String] the message for the raised error
336
+ # @param metadata [Hash, nil] trailing metadata
337
+ # @return [AntdError, nil]
338
+ def self.error_for_grpc_metadata(code, details, message, metadata)
339
+ tag = metadata.is_a?(Hash) ? metadata[ANTD_ERROR_CODE_METADATA] : nil
340
+ tag = tag.first if tag.is_a?(Array)
341
+ return nil unless tag.is_a?(String) && GRPC_STATUS_FOR_ERROR_CODE[tag] == code
342
+
343
+ error_for_code(tag, message, retryable: CLOSE_GROUP_RETAINED_TAIL.match?(details.to_s))
344
+ end
345
+
225
346
  # Maps a non-2xx REST response onto a typed error, preferring the body's
226
347
  # machine-readable +code+ over the bare HTTP status where they diverge:
227
348
  # +PARTIAL_UPLOAD+ arrives as a 502 that would otherwise read as a generic
228
- # +NetworkError+. Every other code keeps the status-based mapping of
229
- # +error_for_status+. A JSON object body's +error+ field becomes the
230
- # message when it is a string; otherwise (non-JSON body, a JSON value that
231
- # is not an object, a non-string +error+) the raw body is the message.
349
+ # +NetworkError+, POINTER_FINAL, POINTER_FORKED and CONFLICT are 409s that
350
+ # must not read as +AlreadyExistsError+, and CLOSE_GROUP_SHORTFALL is a 503
351
+ # that must not read as +ServiceUnavailableError+ (see +error_for_code+).
352
+ # Every other code keeps the status-based mapping of +error_for_status+. A
353
+ # JSON object body's +error+ field becomes the message when it is a string;
354
+ # otherwise (non-JSON body, a JSON value that is not an object, a non-string
355
+ # +error+) the raw body is the message.
232
356
  #
233
357
  # Never raises on a malformed body: every field is type-checked, not
234
358
  # coerced. Only a +code+ that is exactly the string "PARTIAL_UPLOAD"
@@ -264,6 +388,11 @@ module Antd
264
388
  retention_known: [true, false].include?(parsed["retryable"])
265
389
  )
266
390
  end
391
+
392
+ # retryable is present, and true, only on a pointer finalize that kept
393
+ # the paid state; anything else reads as not retained.
394
+ coded = error_for_code(parsed["code"], message, retryable: parsed["retryable"] == true)
395
+ return coded if coded
267
396
  end
268
397
 
269
398
  error_for_status(code, message)