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.
data/lib/antd/errors.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module Antd
4
6
  # Base error type for all antd errors.
5
7
  class AntdError < StandardError
@@ -56,6 +58,357 @@ module Antd
56
58
  def initialize(message) = super(message, status_code: 503)
57
59
  end
58
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
+
121
+ # A finalize stored some chunks while others remained unstored after the
122
+ # daemon's retries (HTTP 502 with +code: "PARTIAL_UPLOAD"+; gRPC ABORTED).
123
+ # The on-chain payment persists and the stored chunks stay on the network.
124
+ # How to finish the upload depends on +retryable+ and +retention_known+:
125
+ #
126
+ # - +retryable+ (antd >= 0.14.0; implies +retention_known+): the daemon kept
127
+ # the paid attempt (payment proofs + unstored chunks) under the same
128
+ # +upload_id+. Call the same +finalize_*+ method again with the same
129
+ # +upload_id+ and payment artefacts to store the remainder against the
130
+ # same payment -- no re-prepare, no second signature, no double payment.
131
+ # Bound the loop: a persistent failure raises this error on every call, so
132
+ # cap the attempts and treat a +chunks_failed+ that stops shrinking as
133
+ # stuck. The retained attempt expires with the daemon's pending-upload
134
+ # TTL.
135
+ # - +retention_known+ and not +retryable+: the daemon confirmed nothing was
136
+ # retained (a merkle finalize with deliberately unpaid batches).
137
+ # Re-preparing the same content skips already-stored chunks, so a retry
138
+ # pays only for the missing remainder.
139
+ # - not +retention_known+: retention is unknown. The error did not say, in
140
+ # a form this SDK could read, whether the paid attempt was kept, and the
141
+ # daemon may still hold it: it records the resume handle before it
142
+ # returns the error. Stop automatic recovery, keep the +upload_id+ and the
143
+ # original payment artefacts (tx hashes, or the merkle winner pool hash),
144
+ # and reconcile before re-preparing or paying again. Never pay again on
145
+ # this signal alone. Daemons older than 0.14.0 never send +retryable+, so
146
+ # their REST partial uploads read as unknown.
147
+ #
148
+ # Over REST the counts and flags come from the structured error body; a
149
+ # count of the wrong JSON type reads as 0, and +retention_known+ is true
150
+ # only when the body's +retryable+ is a JSON boolean. Over gRPC they are
151
+ # parsed best-effort from the status details ("Partial upload: S/T chunks
152
+ # stored, F failed after retries: <reason> (<hint>)"); only an ABORTED
153
+ # whose details start with "Partial upload:" is a partial upload.
154
+ # +retention_known+ is true there only when the details start with the
155
+ # counts, all three parse, and the details end with one of the daemon's two
156
+ # hints: "(paid attempt retained...)" sets +retryable+, and "(stored chunks
157
+ # persist; re-prepare the same content...)" means the daemon confirmed
158
+ # nothing was retained (daemons older than 0.14.0 write only this one).
159
+ # Counts that do not parse leave all three zero and both flags false.
160
+ # Readable counts with a missing, truncated or unrecognised hint keep the
161
+ # counts, but both flags stay false: retention unknown, not "nothing
162
+ # retained".
163
+ #
164
+ # Subclasses +NetworkError+ because the daemon reports it as a 502: existing
165
+ # +rescue Antd::NetworkError+ blocks keep catching it, and +status_code+ is
166
+ # 502 on both transports. Rescue +PartialUploadError+ first to handle it
167
+ # specifically. See docs/external-signer-flow.md section 6.
168
+ class PartialUploadError < NetworkError
169
+ attr_reader :chunks_stored, :chunks_failed, :total_chunks, :retryable, :retention_known
170
+
171
+ # @param message [String]
172
+ # @param chunks_stored [Integer] chunks confirmed stored on the network
173
+ # @param chunks_failed [Integer] chunks still unstored after retries
174
+ # @param total_chunks [Integer] chunks in the upload
175
+ # @param retryable [Boolean] whether the daemon retained the paid attempt
176
+ # @param retention_known [Boolean] whether the daemon said, in a form this
177
+ # SDK could read, whether it retained the paid attempt. +retryable+
178
+ # implies it: +retryable: true+ always yields +retention_known == true+.
179
+ def initialize(message, chunks_stored: 0, chunks_failed: 0, total_chunks: 0, retryable: false,
180
+ retention_known: false)
181
+ @chunks_stored = chunks_stored
182
+ @chunks_failed = chunks_failed
183
+ @total_chunks = total_chunks
184
+ @retryable = retryable
185
+ @retention_known = (retention_known || retryable) ? true : false
186
+ super(message)
187
+ end
188
+
189
+ alias retryable? retryable
190
+ alias retention_known? retention_known
191
+ end
192
+
193
+ # Fixed text every PARTIAL_UPLOAD message from the daemon opens with. Over
194
+ # gRPC the status carries no structured code, so an ABORTED status is a
195
+ # partial upload only when its details start with this prefix.
196
+ PARTIAL_UPLOAD_PREFIX = "Partial upload:"
197
+
198
+ # Fixed prefix of the daemon's PARTIAL_UPLOAD message:
199
+ # "Partial upload: <stored>/<total> chunks stored, <failed> failed".
200
+ # Anchored with +\A+ (start of input; +^+ would also match after a
201
+ # newline), so counts quoted later in a garbled message are never read.
202
+ PARTIAL_UPLOAD_COUNTS = %r{\APartial upload: (\d+)/(\d+) chunks stored, (\d+) failed}
203
+
204
+ # The daemon closes every PARTIAL_UPLOAD message with one of two
205
+ # parenthesised hints (partial_upload_hint in antd/src/error.rs). This one
206
+ # opens the hint when it kept the paid attempt for a same-upload_id retry.
207
+ PARTIAL_UPLOAD_RETAINED_HINT = "paid attempt retained"
208
+
209
+ # This one opens the hint when it did not. Daemons older than 0.14.0 write
210
+ # only this one.
211
+ PARTIAL_UPLOAD_NOT_RETAINED_HINT = "stored chunks persist; re-prepare the same content"
212
+ private_constant :PARTIAL_UPLOAD_NOT_RETAINED_HINT
213
+
214
+ # The hint that closes the message: "(<hint>...)" at the very end. +\z+ is
215
+ # the end of the input; Ruby's +$+ is the end of a LINE and +\Z+ allows a
216
+ # trailing newline. A hint quoted inside the failure reason, a truncated
217
+ # or unclosed tail, or any text after the hint does not match.
218
+ PARTIAL_UPLOAD_RETENTION_TAIL =
219
+ /\((#{Regexp.union(PARTIAL_UPLOAD_RETAINED_HINT, PARTIAL_UPLOAD_NOT_RETAINED_HINT).source})[^()]*\)\z/
220
+ private_constant :PARTIAL_UPLOAD_RETENTION_TAIL
221
+
222
+ # Largest count the daemon can send (its counts are u64). Ruby integers are
223
+ # unbounded, so a larger digit run is treated as a failed conversion.
224
+ PARTIAL_UPLOAD_COUNT_MAX = 18_446_744_073_709_551_615
225
+ private_constant :PARTIAL_UPLOAD_COUNT_MAX
226
+
227
+ # Whether a gRPC status's details are the daemon's PARTIAL_UPLOAD message.
228
+ # Anchored: the details must start with +PARTIAL_UPLOAD_PREFIX+. A status
229
+ # that merely quotes "Partial upload:" further into its text (an upstream
230
+ # error wrapping one, say) is not a partial upload and must not select the
231
+ # paid-attempt recovery path with zero counts.
232
+ #
233
+ # Pass +GRPC::BadStatus#details+ -- the status message exactly as the
234
+ # daemon sent it -- not +#message+, which grpc-ruby decorates as
235
+ # "<code>:<details>" ("10:Partial upload: ...") and so never starts with
236
+ # the prefix.
237
+ #
238
+ # @param details [String, nil] gRPC status details
239
+ # @return [Boolean]
240
+ def self.partial_upload_message?(details)
241
+ details.to_s.start_with?(PARTIAL_UPLOAD_PREFIX)
242
+ end
243
+
244
+ # Recovers the chunk counts and the retention flags from a PARTIAL_UPLOAD
245
+ # message (over gRPC, the status details). Used for gRPC, where the status
246
+ # carries no structured detail; REST callers get the body fields instead.
247
+ #
248
+ # Conservative: +retention_known+ is true only when the message starts
249
+ # with the counts pattern ("Partial upload: <stored>/<total> chunks
250
+ # stored, <failed> failed"), all three counts converted (each at most
251
+ # u64::MAX, the daemon's count type), and the message ends with one of the
252
+ # daemon's two hints, "(paid attempt retained...)" or "(stored chunks
253
+ # persist; re-prepare the same content...)"; +retryable+ is then true only
254
+ # for the first. On a prefix or pattern miss, or a count out of range, all
255
+ # three counts are 0 and both flags are false. Readable counts with a
256
+ # missing, truncated or unrecognised tail, or text after it, keep the
257
+ # counts but leave both flags false: the daemon's answer on retention was
258
+ # not read, so retention is unknown, never "nothing retained". A message
259
+ # the parser cannot fully read never selects the same-upload_id retry, and
260
+ # never reads as confirmed non-retention.
261
+ #
262
+ # @param message [String]
263
+ # @return [Hash] +:chunks_stored+, +:chunks_failed+, +:total_chunks+,
264
+ # +:retryable+, +:retention_known+ -- splat into +PartialUploadError.new+
265
+ def self.parse_partial_upload_message(message)
266
+ text = message.to_s
267
+ m = partial_upload_message?(text) && PARTIAL_UPLOAD_COUNTS.match(text)
268
+ # The groups are ASCII digit runs, so #to_i is exact; only the range can fail.
269
+ stored, total, failed = m && [m[1], m[2], m[3]].map(&:to_i)
270
+ unless m && [stored, total, failed].all? { |n| n <= PARTIAL_UPLOAD_COUNT_MAX }
271
+ return { chunks_stored: 0, chunks_failed: 0, total_chunks: 0, retryable: false, retention_known: false }
272
+ end
273
+
274
+ # Only the hint that closes the message is the daemon's answer.
275
+ tail = PARTIAL_UPLOAD_RETENTION_TAIL.match(text)
276
+ {
277
+ chunks_stored: stored,
278
+ chunks_failed: failed,
279
+ total_chunks: total,
280
+ retryable: !tail.nil? && tail[1] == PARTIAL_UPLOAD_RETAINED_HINT,
281
+ retention_known: !tail.nil?
282
+ }
283
+ end
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
+
346
+ # Maps a non-2xx REST response onto a typed error, preferring the body's
347
+ # machine-readable +code+ over the bare HTTP status where they diverge:
348
+ # +PARTIAL_UPLOAD+ arrives as a 502 that would otherwise read as a generic
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.
356
+ #
357
+ # Never raises on a malformed body: every field is type-checked, not
358
+ # coerced. Only a +code+ that is exactly the string "PARTIAL_UPLOAD"
359
+ # selects +PartialUploadError+; a count that is not a non-negative JSON
360
+ # integer reads as 0; +retryable+ is true only for JSON +true+; and
361
+ # +retention_known+ is true only when +retryable+ is a JSON boolean (absent
362
+ # on daemons < 0.14.0, +null+ or any other type reads as unknown).
363
+ #
364
+ # @param code [Integer] HTTP status
365
+ # @param body [String, nil] raw response body
366
+ # @return [AntdError]
367
+ def self.error_for_response(code, body)
368
+ message = body.to_s
369
+ parsed = begin
370
+ JSON.parse(message)
371
+ rescue JSON::ParserError
372
+ nil # use raw body as message
373
+ end
374
+ parsed = nil unless parsed.is_a?(Hash)
375
+
376
+ if parsed
377
+ message = parsed["error"] if parsed["error"].is_a?(String)
378
+ if parsed["code"] == "PARTIAL_UPLOAD"
379
+ return PartialUploadError.new(
380
+ message,
381
+ chunks_stored: body_count(parsed["chunks_stored"]),
382
+ chunks_failed: body_count(parsed["chunks_failed"]),
383
+ total_chunks: body_count(parsed["total_chunks"]),
384
+ retryable: parsed["retryable"] == true,
385
+ # Known only when the daemon sent the flag as a JSON boolean. Absent
386
+ # (daemons < 0.14.0), null or any other type -> unknown: the daemon
387
+ # may still hold the paid attempt, so this is not a re-prepare signal.
388
+ retention_known: [true, false].include?(parsed["retryable"])
389
+ )
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
396
+ end
397
+
398
+ error_for_status(code, message)
399
+ end
400
+
401
+ # A chunk count from a PARTIAL_UPLOAD body: a non-negative JSON integer is
402
+ # taken as is; anything else (absent, null, string, float, boolean, array,
403
+ # object, negative) reads as 0 rather than raising or being coerced.
404
+ #
405
+ # @param value [Object] parsed JSON value
406
+ # @return [Integer]
407
+ def self.body_count(value)
408
+ value.is_a?(Integer) && value >= 0 ? value : 0
409
+ end
410
+ private_class_method :body_count
411
+
59
412
  # Returns the appropriate error type for an HTTP status code.
60
413
  def self.error_for_status(code, message)
61
414
  case code
@@ -17,6 +17,8 @@ require_relative "v1/chunks_services_pb"
17
17
  require_relative "v1/files_services_pb"
18
18
  require_relative "v1/upload_services_pb"
19
19
  require_relative "v1/wallet_services_pb"
20
+ require_relative "v1/pointers_services_pb"
21
+ require "base64"
20
22
 
21
23
  module Antd
22
24
  DEFAULT_GRPC_TARGET = "localhost:50051"
@@ -47,6 +49,7 @@ module Antd
47
49
  @file_stub = Antd::V1::FileService::Stub.new(target, :this_channel_is_insecure)
48
50
  @upload_stub = Antd::V1::UploadService::Stub.new(target, :this_channel_is_insecure)
49
51
  @wallet_stub = Antd::V1::WalletService::Stub.new(target, :this_channel_is_insecure)
52
+ @pointer_stub = Antd::V1::PointerService::Stub.new(target, :this_channel_is_insecure)
50
53
  end
51
54
 
52
55
  # --- Health ---
@@ -324,6 +327,16 @@ module Antd
324
327
  # @param upload_id [String]
325
328
  # @param tx_hashes [Hash<String, String>]
326
329
  # @return [String] hex chunk address
330
+ # @raise [PartialUploadError] when some chunks stored and others did not
331
+ # (gRPC ABORTED, details prefixed +Partial upload:+). The payment
332
+ # persists. If +retryable+, call this method again with the same
333
+ # arguments to store the remainder against the same payment
334
+ # (antd >= 0.14.0). If +retention_known+ but not +retryable+, nothing
335
+ # was retained: re-prepare the same content, which skips stored chunks.
336
+ # If +retention_known+ is false, retention is unknown: stop, keep the
337
+ # upload_id and payment artefacts, and reconcile before re-preparing or
338
+ # paying again. See +PartialUploadError+ and
339
+ # docs/external-signer-flow.md section 6.
327
340
  def finalize_chunk_upload(upload_id, tx_hashes)
328
341
  req = Antd::V1::FinalizeChunkRequest.new(
329
342
  upload_id: upload_id,
@@ -378,6 +391,16 @@ module Antd
378
391
  # @param upload_id [String]
379
392
  # @param tx_hashes [Hash<String, String>]
380
393
  # @return [FinalizeUploadResult]
394
+ # @raise [PartialUploadError] when some chunks stored and others did not
395
+ # (gRPC ABORTED, details prefixed +Partial upload:+). The payment
396
+ # persists. If +retryable+, call this method again with the same
397
+ # arguments to store the remainder against the same payment
398
+ # (antd >= 0.14.0). If +retention_known+ but not +retryable+, nothing
399
+ # was retained: re-prepare the same content, which skips stored chunks.
400
+ # If +retention_known+ is false, retention is unknown: stop, keep the
401
+ # upload_id and payment artefacts, and reconcile before re-preparing or
402
+ # paying again. See +PartialUploadError+ and
403
+ # docs/external-signer-flow.md section 6.
381
404
  def finalize_upload(upload_id, tx_hashes)
382
405
  req = Antd::V1::FinalizeUploadRequest.new(
383
406
  upload_id: upload_id,
@@ -399,6 +422,16 @@ module Antd
399
422
  # @param winner_pool_hash [String]
400
423
  # @param store_data_map [Boolean]
401
424
  # @return [FinalizeUploadResult]
425
+ # @raise [PartialUploadError] when some chunks stored and others did not
426
+ # (gRPC ABORTED, details prefixed +Partial upload:+). The payment
427
+ # persists. If +retryable+, call this method again with the same
428
+ # arguments to store the remainder against the same payment
429
+ # (antd >= 0.14.0). If +retention_known+ but not +retryable+, nothing
430
+ # was retained: re-prepare the same content, which skips stored chunks.
431
+ # If +retention_known+ is false, retention is unknown: stop, keep the
432
+ # upload_id and payment artefacts, and reconcile before re-preparing or
433
+ # paying again. See +PartialUploadError+ and
434
+ # docs/external-signer-flow.md section 6.
402
435
  def finalize_merkle_upload(upload_id, winner_pool_hash, store_data_map: false)
403
436
  req = Antd::V1::FinalizeUploadRequest.new(
404
437
  upload_id: upload_id,
@@ -438,8 +471,183 @@ module Antd
438
471
  resp.approved
439
472
  end
440
473
 
474
+ # --- Pointers ---
475
+ #
476
+ # The same methods, models and errors as the REST +Client+ (see there),
477
+ # over PointerService. The pointer errors share gRPC status codes with
478
+ # older ones, so the daemon names them in the +x-antd-error-code+
479
+ # trailer, which +grpc_call+ reads (see +Antd.error_for_grpc_metadata+).
480
+ # Unset optionals arrive as "" or an absent message and read as nil, as
481
+ # REST's nulls do. Every write pays: do not retry +pointer_create+,
482
+ # +pointer_update+, +pointer_transfer+ or +pointer_finalize+
483
+ # automatically.
484
+
485
+ # Mirrors +Client#pointer_keys+.
486
+ # @return [Array<String>]
487
+ def pointer_keys
488
+ resp = grpc_call { @pointer_stub.list_keys(Antd::V1::ListPointerKeysRequest.new) }
489
+ resp.keys.map(&:address)
490
+ end
491
+
492
+ # Mirrors +Client#pointer_create+. Pays; do not retry automatically.
493
+ # @return [PointerWriteResult]
494
+ def pointer_create(owner, target, kind: nil)
495
+ req = Antd::V1::PointerWriteRequest.new(owner: owner, target: target, kind: kind.to_s)
496
+ pointer_write_result(grpc_call { @pointer_stub.create(req) })
497
+ end
498
+
499
+ # Mirrors +Client#pointer_update+. Pays; do not retry automatically.
500
+ # @return [PointerWriteResult]
501
+ def pointer_update(owner, target, kind: nil)
502
+ req = Antd::V1::PointerWriteRequest.new(owner: owner, target: target, kind: kind.to_s)
503
+ pointer_write_result(grpc_call { @pointer_stub.update(req) })
504
+ end
505
+
506
+ # Mirrors +Client#pointer_transfer+. Pays; do not retry automatically.
507
+ # @return [PointerTransferResult]
508
+ def pointer_transfer(owner, recipient)
509
+ req = Antd::V1::PointerTransferRequest.new(owner: owner, recipient: recipient)
510
+ resp = grpc_call { @pointer_stub.transfer(req) }
511
+ PointerTransferResult.new(
512
+ address: resp.address,
513
+ state_id: resp.state_id,
514
+ finality: resp.finality.nil? ? nil : pointer_finality_from(resp.finality),
515
+ finality_error: presence(resp.finality_error)
516
+ )
517
+ end
518
+
519
+ # Mirrors +Client#pointer_get+.
520
+ # @return [PointerRecord]
521
+ def pointer_get(address)
522
+ resp = grpc_call { @pointer_stub.get(Antd::V1::GetPointerRequest.new(address: address)) }
523
+ PointerRecord.new(
524
+ address: resp.address,
525
+ owner: resp.owner,
526
+ counter: Antd.parse_pointer_counter("counter", resp.counter),
527
+ kind: resp.kind,
528
+ kind_tag: resp.kind_tag,
529
+ target: resp.target,
530
+ state_id: resp.state_id,
531
+ is_final: resp.is_final,
532
+ transferred_to: presence(resp.transferred_to)
533
+ )
534
+ end
535
+
536
+ # Mirrors +Client#pointer_resolve+.
537
+ # @return [PointerTarget]
538
+ def pointer_resolve(address)
539
+ resp = grpc_call { @pointer_stub.resolve(Antd::V1::ResolvePointerRequest.new(address: address)) }
540
+ PointerTarget.new(kind: resp.kind, kind_tag: resp.kind_tag, target: resp.target)
541
+ end
542
+
543
+ # Mirrors +Client#pointer_finality+.
544
+ # @return [PointerFinality]
545
+ def pointer_finality(address)
546
+ resp = grpc_call { @pointer_stub.finality(Antd::V1::PointerFinalityRequest.new(address: address)) }
547
+ pointer_finality_from(resp)
548
+ end
549
+
550
+ # Mirrors +Client#pointer_controller+.
551
+ # @return [PointerController]
552
+ def pointer_controller(address)
553
+ resp = grpc_call { @pointer_stub.controller(Antd::V1::PointerControllerRequest.new(address: address)) }
554
+ PointerController.new(pointer: resp.pointer, transfers: resp.transfers.to_a, frozen: resp.frozen)
555
+ end
556
+
557
+ # Mirrors +Client#pointer_prepare+.
558
+ # @return [PointerPrepareResult]
559
+ def pointer_prepare(owner, op, target: nil, kind: nil, recipient: nil, include_signed_quotes: false)
560
+ req = Antd::V1::PreparePointerRequest.new(
561
+ owner: owner,
562
+ op: op.to_s,
563
+ target: target.to_s,
564
+ kind: kind.to_s,
565
+ recipient: recipient.to_s,
566
+ include_signed_quotes: include_signed_quotes ? true : false
567
+ )
568
+ resp = grpc_call { @pointer_stub.prepare(req) }
569
+ PointerPrepareResult.new(
570
+ upload_id: resp.upload_id,
571
+ op: resp.op,
572
+ address: resp.address,
573
+ state_id: resp.state_id,
574
+ counter: Antd.parse_pointer_counter("counter", resp.counter),
575
+ payment_type: resp.payment_type,
576
+ payments: resp.payments.map do |p|
577
+ PaymentInfo.new(quote_hash: p.quote_hash, rewards_address: p.rewards_address, amount: p.amount)
578
+ end,
579
+ total_amount: resp.total_amount,
580
+ payment_vault_address: resp.payment_vault_address,
581
+ payment_token_address: resp.payment_token_address,
582
+ rpc_url: resp.rpc_url,
583
+ # Bytes on the wire; base64 here, the same form REST returns.
584
+ signed_quotes: resp.signed_quotes.map do |q|
585
+ SignedQuoteEntry.new(
586
+ quote_hash: q.quote_hash,
587
+ quote: Base64.strict_encode64(q.quote),
588
+ commitment_sidecar: q.commitment_sidecar.empty? ? nil : Base64.strict_encode64(q.commitment_sidecar)
589
+ )
590
+ end
591
+ )
592
+ end
593
+
594
+ # Mirrors +Client#pointer_finalize+. A +CloseGroupShortfallError+ with
595
+ # +retryable+ means the daemon kept the paid state: repeat the call with
596
+ # the same +upload_id+.
597
+ # @return [PointerFinalizeResult]
598
+ def pointer_finalize(upload_id, tx_hashes)
599
+ req = Antd::V1::FinalizePointerRequest.new(upload_id: upload_id, tx_hashes: tx_hashes)
600
+ resp = grpc_call { @pointer_stub.finalize(req) }
601
+ PointerFinalizeResult.new(
602
+ address: resp.address,
603
+ state_id: resp.state_id,
604
+ counter: Antd.parse_pointer_counter("counter", resp.counter),
605
+ finality: resp.finality.nil? ? nil : pointer_finality_from(resp.finality)
606
+ )
607
+ end
608
+
441
609
  private
442
610
 
611
+ def pointer_write_result(resp)
612
+ PointerWriteResult.new(
613
+ address: resp.address,
614
+ state_id: resp.state_id,
615
+ counter: Antd.parse_pointer_counter("counter", resp.counter)
616
+ )
617
+ end
618
+
619
+ # Converts the proto finality, where an unset counter or transferred_to is
620
+ # "" and an unset state or majority an absent message: each reads as nil.
621
+ def pointer_finality_from(f)
622
+ PointerFinality.new(
623
+ address: f.address,
624
+ group: f.group,
625
+ answered: f.answered,
626
+ status: f.status,
627
+ counter: f.counter.empty? ? nil : Antd.parse_pointer_counter("finality counter", f.counter),
628
+ state: f.state.nil? ? nil : pointer_final_state_from(f.state),
629
+ states: f.states.map { |s| pointer_final_state_from(s) },
630
+ majority: f.majority.nil? ? nil : pointer_final_state_from(f.majority),
631
+ transferred_to: presence(f.transferred_to)
632
+ )
633
+ end
634
+
635
+ def pointer_final_state_from(s)
636
+ PointerFinalState.new(
637
+ state_id: s.state_id,
638
+ kind: s.kind,
639
+ kind_tag: s.kind_tag,
640
+ target: s.target,
641
+ holders: s.holders,
642
+ transferred_to: presence(s.transferred_to)
643
+ )
644
+ end
645
+
646
+ # A proto string, or nil when unset (empty).
647
+ def presence(value)
648
+ value.nil? || value.empty? ? nil : value
649
+ end
650
+
443
651
  # Drives a +return_op: true+ server-streaming call, yielding +DownloadFrame+s.
444
652
  # The byte denominator is surfaced first as a leading +DownloadFrame+ (its
445
653
  # +meta+ set), read from the response's +x-content-length+ initial metadata;
@@ -520,14 +728,31 @@ module Antd
520
728
  payment_type: resp.payment_type,
521
729
  depth: is_merkle ? resp.depth : nil,
522
730
  pool_commitments: pool_commitments,
523
- merkle_payment_timestamp: is_merkle ? resp.merkle_payment_timestamp.to_i : nil
731
+ merkle_payment_timestamp: is_merkle ? resp.merkle_payment_timestamp.to_i : nil,
732
+ # Already-stored preflight (parity with REST): the external signer
733
+ # pays for total_chunks - already_stored_count chunks.
734
+ total_chunks: resp.total_chunks,
735
+ already_stored_count: resp.already_stored_count
524
736
  )
525
737
  end
526
738
 
527
739
 
528
740
  # Executes a gRPC call and translates errors to Antd error types.
741
+ #
742
+ # The daemon's +x-antd-error-code+ trailer comes first: where it names a
743
+ # code that shares its gRPC status with older errors (POINTER_FINAL,
744
+ # POINTER_FORKED, CONFLICT, CLOSE_GROUP_SHORTFALL) and agrees with the
745
+ # status code, it selects the typed error. Anything else is re-raised to
746
+ # the status-code mapping below.
529
747
  def grpc_call
530
- yield
748
+ begin
749
+ yield
750
+ rescue GRPC::BadStatus => e
751
+ tagged = Antd.error_for_grpc_metadata(e.code, e.details, e.message, e.metadata)
752
+ raise tagged unless tagged.nil?
753
+
754
+ raise
755
+ end
531
756
  rescue GRPC::InvalidArgument => e
532
757
  raise BadRequestError, e.message
533
758
  rescue GRPC::NotFound => e
@@ -542,6 +767,24 @@ module Antd
542
767
  raise NetworkError, e.message
543
768
  rescue GRPC::FailedPrecondition => e
544
769
  raise PaymentError, e.message
770
+ rescue GRPC::Aborted => e
771
+ # PARTIAL_UPLOAD: some chunks stored, some still unstored after
772
+ # retries. The daemon's status message always opens with "Partial
773
+ # upload:", so only an ABORTED whose details *start* with it becomes
774
+ # the typed error. Any other ABORTED -- including one that merely
775
+ # quotes the marker further in -- keeps the generic mapping instead of
776
+ # masquerading as a partial upload with zero counts. Gate and parse on
777
+ # +e.details+ (the message as the daemon sent it), not +e.message+,
778
+ # which grpc-ruby decorates as "10:<details>". The counts and the
779
+ # daemon's closing retention hint ride that text over gRPC (no
780
+ # structured detail yet), so parse them best-effort to match the REST
781
+ # client's typed error: retention is known only when the counts parse
782
+ # and the text ends with one of the daemon's two hints (see
783
+ # Antd.parse_partial_upload_message). The raised message stays
784
+ # +e.message+, like every other branch.
785
+ raise AntdError.new(e.message, status_code: e.code) unless Antd.partial_upload_message?(e.details)
786
+
787
+ raise PartialUploadError.new(e.message, **Antd.parse_partial_upload_message(e.details))
545
788
  rescue GRPC::BadStatus => e
546
789
  raise AntdError.new(e.message, status_code: e.code)
547
790
  end