Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 115 additions & 3 deletions antd-go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -480,9 +480,17 @@ func (c *Client) ChunkGet(ctx context.Context, address string) ([]byte, error) {
//
// Requires antd >= 0.7.0.
func (c *Client) PrepareChunkUpload(ctx context.Context, content []byte) (*PrepareChunkResult, error) {
j, _, err := c.doJSON(ctx, http.MethodPost, "/v1/chunks/prepare", map[string]any{
"data": b64Encode(content),
})
return c.PrepareChunkUploadWithOptions(ctx, content, PrepareOptions{})
}

// PrepareChunkUploadWithOptions is PrepareChunkUpload with explicit options.
// Visibility is not applicable to single-chunk publishes and is ignored.
func (c *Client) PrepareChunkUploadWithOptions(ctx context.Context, content []byte, opts PrepareOptions) (*PrepareChunkResult, error) {
body := map[string]any{"data": b64Encode(content)}
if opts.IncludeSignedQuotes {
body["include_signed_quotes"] = true
}
j, _, err := c.doJSON(ctx, http.MethodPost, "/v1/chunks/prepare", body)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -510,6 +518,7 @@ func (c *Client) PrepareChunkUpload(ctx context.Context, content []byte) (*Prepa
})
}
}
r.SignedQuotes = parseSignedQuotes(arrAt(j, "signed_quotes"))
return r, nil
}

Expand Down Expand Up @@ -678,6 +687,10 @@ func parsePrepareResponse(j map[string]any) *PrepareUploadResult {
}
}

// Signed-quote exposure (antd >= 0.13.0) — present only when the prepare
// requested IncludeSignedQuotes.
result.SignedQuotes = parseSignedQuotes(arrAt(j, "signed_quotes"))

// Parse merkle fields
if result.PaymentType == "merkle" {
result.Depth = int(num64(j, "depth"))
Expand Down Expand Up @@ -735,6 +748,105 @@ func (c *Client) PrepareUpload(ctx context.Context, path string) (*PrepareUpload
return parsePrepareResponse(j), nil
}

// PrepareOptions selects optional behaviour for the prepare endpoints.
type PrepareOptions struct {
// Visibility is "private" (default when empty) or "public" — see
// PrepareUploadPublic for what "public" changes.
Visibility string
// IncludeSignedQuotes asks the daemon to carry the full signed quotes +
// ADR-0004 commitment sidecars in the response (wave-batch only), for
// offline verification via VerifyQuotes. Requires antd >= 0.13.0; older
// daemons ignore the flag and SignedQuotes stays empty.
IncludeSignedQuotes bool
}

// PrepareUploadWithOptions is PrepareUpload with explicit options.
func (c *Client) PrepareUploadWithOptions(ctx context.Context, path string, opts PrepareOptions) (*PrepareUploadResult, error) {
body := map[string]any{"path": path}
if opts.Visibility != "" {
body["visibility"] = opts.Visibility
}
if opts.IncludeSignedQuotes {
body["include_signed_quotes"] = true
}
j, _, err := c.doJSON(ctx, http.MethodPost, "/v1/upload/prepare", body)
if err != nil {
return nil, err
}
return parsePrepareResponse(j), nil
}

// PrepareDataUploadWithOptions is PrepareDataUpload with explicit options.
// Note visibility:"public" is not yet supported by the data endpoint (the
// daemon returns 501) — see PrepareDataUpload.
func (c *Client) PrepareDataUploadWithOptions(ctx context.Context, data []byte, opts PrepareOptions) (*PrepareUploadResult, error) {
body := map[string]any{"data": b64Encode(data)}
if opts.Visibility != "" {
body["visibility"] = opts.Visibility
}
if opts.IncludeSignedQuotes {
body["include_signed_quotes"] = true
}
j, _, err := c.doJSON(ctx, http.MethodPost, "/v1/data/prepare", body)
if err != nil {
return nil, err
}
return parsePrepareResponse(j), nil
}

// VerifyQuotes verifies a batch of signed quotes offline via
// POST /v1/verify/quotes: quote-hash recomputation, ML-DSA-65 signature,
// paid-fields equality against each entry's triple, and the ADR-0004
// commitment binding with exact on-curve pricing. Stateless and offline —
// call it on a daemon you trust (your own), never the counterparty's.
// Policy checks (expiry windows, replay ledgers, chunk-set equality,
// count-plausibility caps) remain the caller's job; the verdicts carry the
// extracted fields those policies need.
//
// Requires antd >= 0.13.0.
func (c *Client) VerifyQuotes(ctx context.Context, entries []VerifyQuoteEntry) (*VerifyQuotesResult, error) {
j, _, err := c.doJSON(ctx, http.MethodPost, "/v1/verify/quotes", map[string]any{
"entries": entries,
})
if err != nil {
return nil, err
}
result := &VerifyQuotesResult{Valid: boolField(j, "valid")}
for _, e := range arrAt(j, "entries") {
em, ok := e.(map[string]any)
if !ok {
continue
}
result.Entries = append(result.Entries, VerifyQuoteVerdict{
QuoteHash: str(em, "quote_hash"),
Valid: boolField(em, "valid"),
Error: str(em, "error"),
TimestampUnixSecs: uint64(num64(em, "timestamp_unix_secs")),
Content: str(em, "content"),
Price: str(em, "price"),
RewardsAddress: str(em, "rewards_address"),
CommittedKeyCount: uint32(num64(em, "committed_key_count")),
Pinned: boolField(em, "pinned"),
})
}
return result, nil
}

// parseSignedQuotes maps a JSON signed_quotes array into typed entries.
func parseSignedQuotes(raw []any) []SignedQuoteEntry {
var out []SignedQuoteEntry
for _, s := range raw {
if sm, ok := s.(map[string]any); ok {
out = append(out, SignedQuoteEntry{
QuoteHash: str(sm, "quote_hash"),
Quote: str(sm, "quote"),
CommitmentSidecar: str(sm, "commitment_sidecar"),
})
}
}
return out
}

// PrepareUploadPublic prepares a public file upload for external signing.
// In addition to the data chunks, the daemon bundles the serialized DataMap
// chunk into the same payment batch — so the external signer signs ONE EVM
Expand Down
86 changes: 86 additions & 0 deletions antd-go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1137,3 +1137,89 @@ func TestPlain502StillMapsToNetworkError(t *testing.T) {
t.Fatalf("expected *NetworkError for plain 502, got %T: %v", err, err)
}
}

func TestPrepareUploadWithOptionsSendsFlagAndParsesSignedQuotes(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/upload/prepare" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["include_signed_quotes"] != true {
t.Fatalf("include_signed_quotes not sent: %+v", body)
}
_, _ = io.WriteString(w, `{
"upload_id": "up9", "payment_type": "wave_batch",
"payments": [{"quote_hash": "qh9", "rewards_address": "ra9", "amount": "5"}],
"signed_quotes": [{"quote_hash": "qh9", "quote": "b3BhcXVl", "commitment_sidecar": "c2lkZQ=="}],
"total_amount": "5", "payment_vault_address": "dp", "payment_token_address": "pt",
"rpc_url": "http://localhost:1"
}`)
}))
defer srv.Close()
c := NewClient(srv.URL)
res, err := c.PrepareUploadWithOptions(context.Background(), "/tmp/x", PrepareOptions{IncludeSignedQuotes: true})
if err != nil {
t.Fatal(err)
}
if len(res.SignedQuotes) != 1 {
t.Fatalf("unexpected signed_quotes: %+v", res.SignedQuotes)
}
sq := res.SignedQuotes[0]
if sq.QuoteHash != "qh9" || sq.Quote != "b3BhcXVl" || sq.CommitmentSidecar != "c2lkZQ==" {
t.Fatalf("unexpected entry: %+v", sq)
}
}

func TestVerifyQuotes(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/verify/quotes" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
var body struct {
Entries []VerifyQuoteEntry `json:"entries"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if len(body.Entries) != 2 || body.Entries[0].SignedQuote != "b3BhcXVl" {
t.Fatalf("unexpected entries: %+v", body.Entries)
}
if body.Entries[1].CommitmentSidecar != "" {
t.Fatalf("baseline entry should have no sidecar: %+v", body.Entries[1])
}
_, _ = io.WriteString(w, `{
"valid": false,
"entries": [
{"quote_hash": "qh1", "valid": true, "timestamp_unix_secs": 1756000000,
"content": "aa", "price": "5", "rewards_address": "ra1",
"committed_key_count": 42, "pinned": true},
{"quote_hash": "qh2", "valid": false, "error": "price 6 does not equal calculate_price(committed_key_count=0)"}
]
}`)
}))
defer srv.Close()
c := NewClient(srv.URL)
res, err := c.VerifyQuotes(context.Background(), []VerifyQuoteEntry{
{QuoteHash: "qh1", RewardsAddress: "ra1", Amount: "5", SignedQuote: "b3BhcXVl", CommitmentSidecar: "c2lkZQ=="},
{QuoteHash: "qh2", RewardsAddress: "ra2", Amount: "6", SignedQuote: "b3BhcXVlMg=="},
})
if err != nil {
t.Fatal(err)
}
if res.Valid {
t.Fatal("expected overall valid=false")
}
if len(res.Entries) != 2 {
t.Fatalf("unexpected entries: %+v", res.Entries)
}
if !res.Entries[0].Valid || res.Entries[0].CommittedKeyCount != 42 || !res.Entries[0].Pinned ||
res.Entries[0].TimestampUnixSecs != 1756000000 {
t.Fatalf("unexpected first verdict: %+v", res.Entries[0])
}
if res.Entries[1].Valid || res.Entries[1].Error == "" {
t.Fatalf("unexpected second verdict: %+v", res.Entries[1])
}
}
52 changes: 52 additions & 0 deletions antd-go/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,22 @@ type PrepareUploadResult struct {
// external signer pays for (TotalChunks - AlreadyStoredCount) chunks.
TotalChunks int `json:"total_chunks,omitempty"` // total chunks incl. already-stored
AlreadyStoredCount int `json:"already_stored_count,omitempty"` // chunks skipped (already on-network)

// Signed-quote exposure (antd >= 0.13.0, V2-854). Populated only when the
// prepare was made with IncludeSignedQuotes and PaymentType is wave_batch:
// one entry per Payments quote carrying the full signed quote (and its
// ADR-0004 commitment sidecar when pinned) as opaque bytes for offline
// verification via VerifyQuotes.
SignedQuotes []SignedQuoteEntry `json:"signed_quotes,omitempty"`
}

// SignedQuoteEntry is one Payments quote in full signed form. Quote and
// CommitmentSidecar are opaque base64 blobs — pass them to VerifyQuotes
// unchanged; only antd parses them.
type SignedQuoteEntry struct {
QuoteHash string `json:"quote_hash"` // hex with 0x prefix — matches Payments
Quote string `json:"quote"` // base64(msgpack signed PaymentQuote), opaque
CommitmentSidecar string `json:"commitment_sidecar,omitempty"` // base64(msgpack StorageCommitment), empty for baseline quotes
}

// MerkleBatchEntry describes one merkle payment batch: everything the
Expand Down Expand Up @@ -172,6 +188,42 @@ type PrepareChunkResult struct {
PaymentTokenAddress string `json:"payment_token_address,omitempty"`
// EVM RPC URL for submitting transactions.
RPCUrl string `json:"rpc_url,omitempty"`
// Same semantics as PrepareUploadResult.SignedQuotes (antd >= 0.13.0).
SignedQuotes []SignedQuoteEntry `json:"signed_quotes,omitempty"`
}

// VerifyQuoteEntry is one entry for VerifyQuotes: the payment triple the
// caller was asked to pay plus the opaque signed artifacts from the prepare
// response's SignedQuotes.
type VerifyQuoteEntry struct {
QuoteHash string `json:"quote_hash"` // hex, 32 bytes
RewardsAddress string `json:"rewards_address"` // hex with 0x prefix
Amount string `json:"amount"` // atto tokens, decimal string
SignedQuote string `json:"signed_quote"` // SignedQuoteEntry.Quote, opaque
CommitmentSidecar string `json:"commitment_sidecar,omitempty"` // SignedQuoteEntry.CommitmentSidecar, opaque
}

// VerifyQuoteVerdict is the per-entry result of VerifyQuotes. The extracted
// fields (Timestamp, Content, …) are populated as soon as the signed quote
// deserializes — even when a later check fails — so policy layers can see
// what the quote claimed.
type VerifyQuoteVerdict struct {
QuoteHash string `json:"quote_hash"` // echo of the request entry
Valid bool `json:"valid"` // every check passed
Error string `json:"error,omitempty"` // first failing rule, by name
TimestampUnixSecs uint64 `json:"timestamp_unix_secs,omitempty"` // for expiry policy
Content string `json:"content,omitempty"` // chunk address (hex, 32 bytes)
Price string `json:"price,omitempty"` // signed price (atto tokens)
RewardsAddress string `json:"rewards_address,omitempty"` // signed rewards address
CommittedKeyCount uint32 `json:"committed_key_count,omitempty"` // for count-plausibility caps (0 = baseline)
Pinned bool `json:"pinned,omitempty"` // quote pins a storage commitment
}

// VerifyQuotesResult is the result of VerifyQuotes.
type VerifyQuotesResult struct {
// Valid is true only when Entries is non-empty and every entry verified.
Valid bool `json:"valid"`
Entries []VerifyQuoteVerdict `json:"entries"`
}

// UploadCostEstimate is the result of an estimate (EstimateDataCost / EstimateFileCost).
Expand Down
Loading
Loading