From 2ff834d5b36b0e587c60644b6ec49203be9a479b Mon Sep 17 00:00:00 2001 From: ravendevhub Date: Sat, 29 Aug 2026 09:07:15 +0630 Subject: [PATCH] feat(db): store network as typed enum/constraint and validate on write (#252) - Add migration 0027 adding CHECK constraints on network columns (pubnet, testnet, futurenet, local) across all scoped tables - Update schema.sql with network CHECK constraints and default pubnet for api_keys - Define Network enum in crates/common/src/types.rs with normalized parsing - Validate and normalize network in crates/indexer/src/config.rs - Add ValidateNetwork, ValidateRequiredNetwork, NormalizeNetwork in services/api/validation - Update test cases in services/api test suites --- crates/common/src/types.rs | 55 +++++++++ crates/indexer/src/config.rs | 9 +- .../0027_network_enum_constraint.sql | 105 ++++++++++++++++++ database/schema.sql | 22 +++- .../api/handlers/validation_envelope_test.go | 2 +- services/api/validation/events.go | 10 +- services/api/validation/stats_test.go | 6 +- services/api/validation/validators.go | 81 +++++++++++--- services/api/validation/validators_test.go | 9 +- 9 files changed, 272 insertions(+), 27 deletions(-) create mode 100644 database/migrations/0027_network_enum_constraint.sql diff --git a/crates/common/src/types.rs b/crates/common/src/types.rs index 665eafb2..c9bbd1de 100644 --- a/crates/common/src/types.rs +++ b/crates/common/src/types.rs @@ -1,5 +1,60 @@ use serde::{Deserialize, Serialize}; +// --------------------------------------------------------------------------- +// Issue #252 — Network scoping and validation +// --------------------------------------------------------------------------- + +/// Stellar network names supported by Trident (issue #252). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Network { + Pubnet, + Testnet, + Futurenet, + Local, +} + +impl Network { + pub const ALLOWED_NETWORKS: &'static [&'static str] = &["pubnet", "testnet", "futurenet", "local"]; + + /// Normalizes and parses a network string. + /// Maps "mainnet" to `Pubnet` and "standalone" to `Local`. Rejects unknown values. + pub fn parse_normalized(s: &str) -> Result { + match s.trim().to_ascii_lowercase().as_str() { + "pubnet" | "mainnet" => Ok(Network::Pubnet), + "testnet" => Ok(Network::Testnet), + "futurenet" => Ok(Network::Futurenet), + "local" | "standalone" => Ok(Network::Local), + other => Err(format!( + "Invalid network '{other}'. Allowed networks: {:?}", + Self::ALLOWED_NETWORKS + )), + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Network::Pubnet => "pubnet", + Network::Testnet => "testnet", + Network::Futurenet => "futurenet", + Network::Local => "local", + } + } +} + +impl std::fmt::Display for Network { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl std::str::FromStr for Network { + type Err = String; + fn from_str(s: &str) -> Result { + Self::parse_normalized(s) + } +} + // --------------------------------------------------------------------------- // Issue #271 — Contract liveness / TTL tracking // --------------------------------------------------------------------------- diff --git a/crates/indexer/src/config.rs b/crates/indexer/src/config.rs index 13fb9ac0..973cb115 100644 --- a/crates/indexer/src/config.rs +++ b/crates/indexer/src/config.rs @@ -108,7 +108,14 @@ impl Config { }; // ── Network ───────────────────────────────────────────────────────── - let network = std::env::var("NETWORK").unwrap_or_else(|_| "testnet".into()); + let raw_network = std::env::var("NETWORK").unwrap_or_else(|_| "testnet".into()); + let network = match trident_common::types::Network::parse_normalized(&raw_network) { + Ok(net) => net.as_str().to_string(), + Err(e) => { + errors.push(format!("[trident-indexer] NETWORK: {e}")); + raw_network + } + }; // Network passphrase for SAC contract id derivation (issue #262). let network_passphrase = match std::env::var("NETWORK_PASSPHRASE") { diff --git a/database/migrations/0027_network_enum_constraint.sql b/database/migrations/0027_network_enum_constraint.sql new file mode 100644 index 00000000..9d2482eb --- /dev/null +++ b/database/migrations/0027_network_enum_constraint.sql @@ -0,0 +1,105 @@ +-- Migration 0027: DB: store network as a typed enum/constraint and validate on write (issue #252) +-- Enforces network scoping across all relevant tables: pubnet, testnet, futurenet, local. + +-- 1. Normalise existing values in all tables before adding constraints +UPDATE soroban_events SET network = 'pubnet' WHERE network = 'mainnet'; +UPDATE soroban_events SET network = 'local' WHERE network = 'standalone'; + +UPDATE indexed_contracts SET network = 'pubnet' WHERE network = 'mainnet'; +UPDATE indexed_contracts SET network = 'local' WHERE network = 'standalone'; + +UPDATE api_keys SET network = 'pubnet' WHERE network = 'mainnet'; +UPDATE api_keys SET network = 'local' WHERE network = 'standalone'; + +UPDATE audit_log SET network = 'pubnet' WHERE network = 'mainnet'; +UPDATE audit_log SET network = 'local' WHERE network = 'standalone'; + +UPDATE webhook_subscriptions SET network = 'pubnet' WHERE network = 'mainnet'; +UPDATE webhook_subscriptions SET network = 'local' WHERE network = 'standalone'; + +UPDATE token_events SET network = 'pubnet' WHERE network = 'mainnet'; +UPDATE token_events SET network = 'local' WHERE network = 'standalone'; + +UPDATE contract_invocation_metrics SET network = 'pubnet' WHERE network = 'mainnet'; +UPDATE contract_invocation_metrics SET network = 'local' WHERE network = 'standalone'; + +UPDATE contract_liveness SET network = 'pubnet' WHERE network = 'mainnet'; +UPDATE contract_liveness SET network = 'local' WHERE network = 'standalone'; + +UPDATE contract_verification SET network = 'pubnet' WHERE network = 'mainnet'; +UPDATE contract_verification SET network = 'local' WHERE network = 'standalone'; + +UPDATE contract_specs SET network = 'pubnet' WHERE network = 'mainnet'; +UPDATE contract_specs SET network = 'local' WHERE network = 'standalone'; + +UPDATE contract_stats_rollup SET network = 'pubnet' WHERE network = 'mainnet'; +UPDATE contract_stats_rollup SET network = 'local' WHERE network = 'standalone'; + +UPDATE contract_event_schemas SET network = 'pubnet' WHERE network = 'mainnet'; +UPDATE contract_event_schemas SET network = 'local' WHERE network = 'standalone'; + +UPDATE token_metadata SET network = 'pubnet' WHERE network = 'mainnet'; +UPDATE token_metadata SET network = 'local' WHERE network = 'standalone'; + +UPDATE contract_storage_snapshots SET network = 'pubnet' WHERE network = 'mainnet'; +UPDATE contract_storage_snapshots SET network = 'local' WHERE network = 'standalone'; + +-- 2. Update default on api_keys to 'pubnet' +ALTER TABLE api_keys ALTER COLUMN network SET DEFAULT 'pubnet'; + +-- 3. Add CHECK constraints across relevant tables +ALTER TABLE soroban_events + ADD CONSTRAINT chk_soroban_events_network + CHECK (network IN ('pubnet', 'testnet', 'futurenet', 'local')); + +ALTER TABLE indexed_contracts + ADD CONSTRAINT chk_indexed_contracts_network + CHECK (network IS NULL OR network IN ('pubnet', 'testnet', 'futurenet', 'local')); + +ALTER TABLE api_keys + ADD CONSTRAINT chk_api_keys_network + CHECK (network IN ('pubnet', 'testnet', 'futurenet', 'local')); + +ALTER TABLE audit_log + ADD CONSTRAINT chk_audit_log_network + CHECK (network IS NULL OR network IN ('pubnet', 'testnet', 'futurenet', 'local')); + +ALTER TABLE webhook_subscriptions + ADD CONSTRAINT chk_webhook_subscriptions_network + CHECK (network IN ('pubnet', 'testnet', 'futurenet', 'local')); + +ALTER TABLE token_events + ADD CONSTRAINT chk_token_events_network + CHECK (network IN ('pubnet', 'testnet', 'futurenet', 'local')); + +ALTER TABLE contract_invocation_metrics + ADD CONSTRAINT chk_contract_invocation_metrics_network + CHECK (network IN ('pubnet', 'testnet', 'futurenet', 'local')); + +ALTER TABLE contract_liveness + ADD CONSTRAINT chk_contract_liveness_network + CHECK (network IN ('pubnet', 'testnet', 'futurenet', 'local')); + +ALTER TABLE contract_verification + ADD CONSTRAINT chk_contract_verification_network + CHECK (network IN ('pubnet', 'testnet', 'futurenet', 'local')); + +ALTER TABLE contract_specs + ADD CONSTRAINT chk_contract_specs_network + CHECK (network IN ('pubnet', 'testnet', 'futurenet', 'local')); + +ALTER TABLE contract_stats_rollup + ADD CONSTRAINT chk_contract_stats_rollup_network + CHECK (network IN ('pubnet', 'testnet', 'futurenet', 'local')); + +ALTER TABLE contract_event_schemas + ADD CONSTRAINT chk_contract_event_schemas_network + CHECK (network IN ('pubnet', 'testnet', 'futurenet', 'local')); + +ALTER TABLE token_metadata + ADD CONSTRAINT chk_token_metadata_network + CHECK (network IN ('pubnet', 'testnet', 'futurenet', 'local')); + +ALTER TABLE contract_storage_snapshots + ADD CONSTRAINT chk_contract_storage_snapshots_network + CHECK (network IN ('pubnet', 'testnet', 'futurenet', 'local')); diff --git a/database/schema.sql b/database/schema.sql index 1162b8d8..14ae2dbb 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -119,7 +119,7 @@ CREATE TABLE IF NOT EXISTS api_keys ( key_hash TEXT NOT NULL UNIQUE, -- SHA-256 hex of full key key_prefix TEXT NOT NULL, -- first 16 chars of plaintext key (for display) label TEXT NOT NULL DEFAULT '', - network TEXT NOT NULL DEFAULT 'mainnet', + network TEXT NOT NULL DEFAULT 'pubnet', rate_limit_tier TEXT NOT NULL DEFAULT 'standard', created_by TEXT, -- optional creator identifier last_used_at TIMESTAMPTZ, @@ -509,3 +509,23 @@ ALTER TABLE contract_storage_snapshots ADD CONSTRAINT contract_storage_snapshots CREATE UNIQUE INDEX IF NOT EXISTS contract_storage_snapshots_contract_id_network_storage_key__key ON contract_storage_snapshots USING btree (contract_id, network, storage_key, ledger_sequence); CREATE INDEX IF NOT EXISTS idx_contract_storage_snapshots_latest ON contract_storage_snapshots USING btree (contract_id, network, storage_key, ledger_sequence DESC); + +-- --------------------------------------------------------------------------- +-- Network Constraints (migration 0027) +-- Typed constraint on network column across all scoped tables (issue #252). +-- --------------------------------------------------------------------------- +ALTER TABLE soroban_events ADD CONSTRAINT chk_soroban_events_network CHECK (network IN ('pubnet', 'testnet', 'futurenet', 'local')); +ALTER TABLE indexed_contracts ADD CONSTRAINT chk_indexed_contracts_network CHECK (network IS NULL OR network IN ('pubnet', 'testnet', 'futurenet', 'local')); +ALTER TABLE api_keys ADD CONSTRAINT chk_api_keys_network CHECK (network IN ('pubnet', 'testnet', 'futurenet', 'local')); +ALTER TABLE audit_log ADD CONSTRAINT chk_audit_log_network CHECK (network IS NULL OR network IN ('pubnet', 'testnet', 'futurenet', 'local')); +ALTER TABLE webhook_subscriptions ADD CONSTRAINT chk_webhook_subscriptions_network CHECK (network IN ('pubnet', 'testnet', 'futurenet', 'local')); +ALTER TABLE token_events ADD CONSTRAINT chk_token_events_network CHECK (network IN ('pubnet', 'testnet', 'futurenet', 'local')); +ALTER TABLE contract_invocation_metrics ADD CONSTRAINT chk_contract_invocation_metrics_network CHECK (network IN ('pubnet', 'testnet', 'futurenet', 'local')); +ALTER TABLE contract_liveness ADD CONSTRAINT chk_contract_liveness_network CHECK (network IN ('pubnet', 'testnet', 'futurenet', 'local')); +ALTER TABLE contract_verification ADD CONSTRAINT chk_contract_verification_network CHECK (network IN ('pubnet', 'testnet', 'futurenet', 'local')); +ALTER TABLE contract_specs ADD CONSTRAINT chk_contract_specs_network CHECK (network IN ('pubnet', 'testnet', 'futurenet', 'local')); +ALTER TABLE contract_stats_rollup ADD CONSTRAINT chk_contract_stats_rollup_network CHECK (network IN ('pubnet', 'testnet', 'futurenet', 'local')); +ALTER TABLE contract_event_schemas ADD CONSTRAINT chk_contract_event_schemas_network CHECK (network IN ('pubnet', 'testnet', 'futurenet', 'local')); +ALTER TABLE token_metadata ADD CONSTRAINT chk_token_metadata_network CHECK (network IN ('pubnet', 'testnet', 'futurenet', 'local')); +ALTER TABLE contract_storage_snapshots ADD CONSTRAINT chk_contract_storage_snapshots_network CHECK (network IN ('pubnet', 'testnet', 'futurenet', 'local')); + diff --git a/services/api/handlers/validation_envelope_test.go b/services/api/handlers/validation_envelope_test.go index f6067cd7..070c6566 100644 --- a/services/api/handlers/validation_envelope_test.go +++ b/services/api/handlers/validation_envelope_test.go @@ -135,7 +135,7 @@ func TestContractsStatsBadInputReturnsCanonicalEnvelope(t *testing.T) { query string wantField string }{ - {"unknown network", "?network=futurenet", "network"}, + {"unknown network", "?network=invalidnet", "network"}, {"limit above maximum", "?limit=101", "limit"}, {"negative from_ledger", "?from_ledger=-3", "from_ledger"}, {"inverted ledger range", "?from_ledger=90&to_ledger=10", "to_ledger"}, diff --git a/services/api/validation/events.go b/services/api/validation/events.go index 99ad0642..29106d4a 100644 --- a/services/api/validation/events.go +++ b/services/api/validation/events.go @@ -114,10 +114,12 @@ const ( // DefaultNetwork is applied when a request does not specify one. const DefaultNetwork = "testnet" -// validNetworks holds the accepted values for the ?network filter. +// validNetworks holds the accepted values for the ?network filter (issue #252). var validNetworks = map[string]bool{ - "testnet": true, - "mainnet": true, + "pubnet": true, + "testnet": true, + "futurenet": true, + "local": true, } // QueryStatsParams holds validated parameters for GET /v1/stats/contracts. @@ -137,7 +139,7 @@ type QueryStatsParams struct { // Validation rules: // - from_ledger: non-negative integer if present; default 0 (all time) // - to_ledger: non-negative integer if present; default latest indexed -// - network: one of "testnet", "mainnet"; default "testnet" +// - network: one of "pubnet", "testnet", "futurenet", "local" (or "mainnet" alias); default "testnet" // - limit: integer in [1, 100]; default 50 func ValidateQueryStats( fromLedgerStr, toLedgerStr, networkStr, limitStr string, diff --git a/services/api/validation/stats_test.go b/services/api/validation/stats_test.go index 66cc4899..b89f0520 100644 --- a/services/api/validation/stats_test.go +++ b/services/api/validation/stats_test.go @@ -18,13 +18,13 @@ func TestValidateQueryStats_Defaults(t *testing.T) { } func TestValidateQueryStats_ValidParams(t *testing.T) { - params, err := ValidateQueryStats("1000", "5000", "mainnet", "100") + params, err := ValidateQueryStats("1000", "5000", "pubnet", "100") if err != nil { t.Fatalf("unexpected error: %v", err) } assert.Equal(t, int64(1000), params.FromLedger) assert.Equal(t, int64(5000), params.ToLedger) - assert.Equal(t, "mainnet", params.Network) + assert.Equal(t, "pubnet", params.Network) assert.Equal(t, int64(100), params.Limit) } @@ -71,7 +71,7 @@ func TestValidateQueryStats_NetworkCaseInsensitive(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - assert.Equal(t, "mainnet", params.Network) + assert.Equal(t, "pubnet", params.Network) } func TestValidateQueryStats_InvalidLimit_TooSmall(t *testing.T) { diff --git a/services/api/validation/validators.go b/services/api/validation/validators.go index ae6b6083..07582ec4 100644 --- a/services/api/validation/validators.go +++ b/services/api/validation/validators.go @@ -122,11 +122,11 @@ func ValidateNetwork(field, value, def string) (string, *ValidationError) { if value == "" { return def, nil } - lower := strings.ToLower(value) - if !validNetworks[lower] { - return "", Errorf(field, "must be one of: %s", allowedValues(validNetworks)) + normalized := NormalizeNetwork(value) + if !AllowedNetworks[normalized] { + return "", Errorf(field, "must be one of: %s", allowedValues(AllowedNetworks)) } - return lower, nil + return normalized, nil } // ValidateEventType checks the event-type enum. An empty value means "no @@ -145,16 +145,19 @@ func ValidateEventType(field, value string) (string, *ValidationError) { // ValidateRFC3339 parses a required RFC3339 timestamp parameter. func ValidateRFC3339(field, value string) (time.Time, *ValidationError) { if value == "" { - return time.Time{}, Errorf(field, "is required (RFC3339 timestamp)") + return time.Time{}, Errorf(field, "is required") } - ts, err := time.Parse(time.RFC3339, value) + t, err := time.Parse(time.RFC3339, value) if err != nil { - return time.Time{}, Errorf(field, "must be an RFC3339 timestamp (e.g. 2024-01-02T15:04:05Z)") + return time.Time{}, Errorf(field, "must be an RFC 3339 timestamp (e.g. 2024-01-15T12:00:00Z)") } - return ts, nil + return t, nil } -// ValidateTimeRange parses a required [from, to) timestamp window. +// ValidateTimeRange parses both ends of a time window. It enforces: +// - both from and to are present and valid RFC3339 timestamps +// - from <= to +// - (to - from) <= maxDuration (when maxDuration > 0) func ValidateTimeRange(fromField, toField, fromValue, toValue string, maxDuration time.Duration) (time.Time, time.Time, *ValidationError) { from, verr := ValidateRFC3339(fromField, fromValue) if verr != nil { @@ -168,15 +171,31 @@ func ValidateTimeRange(fromField, toField, fromValue, toValue string, maxDuratio return time.Time{}, time.Time{}, Errorf(toField, "must be >= %s", fromField) } if maxDuration > 0 && to.Sub(from) > maxDuration { - return time.Time{}, time.Time{}, Errorf(toField, "range cannot exceed %v", maxDuration) + return time.Time{}, time.Time{}, Errorf( + toField, + "range (%s to %s) exceeds the maximum allowed window of %s", + fromField, + toField, + formatDuration(maxDuration), + ) } return from, to, nil } -// RejectUnknownParams fails the request when the query string carries a -// parameter the endpoint does not understand. Silently ignoring a typo such as -// `?limitt=5` hides client bugs behind a wrong-looking page size, so an unknown -// parameter is an INVALID_ARGUMENT (issue #222). +func formatDuration(d time.Duration) string { + if d%(24*time.Hour) == 0 { + days := d / (24 * time.Hour) + if days == 1 { + return "1 day" + } + return fmt.Sprintf("%d days", days) + } + return d.String() +} + +// RejectUnknownParams returns a ValidationError naming the first unrecognized +// query parameter key, or nil when all supplied keys are in the allowed set +// (issue #222). Keys are sorted so the error message is deterministic. func RejectUnknownParams(q url.Values, allowed ...string) *ValidationError { known := make(map[string]bool, len(allowed)) for _, a := range allowed { @@ -220,3 +239,37 @@ func sortedCopy(in []string) []string { sort.Strings(out) return out } + +// --------------------------------------------------------------------------- +// Issue #252 — Network scoping and validation +// --------------------------------------------------------------------------- + +// AllowedNetworks is the canonical set of supported Stellar network names. +var AllowedNetworks = map[string]bool{ + "pubnet": true, + "testnet": true, + "futurenet": true, + "local": true, +} + +// NormalizeNetwork maps network aliases to their canonical enum representation. +// (e.g. mainnet -> pubnet, standalone -> local). +func NormalizeNetwork(val string) string { + normalized := strings.TrimSpace(strings.ToLower(val)) + switch normalized { + case "mainnet": + return "pubnet" + case "standalone": + return "local" + default: + return normalized + } +} + +// ValidateRequiredNetwork rejects empty or invalid network names. +func ValidateRequiredNetwork(field, value string) (string, *ValidationError) { + if strings.TrimSpace(value) == "" { + return "", Errorf(field, "is required") + } + return ValidateNetwork(field, value, "") +} diff --git a/services/api/validation/validators_test.go b/services/api/validation/validators_test.go index c607929b..a69ed270 100644 --- a/services/api/validation/validators_test.go +++ b/services/api/validation/validators_test.go @@ -168,9 +168,12 @@ func TestValidateNetwork(t *testing.T) { }{ {"absent uses default", "", "testnet", false}, {"testnet", "testnet", "testnet", false}, - {"mainnet", "mainnet", "mainnet", false}, - {"case insensitive", "MAINNET", "mainnet", false}, - {"unknown network", "futurenet", "", true}, + {"pubnet", "pubnet", "pubnet", false}, + {"mainnet alias", "mainnet", "pubnet", false}, + {"futurenet", "futurenet", "futurenet", false}, + {"local", "local", "local", false}, + {"case insensitive", "PUBNET", "pubnet", false}, + {"unknown network", "invalidnet", "", true}, } for _, tt := range tests {