Skip to content
Open
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
55 changes: 55 additions & 0 deletions crates/common/src/types.rs
Original file line number Diff line number Diff line change
@@ -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<Self, String> {
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, Self::Err> {
Self::parse_normalized(s)
}
}

// ---------------------------------------------------------------------------
// Issue #271 — Contract liveness / TTL tracking
// ---------------------------------------------------------------------------
Expand Down
9 changes: 8 additions & 1 deletion crates/indexer/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
105 changes: 105 additions & 0 deletions database/migrations/0027_network_enum_constraint.sql
Original file line number Diff line number Diff line change
@@ -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'));
22 changes: 21 additions & 1 deletion database/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'));

2 changes: 1 addition & 1 deletion services/api/handlers/validation_envelope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
10 changes: 6 additions & 4 deletions services/api/validation/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions services/api/validation/stats_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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) {
Expand Down
81 changes: 67 additions & 14 deletions services/api/validation/validators.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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, "")
}
Loading