diff --git a/README.md b/README.md index c206388..450e542 100755 --- a/README.md +++ b/README.md @@ -147,6 +147,43 @@ All API methods accept a `context.Context` as their first parameter for timeout ### Block - `Get(ctx, blockID)` - Get block by ID +### Proof Measure (public, unauthenticated) +- `GetSecurity(ctx, network, since)` - A network's accumulated security since `since` (unix seconds; `0` = service default), as raw measure + USD valuation. `network` = `"BTC"` or `"ETH"`. +- `Report(ctx, req)` - Per-transaction "securedBy" report over interchain anchors. +- `Health(ctx)` - Service liveness. + +## Proof Measure + +`proof-measure` is a separate, **public, unauthenticated** Dragonchain service +(the measured-immutability / "securedBy" metric) at +`https://proof-measure.dragonchain.com`. It needs no credentials. The SDK exposes +it two ways: + +```go +// 1. Via the main SDK (targets the default public endpoint): +sec, err := client.ProofMeasure.GetSecurity(ctx, "BTC", time.Now().Add(-time.Hour).Unix()) +if err != nil { + log.Fatal(err) +} +fmt.Printf("BTC secured by %s (%s) since %d\n", sec.ValueUSDFormatted, sec.Raw.Value+" "+sec.Raw.Unit, sec.SinceUnix) + +// 2. Standalone (no prime credentials needed; empty URL = public prod): +import "git.dragonchain.com/dragonchain/prime-sdk-go/proofmeasure" + +pm := proofmeasure.NewProofMeasureClient("") // or a custom base URL +report, err := pm.Report(ctx, &models.ReportRequest{ + TransactionID: "tx-123", PrimeID: "my-prime", BlockID: "42", + Anchors: []models.ReportAnchorInput{ + {Network: "BTC", TxHash: "0x...", Timestamp: anchorUnix}, + {Network: "ETH", TxHash: "0x...", Timestamp: anchorUnix}, + }, +}) +if err != nil { + log.Fatal(err) +} +fmt.Printf("Secured by %s across %d anchors\n", report.TotalValueUSDFormatted, len(report.Anchors)) +``` + ## Authentication The SDK uses HMAC-SHA256 authentication. You need to provide: diff --git a/client/unauthenticated.go b/client/unauthenticated.go new file mode 100644 index 0000000..ff8f2d7 --- /dev/null +++ b/client/unauthenticated.go @@ -0,0 +1,103 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// UnauthenticatedClient is a minimal HTTP client for public Dragonchain services +// that require no HMAC credentials (e.g. the proof-measure service). It mirrors +// Client's body marshaling, status handling, and response decoding, but sends no +// Authorization/Dragonchain/Timestamp headers. +type UnauthenticatedClient struct { + baseURL string + httpClient *http.Client +} + +// NewUnauthenticatedClient builds an UnauthenticatedClient with the default +// HTTP client (30s timeout). +func NewUnauthenticatedClient(baseURL string) *UnauthenticatedClient { + return NewUnauthenticatedClientWithHTTPClient(baseURL, nil) +} + +// NewUnauthenticatedClientWithHTTPClient is like NewUnauthenticatedClient but +// routes requests through the caller-supplied *http.Client (e.g. an SSRF-guarded +// transport). A nil hc falls back to the package default. +func NewUnauthenticatedClientWithHTTPClient(baseURL string, hc *http.Client) *UnauthenticatedClient { + if hc == nil { + hc = &http.Client{Timeout: 30 * time.Second} + } + return &UnauthenticatedClient{ + baseURL: strings.TrimSuffix(baseURL, "/"), + httpClient: hc, + } +} + +// Endpoint returns the configured base URL. +func (c *UnauthenticatedClient) Endpoint() string { return c.baseURL } + +func (c *UnauthenticatedClient) doRequest(ctx context.Context, method, path, contentType string, body any, response any) error { + var bodyBytes []byte + var err error + + if body != nil { + if b, ok := body.([]byte); ok { + bodyBytes = b + } else { + bodyBytes, err = json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to marshal request body: %w", err) + } + contentType = "application/json" + } + } + + fullURL := c.baseURL + path + req, err := http.NewRequestWithContext(ctx, method, fullURL, bytes.NewBuffer(bodyBytes)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode >= 400 { + return fmt.Errorf("API error (status %d): %s", resp.StatusCode, strings.TrimSpace(string(respBody))) + } + + if response != nil && len(respBody) > 0 { + if err := json.Unmarshal(respBody, response); err != nil { + return fmt.Errorf("failed to unmarshal response: %w", err) + } + } + + return nil +} + +// Get performs an unauthenticated GET and decodes the JSON response. +func (c *UnauthenticatedClient) Get(ctx context.Context, path string, response any) error { + return c.doRequest(ctx, http.MethodGet, path, "", nil, response) +} + +// Post performs an unauthenticated POST and decodes the JSON response. +func (c *UnauthenticatedClient) Post(ctx context.Context, path, contentType string, body any, response any) error { + return c.doRequest(ctx, http.MethodPost, path, contentType, body, response) +} diff --git a/dragonchain.go b/dragonchain.go index eb2e5c0..729674a 100755 --- a/dragonchain.go +++ b/dragonchain.go @@ -55,6 +55,7 @@ import ( "git.dragonchain.com/dragonchain/prime-sdk-go/block" "git.dragonchain.com/dragonchain/prime-sdk-go/client" "git.dragonchain.com/dragonchain/prime-sdk-go/contract" + "git.dragonchain.com/dragonchain/prime-sdk-go/proofmeasure" "git.dragonchain.com/dragonchain/prime-sdk-go/system" "git.dragonchain.com/dragonchain/prime-sdk-go/transaction" "git.dragonchain.com/dragonchain/prime-sdk-go/transactiontype" @@ -69,6 +70,11 @@ type DragonchainSDK struct { Contract *contract.ContractClient Block *block.BlockClient System *system.SystemClient + // ProofMeasure calls the public proof-measure service (measured immutability + // / "securedBy"). It is a separate, unauthenticated service, so this handle + // targets its default public endpoint (proofmeasure.DefaultBaseURL); for a + // custom endpoint construct a proofmeasure.ProofMeasureClient directly. + ProofMeasure *proofmeasure.ProofMeasureClient } // NewDragonchainSDK creates a new Dragonchain SDK client. @@ -98,6 +104,7 @@ func NewDragonchainSDKWithHTTPClient(publicID, authKeyID, authKey, baseURL strin Contract: contract.NewContractClient(c), Block: block.NewBlockClient(c), System: system.NewSystemClient(c), + ProofMeasure: proofmeasure.NewProofMeasureClientWithHTTPClient("", hc), } } diff --git a/models/models.go b/models/models.go index 4dbb368..8592ca8 100755 --- a/models/models.go +++ b/models/models.go @@ -206,3 +206,84 @@ type InterchainTrace struct { ValidatorBlocks []VerificationBlock `json:"validatorBlocks"` InterchainTransactions []InterchainTransaction `json:"interchainTransactions"` } + +// --- proof-measure service (measured immutability / "securedBy") --- +// +// Decimal-valued fields are transmitted as strings (full precision); timestamps +// are int64 unix seconds. + +// RawMeasure is a network's native cumulative security measure: cumulative +// hashes for PoW, stake-seconds for PoS. +type RawMeasure struct { + Value string `json:"value"` // human-scaled mantissa (e.g. "484.44") + Unit string `json:"unit"` // e.g. "Zettahashes", "ETH·s" + Base string `json:"base"` // unscaled base amount (hashes or stake-seconds) +} + +// SecurityResult is the security a single network accrued for a window/anchor, +// exposed as the raw native measure AND a USD valuation (energy cost for PoW, +// staked value for PoS), plus a normalized 0..1 score. +type SecurityResult struct { + Network string `json:"network"` + Consensus string `json:"consensus"` // "pow" | "pos" + Raw RawMeasure `json:"raw"` + ValueUSD string `json:"valueUsd"` // decimal string + ValueUSDFormatted string `json:"valueUsdFormatted"` // e.g. "$1,234.56" + Label string `json:"label"` // e.g. "$X energy consumed" / "$X staked" + NormalizedScore string `json:"normalizedScore"` // decimal string in [0,1] + SinceUnix int64 `json:"since"` // window/anchor start (unix seconds) + AsOfUnix int64 `json:"asOf"` // latest sample time (unix seconds) +} + +// ReportAnchorInput is one anchor supplied in a report request. Provide either +// Network ("BTC"/"ETH") or the public-chain numeric ChainID; Network wins. +type ReportAnchorInput struct { + Network string `json:"network,omitempty"` + ChainID string `json:"chainId,omitempty"` + TxHash string `json:"txHash"` + Timestamp int64 `json:"timestamp"` // anchor time, unix seconds +} + +// ReportRequest is the body of ProofMeasure.Report: a transaction's interchain +// anchors. The service computes the security each anchor's network accumulated +// since the anchor and returns a TransactionReport. +type ReportRequest struct { + TransactionID string `json:"transactionId"` + PrimeID string `json:"primeId"` + BlockID string `json:"blockId"` + Anchors []ReportAnchorInput `json:"anchors"` +} + +// AnchorSecurity is one interchain anchor with the security its public network +// has accumulated since the anchor was placed. +type AnchorSecurity struct { + Network string `json:"network"` + AnchorTimestamp int64 `json:"anchorTimestamp"` // unix seconds + AnchorTxHash string `json:"anchorTxHash"` + Security SecurityResult `json:"security"` +} + +// HashPower is the combined raw hash power across a report's PoW anchors. +type HashPower struct { + Value string `json:"value"` + Units string `json:"units"` +} + +// TransactionReport is the per-transaction "securedBy" report: every public-chain +// anchor covering the transaction's block with both raw and USD security, plus +// combined totals. HashPower is nil when there are no PoW anchors. +type TransactionReport struct { + TransactionID string `json:"transactionId"` + PrimeID string `json:"primeId"` + BlockID string `json:"blockId"` + Anchors []AnchorSecurity `json:"anchors"` + TotalValueUSD string `json:"totalValueUsd"` + TotalValueUSDFormatted string `json:"totalValueUsdFormatted"` + HashPower *HashPower `json:"hashPower"` + TotalNormalizedScore string `json:"totalNormalizedScore"` +} + +// HealthResponse is the proof-measure liveness payload. +type HealthResponse struct { + Status string `json:"status"` +} diff --git a/proofmeasure/proofmeasure.go b/proofmeasure/proofmeasure.go new file mode 100644 index 0000000..bd08c2c --- /dev/null +++ b/proofmeasure/proofmeasure.go @@ -0,0 +1,78 @@ +// Package proofmeasure is a client for the Dragonchain proof-measure service — +// the measured-immutability / "securedBy" metric for L1–L5 verification chains. +// +// proof-measure is a separate, public, UNauthenticated service (no API keys), +// so this client takes only a base URL. It exposes a network's accumulated +// security as both a raw measure (cumulative hashes / stake-seconds) and a USD +// valuation, plus a per-transaction "securedBy" report over interchain anchors. +package proofmeasure + +import ( + "context" + "fmt" + "net/http" + + "git.dragonchain.com/dragonchain/prime-sdk-go/client" + "git.dragonchain.com/dragonchain/prime-sdk-go/models" +) + +// DefaultBaseURL is the public production proof-measure endpoint. +const DefaultBaseURL = "https://proof-measure.dragonchain.com" + +// ProofMeasureClient calls the proof-measure HTTP API. +type ProofMeasureClient struct { + client *client.UnauthenticatedClient +} + +// NewProofMeasureClient builds a client for the proof-measure service. An empty +// baseURL defaults to the public production endpoint (DefaultBaseURL). +func NewProofMeasureClient(baseURL string) *ProofMeasureClient { + return NewProofMeasureClientWithHTTPClient(baseURL, nil) +} + +// NewProofMeasureClientWithHTTPClient is like NewProofMeasureClient but routes +// requests through the caller-supplied *http.Client. A nil hc uses the default. +func NewProofMeasureClientWithHTTPClient(baseURL string, hc *http.Client) *ProofMeasureClient { + if baseURL == "" { + baseURL = DefaultBaseURL + } + return &ProofMeasureClient{ + client: client.NewUnauthenticatedClientWithHTTPClient(baseURL, hc), + } +} + +// GetSecurity returns the security a public network (network = "BTC" or "ETH") +// has accumulated since the given unix timestamp, as both a raw measure and a +// USD valuation. A non-positive since omits the parameter, letting the service +// apply its default window. +func (pc *ProofMeasureClient) GetSecurity(ctx context.Context, network string, since int64) (*models.SecurityResult, error) { + path := fmt.Sprintf("/api/v1/security/%s", network) + if since > 0 { + path = fmt.Sprintf("%s?since=%d", path, since) + } + var resp models.SecurityResult + if err := pc.client.Get(ctx, path, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// Report computes the per-transaction "securedBy" report for the supplied +// interchain anchors: each anchor's raw + USD security since it was placed, plus +// combined totals. +func (pc *ProofMeasureClient) Report(ctx context.Context, req *models.ReportRequest) (*models.TransactionReport, error) { + var resp models.TransactionReport + if err := pc.client.Post(ctx, "/api/v1/report", models.ContentTypeJSON, req, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// Health reports service liveness and DB reachability. +func (pc *ProofMeasureClient) Health(ctx context.Context) (*models.HealthResponse, error) { + var resp models.HealthResponse + if err := pc.client.Get(ctx, "/api/v1/health", &resp); err != nil { + return nil, err + } + return &resp, nil +} diff --git a/proofmeasure/proofmeasure_test.go b/proofmeasure/proofmeasure_test.go new file mode 100644 index 0000000..3551ccc --- /dev/null +++ b/proofmeasure/proofmeasure_test.go @@ -0,0 +1,135 @@ +package proofmeasure + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "git.dragonchain.com/dragonchain/prime-sdk-go/models" +) + +func TestGetSecurity(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.RequestURI() + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{ + "network":"BTC","consensus":"pow", + "raw":{"value":"71.63","unit":"Yottahashes","base":"7.16e25"}, + "valueUsd":"13928170.26","valueUsdFormatted":"$13,928,170.26", + "label":"$13,928,170.26 energy consumed","normalizedScore":"0.9234", + "since":1780504894,"asOf":1780591253}`) + })) + defer srv.Close() + + pc := NewProofMeasureClient(srv.URL) + res, err := pc.GetSecurity(context.Background(), "BTC", 1780504894) + if err != nil { + t.Fatalf("GetSecurity: %v", err) + } + if gotPath != "/api/v1/security/BTC?since=1780504894" { + t.Errorf("path = %q, want /api/v1/security/BTC?since=1780504894", gotPath) + } + if res.Network != "BTC" || res.Consensus != "pow" { + t.Errorf("network/consensus = %q/%q", res.Network, res.Consensus) + } + if res.Raw.Unit != "Yottahashes" || res.ValueUSD != "13928170.26" { + t.Errorf("raw.unit=%q valueUsd=%q", res.Raw.Unit, res.ValueUSD) + } + if res.SinceUnix != 1780504894 || res.AsOfUnix != 1780591253 { + t.Errorf("since/asOf = %d/%d", res.SinceUnix, res.AsOfUnix) + } +} + +func TestGetSecurity_NoSinceOmitsParam(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.RequestURI() + _, _ = io.WriteString(w, `{"network":"ETH","consensus":"pos"}`) + })) + defer srv.Close() + + if _, err := NewProofMeasureClient(srv.URL).GetSecurity(context.Background(), "ETH", 0); err != nil { + t.Fatalf("GetSecurity: %v", err) + } + if gotPath != "/api/v1/security/ETH" { + t.Errorf("path = %q, want /api/v1/security/ETH (no since)", gotPath) + } +} + +func TestReport(t *testing.T) { + var gotMethod, gotPath string + var gotReq models.ReportRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + _ = json.NewDecoder(r.Body).Decode(&gotReq) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{ + "transactionId":"tx-1","primeId":"p-1","blockId":"42", + "anchors":[{"network":"BTC","anchorTimestamp":1712345678,"anchorTxHash":"0xabc", + "security":{"network":"BTC","consensus":"pow","valueUsd":"100.00"}}], + "totalValueUsd":"100.00","totalValueUsdFormatted":"$100.00", + "hashPower":{"value":"40.06","units":"Yottahashes"},"totalNormalizedScore":"0.9"}`) + })) + defer srv.Close() + + pc := NewProofMeasureClient(srv.URL) + rep, err := pc.Report(context.Background(), &models.ReportRequest{ + TransactionID: "tx-1", PrimeID: "p-1", BlockID: "42", + Anchors: []models.ReportAnchorInput{{Network: "BTC", TxHash: "0xabc", Timestamp: 1712345678}}, + }) + if err != nil { + t.Fatalf("Report: %v", err) + } + if gotMethod != http.MethodPost || gotPath != "/api/v1/report" { + t.Errorf("method/path = %s %s", gotMethod, gotPath) + } + if len(gotReq.Anchors) != 1 || gotReq.Anchors[0].Network != "BTC" { + t.Errorf("request anchors not sent: %+v", gotReq.Anchors) + } + if rep.TotalValueUSD != "100.00" || rep.HashPower == nil || rep.HashPower.Units != "Yottahashes" { + t.Errorf("report decode wrong: %+v", rep) + } + if len(rep.Anchors) != 1 || rep.Anchors[0].Security.Network != "BTC" { + t.Errorf("report anchors decode wrong: %+v", rep.Anchors) + } +} + +func TestHealth(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/health" { + t.Errorf("health path = %q", r.URL.Path) + } + _, _ = io.WriteString(w, `{"status":"ok"}`) + })) + defer srv.Close() + + h, err := NewProofMeasureClient(srv.URL).Health(context.Background()) + if err != nil { + t.Fatalf("Health: %v", err) + } + if h.Status != "ok" { + t.Errorf("status = %q, want ok", h.Status) + } +} + +func TestDefaultBaseURL(t *testing.T) { + if NewProofMeasureClient("") == nil { + t.Fatal("nil client for empty base URL") + } +} + +func TestErrorStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `bad request`) + })) + defer srv.Close() + + if _, err := NewProofMeasureClient(srv.URL).Health(context.Background()); err == nil { + t.Fatal("expected an error on 400 status, got nil") + } +}