diff --git a/project-portal/project-portal-backend/go.sum b/project-portal/project-portal-backend/go.sum index b03e0585..bdbf8d3e 100644 --- a/project-portal/project-portal-backend/go.sum +++ b/project-portal/project-portal-backend/go.sum @@ -32,6 +32,8 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIM github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= +github.com/aws/aws-sdk-go-v2/service/sns v1.31.3 h1:eSTEdxkfle2G98FE+Xl3db/XAXXVTJPNQo9K/Ar8oAI= +github.com/aws/aws-sdk-go-v2/service/sns v1.31.3/go.mod h1:1dn0delSO3J69THuty5iwP0US2Glt0mx2qBBlI13pvw= github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 h1:+VTRawC4iVY58pS/lzpo0lnoa/SYNGF4/B/3/U5ro8Y= github.com/aws/aws-sdk-go-v2/service/sso v1.30.10/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM= @@ -306,8 +308,6 @@ gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc= gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= -github.com/aws/aws-sdk-go-v2/service/sns v1.31.3 h1:eSTEdxkfle2G98FE+Xl3db/XAXXVTJPNQo9K/Ar8oAI= -github.com/aws/aws-sdk-go-v2/service/sns v1.31.3/go.mod h1:1dn0delSO3J69THuty5iwP0US2Glt0mx2qBBlI13pvw= - diff --git a/project-portal/project-portal-backend/internal/monitoring/handler.go b/project-portal/project-portal-backend/internal/monitoring/handler.go index bf6cd562..f91e941d 100644 --- a/project-portal/project-portal-backend/internal/monitoring/handler.go +++ b/project-portal/project-portal-backend/internal/monitoring/handler.go @@ -1,7 +1,69 @@ -//go:build future -// +build future - package monitoring -// This file won't be compiled in normal builds -// Implementation pending +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +// Handler exposes monitoring endpoints on a Gin router group. +type Handler struct { + svc *Service +} + +// NewHandler constructs a monitoring Handler. +func NewHandler(svc *Service) *Handler { + return &Handler{svc: svc} +} + +// RegisterRoutes mounts the monitoring routes under the provided router group. +// +// POST /api/v1/monitoring/satellite – ingest satellite data +// GET /api/v1/monitoring/:projectID – list readings for a project +func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) { + rg.POST("/satellite", h.ingestSatellite) + rg.GET("/:projectID", h.listReadings) +} + +// ingestSatellite handles POST /api/v1/monitoring/satellite +// +// @Summary Ingest satellite data +// @Description Submit a satellite observation for a registered project +// @Tags monitoring +// @Accept json +// @Produce json +// @Param body body IngestSatelliteRequest true "Satellite reading payload" +// @Success 201 {object} SatelliteReading +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/monitoring/satellite [post] +func (h *Handler) ingestSatellite(c *gin.Context) { + var req IngestSatelliteRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + reading, err := h.svc.IngestSatellite(c.Request.Context(), req) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, reading) +} + +// listReadings handles GET /api/v1/monitoring/:projectID +func (h *Handler) listReadings(c *gin.Context) { + projectID := c.Param("projectID") + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + + readings, err := h.svc.ListReadings(c.Request.Context(), projectID, limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": readings, "count": len(readings)}) +} diff --git a/project-portal/project-portal-backend/internal/monitoring/ingestion/satellite.go b/project-portal/project-portal-backend/internal/monitoring/ingestion/satellite.go index bf6cd562..eb65af23 100644 --- a/project-portal/project-portal-backend/internal/monitoring/ingestion/satellite.go +++ b/project-portal/project-portal-backend/internal/monitoring/ingestion/satellite.go @@ -1,7 +1,155 @@ -//go:build future -// +build future +package ingestion -package monitoring +import ( + "context" + "errors" + "fmt" + "strings" + "time" -// This file won't be compiled in normal builds -// Implementation pending + "github.com/google/uuid" +) + +// SatelliteReading is a local copy of the reading type to avoid import cycles. +// The monitoring package embeds this via its own SatelliteReading type. +type SatelliteReading struct { + ID string + ProjectID string + Source string + DataType string + NDVIMean *float64 + NDVIMin *float64 + NDVIMax *float64 + BiomassTons *float64 + ImageryURL string + BoundingBox *BoundingBox + Metadata map[string]string + CapturedAt time.Time + IngestedAt time.Time +} + +// BoundingBox is a geographic bounding box (WGS-84). +type BoundingBox struct { + MinLat float64 `json:"min_lat"` + MaxLat float64 `json:"max_lat"` + MinLon float64 `json:"min_lon"` + MaxLon float64 `json:"max_lon"` +} + +// IngestRequest is the validated input for the satellite pipeline. +type IngestRequest struct { + ProjectID string + Source string + DataType string + NDVIMean *float64 + NDVIMin *float64 + NDVIMax *float64 + BiomassTons *float64 + ImageryURL string + BoundingBox *BoundingBox + Metadata map[string]string + CapturedAt time.Time +} + +// Repository is the persistence contract used by the pipeline. +type Repository interface { + Save(ctx context.Context, r *SatelliteReading) error +} + +// allowedSources lists accepted satellite data providers. +var allowedSources = map[string]bool{ + "sentinel-2": true, + "planet-labs": true, + "landsat-8": true, + "landsat-9": true, + "drone": true, +} + +// allowedDataTypes lists accepted data type identifiers. +var allowedDataTypes = map[string]bool{ + "NDVI": true, + "BIOMASS": true, + "IMAGERY": true, +} + +// SatellitePipeline validates and persists incoming satellite data. +type SatellitePipeline struct { + repo Repository +} + +// NewSatellitePipeline constructs a SatellitePipeline backed by the given repository. +func NewSatellitePipeline(repo Repository) *SatellitePipeline { + return &SatellitePipeline{repo: repo} +} + +// Ingest validates the request and persists a SatelliteReading. +func (p *SatellitePipeline) Ingest(ctx context.Context, req IngestRequest) (*SatelliteReading, error) { + if err := validate(req); err != nil { + return nil, err + } + + now := time.Now().UTC() + reading := &SatelliteReading{ + ID: uuid.NewString(), + ProjectID: req.ProjectID, + Source: strings.ToLower(req.Source), + DataType: strings.ToUpper(req.DataType), + NDVIMean: req.NDVIMean, + NDVIMin: req.NDVIMin, + NDVIMax: req.NDVIMax, + BiomassTons: req.BiomassTons, + ImageryURL: req.ImageryURL, + BoundingBox: req.BoundingBox, + Metadata: req.Metadata, + CapturedAt: req.CapturedAt.UTC(), + IngestedAt: now, + } + + if err := p.repo.Save(ctx, reading); err != nil { + return nil, fmt.Errorf("satellite ingestion: persist failed: %w", err) + } + + return reading, nil +} + +// validate checks required fields, allowed values, and data-type-specific constraints. +func validate(req IngestRequest) error { + if strings.TrimSpace(req.ProjectID) == "" { + return errors.New("project_id is required") + } + if !allowedSources[strings.ToLower(req.Source)] { + return fmt.Errorf("unsupported source %q; allowed: sentinel-2, planet-labs, landsat-8, landsat-9, drone", req.Source) + } + if !allowedDataTypes[strings.ToUpper(req.DataType)] { + return fmt.Errorf("unsupported data_type %q; allowed: NDVI, BIOMASS, IMAGERY", req.DataType) + } + if req.CapturedAt.IsZero() { + return errors.New("captured_at is required") + } + if req.CapturedAt.After(time.Now().UTC().Add(5 * time.Minute)) { + return errors.New("captured_at cannot be in the future") + } + + switch strings.ToUpper(req.DataType) { + case "NDVI": + if req.NDVIMean == nil { + return errors.New("ndvi_mean is required for NDVI data type") + } + if *req.NDVIMean < -1 || *req.NDVIMean > 1 { + return errors.New("ndvi_mean must be in range [-1, 1]") + } + case "BIOMASS": + if req.BiomassTons == nil { + return errors.New("biomass_tons is required for BIOMASS data type") + } + if *req.BiomassTons < 0 { + return errors.New("biomass_tons must be non-negative") + } + case "IMAGERY": + if strings.TrimSpace(req.ImageryURL) == "" { + return errors.New("imagery_url is required for IMAGERY data type") + } + } + + return nil +} diff --git a/project-portal/project-portal-backend/internal/monitoring/models.go b/project-portal/project-portal-backend/internal/monitoring/models.go index bf6cd562..6292509f 100644 --- a/project-portal/project-portal-backend/internal/monitoring/models.go +++ b/project-portal/project-portal-backend/internal/monitoring/models.go @@ -1,7 +1,26 @@ -//go:build future -// +build future - package monitoring -// This file won't be compiled in normal builds -// Implementation pending +import ( + "time" + + "carbon-scribe/project-portal/project-portal-backend/internal/monitoring/ingestion" +) + +// Re-export ingestion types so callers only import this package. +type BoundingBox = ingestion.BoundingBox +type SatelliteReading = ingestion.SatelliteReading + +// IngestSatelliteRequest is the API payload for POST /api/v1/monitoring/satellite. +type IngestSatelliteRequest struct { + ProjectID string `json:"project_id" binding:"required"` + Source string `json:"source" binding:"required"` + DataType string `json:"data_type" binding:"required"` + NDVIMean *float64 `json:"ndvi_mean"` + NDVIMin *float64 `json:"ndvi_min"` + NDVIMax *float64 `json:"ndvi_max"` + BiomassTons *float64 `json:"biomass_tons"` + ImageryURL string `json:"imagery_url"` + BoundingBox *BoundingBox `json:"bounding_box"` + Metadata map[string]string `json:"metadata"` + CapturedAt time.Time `json:"captured_at" binding:"required"` +} diff --git a/project-portal/project-portal-backend/internal/monitoring/postgres_repository.go b/project-portal/project-portal-backend/internal/monitoring/postgres_repository.go index bf6cd562..5560d526 100644 --- a/project-portal/project-portal-backend/internal/monitoring/postgres_repository.go +++ b/project-portal/project-portal-backend/internal/monitoring/postgres_repository.go @@ -1,7 +1,86 @@ -//go:build future -// +build future - package monitoring -// This file won't be compiled in normal builds -// Implementation pending +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + + "carbon-scribe/project-portal/project-portal-backend/internal/monitoring/ingestion" +) + +// PostgresRepository implements Repository using a *sql.DB connection. +type PostgresRepository struct { + db *sql.DB +} + +// NewPostgresRepository constructs a PostgresRepository. +func NewPostgresRepository(db *sql.DB) *PostgresRepository { + return &PostgresRepository{db: db} +} + +// Save inserts a SatelliteReading into the satellite_readings table. +func (r *PostgresRepository) Save(ctx context.Context, reading *ingestion.SatelliteReading) error { + metaJSON, err := json.Marshal(reading.Metadata) + if err != nil { + return fmt.Errorf("marshal metadata: %w", err) + } + + var bboxJSON []byte + if reading.BoundingBox != nil { + bboxJSON, err = json.Marshal(reading.BoundingBox) + if err != nil { + return fmt.Errorf("marshal bounding_box: %w", err) + } + } + + _, err = r.db.ExecContext(ctx, ` + INSERT INTO satellite_readings + (id, project_id, source, data_type, ndvi_mean, ndvi_min, ndvi_max, + biomass_tons, imagery_url, bounding_box, metadata, captured_at, ingested_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, + reading.ID, reading.ProjectID, reading.Source, reading.DataType, + reading.NDVIMean, reading.NDVIMin, reading.NDVIMax, + reading.BiomassTons, reading.ImageryURL, bboxJSON, metaJSON, + reading.CapturedAt, reading.IngestedAt, + ) + return err +} + +// ListByProject returns the most recent satellite readings for a project. +func (r *PostgresRepository) ListByProject(ctx context.Context, projectID string, limit int) ([]ingestion.SatelliteReading, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, project_id, source, data_type, ndvi_mean, ndvi_min, ndvi_max, + biomass_tons, imagery_url, bounding_box, metadata, captured_at, ingested_at + FROM satellite_readings + WHERE project_id = $1 + ORDER BY captured_at DESC + LIMIT $2`, projectID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []ingestion.SatelliteReading + for rows.Next() { + var sr ingestion.SatelliteReading + var bboxJSON, metaJSON []byte + if err := rows.Scan( + &sr.ID, &sr.ProjectID, &sr.Source, &sr.DataType, + &sr.NDVIMean, &sr.NDVIMin, &sr.NDVIMax, + &sr.BiomassTons, &sr.ImageryURL, &bboxJSON, &metaJSON, + &sr.CapturedAt, &sr.IngestedAt, + ); err != nil { + return nil, err + } + if len(bboxJSON) > 0 { + sr.BoundingBox = &ingestion.BoundingBox{} + _ = json.Unmarshal(bboxJSON, sr.BoundingBox) + } + if len(metaJSON) > 0 { + _ = json.Unmarshal(metaJSON, &sr.Metadata) + } + results = append(results, sr) + } + return results, rows.Err() +} diff --git a/project-portal/project-portal-backend/internal/monitoring/repository.go b/project-portal/project-portal-backend/internal/monitoring/repository.go index bf6cd562..0ea2097c 100644 --- a/project-portal/project-portal-backend/internal/monitoring/repository.go +++ b/project-portal/project-portal-backend/internal/monitoring/repository.go @@ -1,7 +1,13 @@ -//go:build future -// +build future - package monitoring -// This file won't be compiled in normal builds -// Implementation pending +import ( + "context" + + "carbon-scribe/project-portal/project-portal-backend/internal/monitoring/ingestion" +) + +// Repository defines persistence operations for satellite monitoring data. +type Repository interface { + ingestion.Repository + ListByProject(ctx context.Context, projectID string, limit int) ([]SatelliteReading, error) +} diff --git a/project-portal/project-portal-backend/internal/monitoring/service.go b/project-portal/project-portal-backend/internal/monitoring/service.go index bf6cd562..c408b66c 100644 --- a/project-portal/project-portal-backend/internal/monitoring/service.go +++ b/project-portal/project-portal-backend/internal/monitoring/service.go @@ -1,7 +1,47 @@ -//go:build future -// +build future - package monitoring -// This file won't be compiled in normal builds -// Implementation pending +import ( + "context" + + "carbon-scribe/project-portal/project-portal-backend/internal/monitoring/ingestion" +) + +// Service orchestrates satellite data ingestion and retrieval. +type Service struct { + pipeline *ingestion.SatellitePipeline + repo Repository +} + +// NewService constructs a monitoring Service. +func NewService(repo Repository) *Service { + return &Service{ + pipeline: ingestion.NewSatellitePipeline(repo), + repo: repo, + } +} + +// IngestSatellite validates and persists a satellite reading. +func (s *Service) IngestSatellite(ctx context.Context, req IngestSatelliteRequest) (*SatelliteReading, error) { + ir := ingestion.IngestRequest{ + ProjectID: req.ProjectID, + Source: req.Source, + DataType: req.DataType, + NDVIMean: req.NDVIMean, + NDVIMin: req.NDVIMin, + NDVIMax: req.NDVIMax, + BiomassTons: req.BiomassTons, + ImageryURL: req.ImageryURL, + BoundingBox: req.BoundingBox, + Metadata: req.Metadata, + CapturedAt: req.CapturedAt, + } + return s.pipeline.Ingest(ctx, ir) +} + +// ListReadings returns the most recent satellite readings for a project. +func (s *Service) ListReadings(ctx context.Context, projectID string, limit int) ([]SatelliteReading, error) { + if limit <= 0 { + limit = 50 + } + return s.repo.ListByProject(ctx, projectID, limit) +} diff --git a/project-portal/project-portal-backend/internal/notifications/channels/email.go b/project-portal/project-portal-backend/internal/notifications/channels/email.go index 4ef17ced..0e2abdf8 100644 --- a/project-portal/project-portal-backend/internal/notifications/channels/email.go +++ b/project-portal/project-portal-backend/internal/notifications/channels/email.go @@ -1,7 +1,145 @@ -//go:build future -// +build future +package channels -package notifications +import ( + "context" + "errors" + "fmt" + "log" + "net/smtp" + "os" + "regexp" + "strings" + "time" +) -// This file won't be compiled in normal builds -// Implementation pending +// EmailChannel sends transactional and alert emails via SMTP or AWS SES (SMTP interface). +// Configuration is read from environment variables: +// +// EMAIL_SMTP_HOST - SMTP host (default: email-smtp.us-east-1.amazonaws.com) +// EMAIL_SMTP_PORT - SMTP port (default: 587) +// EMAIL_SMTP_USER - SMTP username / AWS SES SMTP user +// EMAIL_SMTP_PASSWORD - SMTP password / AWS SES SMTP password +// EMAIL_FROM_ADDRESS - Sender address (default: noreply@carbonscribe.io) +// EMAIL_FROM_NAME - Sender display name (default: CarbonScribe) +type EmailChannel struct { + host string + port string + user string + password string + from string + fromName string + maxRetry int +} + +// EmailMessage is the payload passed to Send. +type EmailMessage struct { + To string + Subject string + Body string // plain-text or HTML body +} + +var emailRegexp = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) + +// NewEmailChannel constructs an EmailChannel from environment variables. +func NewEmailChannel() *EmailChannel { + host := getEnvOrDefault("EMAIL_SMTP_HOST", "email-smtp.us-east-1.amazonaws.com") + port := getEnvOrDefault("EMAIL_SMTP_PORT", "587") + user := os.Getenv("EMAIL_SMTP_USER") + password := os.Getenv("EMAIL_SMTP_PASSWORD") + from := getEnvOrDefault("EMAIL_FROM_ADDRESS", "noreply@carbonscribe.io") + fromName := getEnvOrDefault("EMAIL_FROM_NAME", "CarbonScribe") + + return &EmailChannel{ + host: host, + port: port, + user: user, + password: password, + from: from, + fromName: fromName, + maxRetry: 3, + } +} + +// Send delivers an email with retry logic. ctx is honoured between retries. +func (c *EmailChannel) Send(ctx context.Context, msg EmailMessage) error { + if err := c.validate(msg); err != nil { + return fmt.Errorf("email validation: %w", err) + } + + raw := c.buildRaw(msg) + addr := c.host + ":" + c.port + + var lastErr error + for attempt := 1; attempt <= c.maxRetry; attempt++ { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + if err := c.sendSMTP(addr, raw, msg.To); err != nil { + lastErr = err + log.Printf("[EmailChannel] attempt %d/%d failed for %s: %v", attempt, c.maxRetry, msg.To, err) + if attempt < c.maxRetry { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Duration(attempt) * 2 * time.Second): + } + } + continue + } + + log.Printf("[EmailChannel] email delivered to %s (attempt %d)", msg.To, attempt) + return nil + } + + return fmt.Errorf("email delivery failed after %d attempts: %w", c.maxRetry, lastErr) +} + +// sendSMTP performs the actual SMTP dial and send. +func (c *EmailChannel) sendSMTP(addr, raw, to string) error { + var auth smtp.Auth + if c.user != "" && c.password != "" { + auth = smtp.PlainAuth("", c.user, c.password, c.host) + } + + return smtp.SendMail(addr, auth, c.from, []string{to}, []byte(raw)) +} + +// buildRaw constructs the RFC 2822 message. +func (c *EmailChannel) buildRaw(msg EmailMessage) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("From: %s <%s>\r\n", c.fromName, c.from)) + sb.WriteString(fmt.Sprintf("To: %s\r\n", msg.To)) + sb.WriteString(fmt.Sprintf("Subject: %s\r\n", msg.Subject)) + sb.WriteString("MIME-Version: 1.0\r\n") + sb.WriteString("Content-Type: text/plain; charset=\"UTF-8\"\r\n") + sb.WriteString("\r\n") + sb.WriteString(msg.Body) + return sb.String() +} + +// validate checks required fields and address format. +func (c *EmailChannel) validate(msg EmailMessage) error { + if strings.TrimSpace(msg.To) == "" { + return errors.New("recipient address is required") + } + if !emailRegexp.MatchString(msg.To) { + return fmt.Errorf("invalid recipient address: %s", msg.To) + } + if strings.TrimSpace(msg.Subject) == "" { + return errors.New("subject is required") + } + if strings.TrimSpace(msg.Body) == "" { + return errors.New("body is required") + } + return nil +} + +func getEnvOrDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/project-portal/project-portal-backend/internal/notifications/service.go b/project-portal/project-portal-backend/internal/notifications/service.go index 5fcbb8b8..3b57a4fc 100644 --- a/project-portal/project-portal-backend/internal/notifications/service.go +++ b/project-portal/project-portal-backend/internal/notifications/service.go @@ -1,23 +1,23 @@ package notifications import ( - "context" - "errors" - "fmt" - "regexp" - "strings" - "time" + "context" + "errors" + "fmt" + "regexp" + "strings" + "time" - "carbon-scribe/project-portal/project-portal-backend/internal/notifications/channels" + "carbon-scribe/project-portal/project-portal-backend/internal/notifications/channels" - "github.com/google/uuid" + "github.com/google/uuid" ) type Service struct { - repo Repository - retryLimit int - defaultLocale string - smsSender channels.SMSSender + repo Repository + retryLimit int + defaultLocale string + smsSender channels.SMSSender } func NewService(repo Repository) *Service { diff --git a/stellar-core/verifiable-registry/Cargo.lock b/stellar-core/verifiable-registry/Cargo.lock index c418cb10..e93f3c63 100644 --- a/stellar-core/verifiable-registry/Cargo.lock +++ b/stellar-core/verifiable-registry/Cargo.lock @@ -854,7 +854,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] -name = "merkle-bridge" +name = "merkle_bridge" version = "0.1.0" dependencies = [ "soroban-sdk", @@ -1579,13 +1579,6 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" -[[package]] -name = "time-lock" -version = "0.1.0" -dependencies = [ - "soroban-sdk", -] - [[package]] name = "time-macros" version = "0.2.26" @@ -1596,6 +1589,13 @@ dependencies = [ "time-core", ] +[[package]] +name = "time_lock" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "typenum" version = "1.19.0" diff --git a/stellar-core/verifiable-registry/contracts/merkle_bridge/Cargo.toml b/stellar-core/verifiable-registry/contracts/merkle_bridge/Cargo.toml index 6516b334..aab28385 100644 --- a/stellar-core/verifiable-registry/contracts/merkle_bridge/Cargo.toml +++ b/stellar-core/verifiable-registry/contracts/merkle_bridge/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "merkle-bridge" +name = "merkle_bridge" version = "0.1.0" edition = "2021" description = "MerkleBridge contract for verifiable tokenization of carbon credits from external registries" diff --git a/stellar-core/verifiable-registry/contracts/merkle_bridge/src/lib.rs b/stellar-core/verifiable-registry/contracts/merkle_bridge/src/lib.rs index 33b49f57..cfb4ca29 100644 --- a/stellar-core/verifiable-registry/contracts/merkle_bridge/src/lib.rs +++ b/stellar-core/verifiable-registry/contracts/merkle_bridge/src/lib.rs @@ -91,6 +91,50 @@ pub enum MerkleBridgeError { CarbonAssetNotSet = 11, /// Epoch must be sequential NonSequentialEpoch = 12, + /// registry_credit_id is too short (minimum 8 characters) + CreditIdTooShort = 13, + /// registry_credit_id is too long (maximum 64 characters) + CreditIdTooLong = 14, + /// registry_credit_id contains disallowed characters (only A-Z, a-z, 0-9, '-', '_' allowed) + CreditIdInvalidCharset = 15, +} + +// ============ registry_credit_id Validation ============ + +/// Minimum allowed length for a registry_credit_id. +const CREDIT_ID_MIN_LEN: u32 = 8; +/// Maximum allowed length for a registry_credit_id. +const CREDIT_ID_MAX_LEN: u32 = 64; + +/// Validate that a registry_credit_id meets length and charset requirements. +/// +/// Rules: +/// - Length: 8–64 characters (inclusive) +/// - Allowed characters: ASCII alphanumeric (`A-Z`, `a-z`, `0-9`), hyphen (`-`), underscore (`_`) +/// +/// These constraints ensure unambiguous, unique Merkle leaf construction and +/// compatibility with relayer and off-chain verification tools. +fn validate_registry_credit_id(id: &String) -> Result<(), MerkleBridgeError> { + let len = id.len(); + + if len < CREDIT_ID_MIN_LEN { + return Err(MerkleBridgeError::CreditIdTooShort); + } + if len > CREDIT_ID_MAX_LEN { + return Err(MerkleBridgeError::CreditIdTooLong); + } + + let mut buf = [0u8; 64]; + id.copy_into_slice(&mut buf[..len as usize]); + + for &b in buf.iter().take(len as usize) { + let allowed = matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_'); + if !allowed { + return Err(MerkleBridgeError::CreditIdInvalidCharset); + } + } + + Ok(()) } // Note: CarbonAsset contract integration will be added once the CarbonAsset @@ -121,11 +165,7 @@ impl MerkleBridge { /// /// # Returns /// * `Result<(), MerkleBridgeError>` - Success or error - pub fn initialize( - env: Env, - admin: Address, - updater: Address, - ) -> Result<(), MerkleBridgeError> { + pub fn initialize(env: Env, admin: Address, updater: Address) -> Result<(), MerkleBridgeError> { // Check if already initialized if env.storage().instance().has(&DataKey::Admin) { return Err(MerkleBridgeError::AlreadyInitialized); @@ -247,12 +287,7 @@ impl MerkleBridge { } .publish(&env); - log!( - &env, - "Root updated for epoch {}: {:?}", - epoch_id, - root_hash - ); + log!(&env, "Root updated for epoch {}: {:?}", epoch_id, root_hash); Ok(()) } @@ -279,6 +314,9 @@ impl MerkleBridge { ) -> Result { caller.require_auth(); + // Validate registry_credit_id length and charset before any state access + validate_registry_credit_id(®istry_credit_id)?; + // Check if credit has already been minted if Self::is_credit_minted(&env, ®istry_credit_id) { return Err(MerkleBridgeError::AlreadyMinted); @@ -362,6 +400,9 @@ impl MerkleBridge { caller.require_auth(); Self::require_updater(&env, &caller)?; + // Validate registry_credit_id length and charset + validate_registry_credit_id(®istry_credit_id)?; + // Mark as retired env.storage() .persistent() @@ -506,7 +547,11 @@ impl MerkleBridge { } /// Compute the leaf hash: sha256(registry_credit_id || status) - fn compute_leaf_hash(env: &Env, registry_credit_id: &String, status: CreditStatus) -> BytesN<32> { + fn compute_leaf_hash( + env: &Env, + registry_credit_id: &String, + status: CreditStatus, + ) -> BytesN<32> { // Convert registry_credit_id to bytes let mut data = Bytes::new(env); @@ -514,8 +559,8 @@ impl MerkleBridge { let id_len = registry_credit_id.len() as usize; let mut id_buffer = [0u8; 256]; // Max length buffer registry_credit_id.copy_into_slice(&mut id_buffer[..id_len]); - for i in 0..id_len { - data.push_back(id_buffer[i]); + for &b in id_buffer.iter().take(id_len) { + data.push_back(b); } // Append status bytes @@ -935,7 +980,7 @@ mod tests { client.initialize(&admin, &updater); // Create 4-leaf tree - let ids = ["VER-001", "VER-002", "VER-003", "VER-004"]; + let ids = ["VER-0001", "VER-0002", "VER-0003", "VER-0004"]; let leaves: [BytesN<32>; 4] = [ compute_test_leaf_hash(&env, ids[0]), compute_test_leaf_hash(&env, ids[1]), @@ -1006,15 +1051,96 @@ mod tests { client.initialize(&admin, &updater); - let leaf_hash = compute_test_leaf_hash(&env, "VER-123"); + let leaf_hash = compute_test_leaf_hash(&env, "VER-1234A"); client.update_root(&updater, &1, &leaf_hash); // Try to mint with leaf_index too large for proof length let user = Address::generate(&env); - let registry_id = String::from_str(&env, "VER-123"); + let registry_id = String::from_str(&env, "VER-1234A"); let proof: Vec> = Vec::new(&env); // leaf_index = 1 but proof is empty (only supports index 0) client.mint_wrapped(&user, ®istry_id, &proof, &1, &1); // Should panic } + + // ============ registry_credit_id Validation Tests ============ + + #[test] + #[should_panic(expected = "Error(Contract, #13)")] + fn test_mint_credit_id_too_short_fails() { + let (env, admin, updater) = setup_env(); + let contract_id = create_contract(&env); + let client = MerkleBridgeClient::new(&env, &contract_id); + client.initialize(&admin, &updater); + + let user = Address::generate(&env); + // 5 chars — below minimum of 8 + let short_id = String::from_str(&env, "VER-1"); + let proof: Vec> = Vec::new(&env); + client.mint_wrapped(&user, &short_id, &proof, &0, &1); + } + + #[test] + #[should_panic(expected = "Error(Contract, #14)")] + fn test_mint_credit_id_too_long_fails() { + let (env, admin, updater) = setup_env(); + let contract_id = create_contract(&env); + let client = MerkleBridgeClient::new(&env, &contract_id); + client.initialize(&admin, &updater); + + let user = Address::generate(&env); + // 65 chars — above maximum of 64 + let long_id = String::from_str( + &env, + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA-X", + ); + let proof: Vec> = Vec::new(&env); + client.mint_wrapped(&user, &long_id, &proof, &0, &1); + } + + #[test] + #[should_panic(expected = "Error(Contract, #15)")] + fn test_mint_credit_id_invalid_charset_fails() { + let (env, admin, updater) = setup_env(); + let contract_id = create_contract(&env); + let client = MerkleBridgeClient::new(&env, &contract_id); + client.initialize(&admin, &updater); + + let user = Address::generate(&env); + // Contains space — not in allowed charset + let bad_id = String::from_str(&env, "VER 123 ABC"); + let proof: Vec> = Vec::new(&env); + client.mint_wrapped(&user, &bad_id, &proof, &0, &1); + } + + #[test] + fn test_mint_valid_credit_id_succeeds() { + let (env, admin, updater) = setup_env(); + let contract_id = create_contract(&env); + let client = MerkleBridgeClient::new(&env, &contract_id); + client.initialize(&admin, &updater); + + // Valid ID: alphanumeric + hyphens + underscores, 8–64 chars + let registry_id = String::from_str(&env, "VER-123-ABC-456"); + let leaf_hash = compute_test_leaf_hash(&env, "VER-123-ABC-456"); + client.update_root(&updater, &1, &leaf_hash); + + let user = Address::generate(&env); + let proof: Vec> = Vec::new(&env); + let token_id = client.mint_wrapped(&user, ®istry_id, &proof, &0, &1); + assert_eq!(token_id, 1); + } + + #[test] + #[should_panic(expected = "Error(Contract, #15)")] + fn test_mark_retired_invalid_charset_fails() { + let (env, admin, updater) = setup_env(); + let contract_id = create_contract(&env); + let client = MerkleBridgeClient::new(&env, &contract_id); + client.initialize(&admin, &updater); + + // Contains dot — not in allowed charset + let bad_id = String::from_str(&env, "VER.123.ABC.456"); + client.mark_retired(&updater, &bad_id); + } } diff --git a/stellar-core/verifiable-registry/contracts/registry_contract/src/lib.rs b/stellar-core/verifiable-registry/contracts/registry_contract/src/lib.rs index 9ac4da2b..172d88e4 100644 --- a/stellar-core/verifiable-registry/contracts/registry_contract/src/lib.rs +++ b/stellar-core/verifiable-registry/contracts/registry_contract/src/lib.rs @@ -29,6 +29,23 @@ impl ProjectRegistry { Ok(()) } + /// Enable or disable strict monotonic timestamp enforcement (admin only). + /// + /// When enabled, every new document anchored for a project must have a + /// timestamp strictly greater than the previous one, preventing backdating. + pub fn set_monotonic_enforcement(env: Env, enabled: bool) -> Result<(), Error> { + let admin = storage::get_admin(&env)?; + admin.require_auth(); + storage::set_monotonic_enforcement(&env, enabled); + extend_instance_ttl(&env); + Ok(()) + } + + /// Return whether strict monotonic timestamp enforcement is currently enabled. + pub fn is_monotonic_enforcement_enabled(env: Env) -> bool { + storage::get_monotonic_enforcement(&env) + } + /// Register a new project and assign initial owner (admin only) pub fn register_project(env: Env, project_id: String, owner: Address) -> Result<(), Error> { let admin = storage::get_admin(&env)?; @@ -73,6 +90,16 @@ impl ProjectRegistry { validate_ipfs_cid(&ipfs_cid)?; let timestamp = env.ledger().timestamp(); + + // Enforce strict monotonic timestamps when the flag is enabled + if storage::get_monotonic_enforcement(&env) { + if let Some(last_ts) = storage::get_last_timestamp(&env, &project_id) { + if timestamp <= last_ts { + return Err(Error::TimestampNotMonotonic); + } + } + } + let record = DocumentRecord { ipfs_cid: ipfs_cid.clone(), timestamp, @@ -90,6 +117,9 @@ impl ProjectRegistry { // Store updated history storage::set_document_history(&env, &project_id, &history); + // Update last recorded timestamp for monotonic enforcement + storage::set_last_timestamp(&env, &project_id, timestamp); + // Update anchorer index let mut anchorer_projects = storage::get_anchorer_projects(&env, &owner).unwrap_or_else(|_| Vec::new(&env)); @@ -122,6 +152,16 @@ impl ProjectRegistry { } let timestamp = env.ledger().timestamp(); + + // Enforce strict monotonic timestamps when the flag is enabled + if storage::get_monotonic_enforcement(&env) { + if let Some(last_ts) = storage::get_last_timestamp(&env, &project_id) { + if timestamp <= last_ts { + return Err(Error::TimestampNotMonotonic); + } + } + } + let mut history = storage::get_document_history(&env, &project_id).unwrap_or_else(|_| Vec::new(&env)); @@ -157,6 +197,9 @@ impl ProjectRegistry { // Store updated history storage::set_document_history(&env, &project_id, &history); + // Update last recorded timestamp for monotonic enforcement + storage::set_last_timestamp(&env, &project_id, timestamp); + // Update anchorer index let mut anchorer_projects = storage::get_anchorer_projects(&env, &owner).unwrap_or_else(|_| Vec::new(&env)); diff --git a/stellar-core/verifiable-registry/contracts/registry_contract/src/storage.rs b/stellar-core/verifiable-registry/contracts/registry_contract/src/storage.rs index e4e3adba..424bc427 100644 --- a/stellar-core/verifiable-registry/contracts/registry_contract/src/storage.rs +++ b/stellar-core/verifiable-registry/contracts/registry_contract/src/storage.rs @@ -12,6 +12,10 @@ pub enum StorageKey { ProjectOwner(String), DocumentHistory(String), AncorerProjects(Address), + /// Whether strict monotonic timestamp enforcement is enabled + MonotonicEnforcement, + /// Last recorded timestamp for a project (project_id -> u64) + LastTimestamp(String), } /// Extend the TTL of instance storage @@ -92,3 +96,32 @@ pub fn set_anchorer_projects(env: &Env, anchorer: &Address, projects: &Vec bool { + env.storage() + .instance() + .get(&StorageKey::MonotonicEnforcement) + .unwrap_or(false) +} + +pub fn set_monotonic_enforcement(env: &Env, enabled: bool) { + env.storage() + .instance() + .set(&StorageKey::MonotonicEnforcement, &enabled); +} + +pub fn get_last_timestamp(env: &Env, project_id: &String) -> Option { + env.storage() + .persistent() + .get(&StorageKey::LastTimestamp(project_id.clone())) +} + +pub fn set_last_timestamp(env: &Env, project_id: &String, timestamp: u64) { + let key = StorageKey::LastTimestamp(project_id.clone()); + env.storage().persistent().set(&key, ×tamp); + env.storage() + .persistent() + .extend_ttl(&key, INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); +} diff --git a/stellar-core/verifiable-registry/contracts/registry_contract/src/test.rs b/stellar-core/verifiable-registry/contracts/registry_contract/src/test.rs index 75d60705..670a38f2 100644 --- a/stellar-core/verifiable-registry/contracts/registry_contract/src/test.rs +++ b/stellar-core/verifiable-registry/contracts/registry_contract/src/test.rs @@ -1,5 +1,6 @@ #![cfg(test)] +use soroban_sdk::testutils::Ledger; use soroban_sdk::{testutils::Address as _, Address, Env, String as SorobanString, Vec}; use crate::types::Error; @@ -290,3 +291,120 @@ fn test_valid_cid_min_length() { let cid = SorobanString::from_str(&env, "QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco"); assert!(validate_ipfs_cid(&cid).is_ok()); } + +// ========== Monotonic Timestamp Tests ========== + +#[test] +fn test_monotonic_enforcement_disabled_by_default() { + let (env, _, client) = create_contract(); + let admin = Address::generate(&env); + client.initialize(&admin); + // Enforcement is off by default + assert!(!client.is_monotonic_enforcement_enabled()); +} + +#[test] +fn test_set_monotonic_enforcement_toggle() { + let (env, _, client) = create_contract(); + let admin = Address::generate(&env); + client.initialize(&admin); + + client.set_monotonic_enforcement(&true); + assert!(client.is_monotonic_enforcement_enabled()); + + client.set_monotonic_enforcement(&false); + assert!(!client.is_monotonic_enforcement_enabled()); +} + +#[test] +fn test_monotonic_enforcement_allows_increasing_timestamps() { + let (env, _, client) = create_contract(); + let admin = Address::generate(&env); + let owner = Address::generate(&env); + let project_id = SorobanString::from_str(&env, "PROJ-001"); + let cid1 = SorobanString::from_str(&env, "QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco"); + let cid2 = SorobanString::from_str(&env, "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"); + let doc_type = SorobanString::from_str(&env, "PDD"); + + client.initialize(&admin); + client.set_monotonic_enforcement(&true); + client.register_project(&project_id, &owner); + + // First anchor at ledger timestamp 1000 + env.ledger().set_timestamp(1000); + client.anchor_document(&project_id, &cid1, &doc_type); + + // Second anchor at a strictly later timestamp — should succeed + env.ledger().set_timestamp(1001); + let v2 = client.anchor_document(&project_id, &cid2, &doc_type); + assert_eq!(v2, 1); +} + +#[test] +#[should_panic(expected = "Error(Contract, #9)")] +fn test_monotonic_enforcement_rejects_backdated_timestamp() { + let (env, _, client) = create_contract(); + let admin = Address::generate(&env); + let owner = Address::generate(&env); + let project_id = SorobanString::from_str(&env, "PROJ-001"); + let cid1 = SorobanString::from_str(&env, "QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco"); + let cid2 = SorobanString::from_str(&env, "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"); + let doc_type = SorobanString::from_str(&env, "PDD"); + + client.initialize(&admin); + client.set_monotonic_enforcement(&true); + client.register_project(&project_id, &owner); + + env.ledger().set_timestamp(1000); + client.anchor_document(&project_id, &cid1, &doc_type); + + // Same timestamp — should be rejected (not strictly greater) + env.ledger().set_timestamp(1000); + client.anchor_document(&project_id, &cid2, &doc_type); +} + +#[test] +#[should_panic(expected = "Error(Contract, #9)")] +fn test_monotonic_enforcement_rejects_earlier_timestamp() { + let (env, _, client) = create_contract(); + let admin = Address::generate(&env); + let owner = Address::generate(&env); + let project_id = SorobanString::from_str(&env, "PROJ-001"); + let cid1 = SorobanString::from_str(&env, "QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco"); + let cid2 = SorobanString::from_str(&env, "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"); + let doc_type = SorobanString::from_str(&env, "PDD"); + + client.initialize(&admin); + client.set_monotonic_enforcement(&true); + client.register_project(&project_id, &owner); + + env.ledger().set_timestamp(1000); + client.anchor_document(&project_id, &cid1, &doc_type); + + // Earlier timestamp — should be rejected + env.ledger().set_timestamp(999); + client.anchor_document(&project_id, &cid2, &doc_type); +} + +#[test] +fn test_monotonic_enforcement_disabled_allows_any_order() { + let (env, _, client) = create_contract(); + let admin = Address::generate(&env); + let owner = Address::generate(&env); + let project_id = SorobanString::from_str(&env, "PROJ-001"); + let cid1 = SorobanString::from_str(&env, "QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco"); + let cid2 = SorobanString::from_str(&env, "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"); + let doc_type = SorobanString::from_str(&env, "PDD"); + + client.initialize(&admin); + // Enforcement is off — any timestamp order is accepted + client.register_project(&project_id, &owner); + + env.ledger().set_timestamp(1000); + client.anchor_document(&project_id, &cid1, &doc_type); + + // Same timestamp — allowed when enforcement is disabled + env.ledger().set_timestamp(1000); + let v2 = client.anchor_document(&project_id, &cid2, &doc_type); + assert_eq!(v2, 1); +} diff --git a/stellar-core/verifiable-registry/contracts/registry_contract/src/types.rs b/stellar-core/verifiable-registry/contracts/registry_contract/src/types.rs index c4b8ff11..27031083 100644 --- a/stellar-core/verifiable-registry/contracts/registry_contract/src/types.rs +++ b/stellar-core/verifiable-registry/contracts/registry_contract/src/types.rs @@ -35,4 +35,6 @@ pub enum Error { EmptyBatch = 7, /// No projects found for anchorer NoProjectsFound = 8, + /// Timestamp is not strictly greater than the last recorded timestamp (anti-backdate) + TimestampNotMonotonic = 9, } diff --git a/stellar-core/verifiable-registry/contracts/registry_contract/test_snapshots/test/test_anchor_document.1.json b/stellar-core/verifiable-registry/contracts/registry_contract/test_snapshots/test/test_anchor_document.1.json index fc967273..7a77965f 100644 --- a/stellar-core/verifiable-registry/contracts/registry_contract/test_snapshots/test/test_anchor_document.1.json +++ b/stellar-core/verifiable-registry/contracts/registry_contract/test_snapshots/test/test_anchor_document.1.json @@ -215,6 +215,51 @@ 518400 ] ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "LastTimestamp" + }, + { + "string": "PROJ-001" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "LastTimestamp" + }, + { + "string": "PROJ-001" + } + ] + }, + "durability": "persistent", + "val": { + "u64": "0" + } + } + }, + "ext": "v0" + }, + 518400 + ] + ], [ { "contract_data": { diff --git a/stellar-core/verifiable-registry/contracts/registry_contract/test_snapshots/test/test_anchor_document_batch.1.json b/stellar-core/verifiable-registry/contracts/registry_contract/test_snapshots/test/test_anchor_document_batch.1.json index bc6e5245..d59baa56 100644 --- a/stellar-core/verifiable-registry/contracts/registry_contract/test_snapshots/test/test_anchor_document_batch.1.json +++ b/stellar-core/verifiable-registry/contracts/registry_contract/test_snapshots/test/test_anchor_document_batch.1.json @@ -315,6 +315,51 @@ 518400 ] ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "LastTimestamp" + }, + { + "string": "PROJ-001" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "LastTimestamp" + }, + { + "string": "PROJ-001" + } + ] + }, + "durability": "persistent", + "val": { + "u64": "0" + } + } + }, + "ext": "v0" + }, + 518400 + ] + ], [ { "contract_data": { diff --git a/stellar-core/verifiable-registry/contracts/registry_contract/test_snapshots/test/test_anchor_document_multiple_versions.1.json b/stellar-core/verifiable-registry/contracts/registry_contract/test_snapshots/test/test_anchor_document_multiple_versions.1.json index d1254199..400c9515 100644 --- a/stellar-core/verifiable-registry/contracts/registry_contract/test_snapshots/test/test_anchor_document_multiple_versions.1.json +++ b/stellar-core/verifiable-registry/contracts/registry_contract/test_snapshots/test/test_anchor_document_multiple_versions.1.json @@ -277,6 +277,51 @@ 518400 ] ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "LastTimestamp" + }, + { + "string": "PROJ-001" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "LastTimestamp" + }, + { + "string": "PROJ-001" + } + ] + }, + "durability": "persistent", + "val": { + "u64": "0" + } + } + }, + "ext": "v0" + }, + 518400 + ] + ], [ { "contract_data": { diff --git a/stellar-core/verifiable-registry/contracts/registry_contract/test_snapshots/test/test_get_document_history.1.json b/stellar-core/verifiable-registry/contracts/registry_contract/test_snapshots/test/test_get_document_history.1.json index 70637667..649d13a2 100644 --- a/stellar-core/verifiable-registry/contracts/registry_contract/test_snapshots/test/test_get_document_history.1.json +++ b/stellar-core/verifiable-registry/contracts/registry_contract/test_snapshots/test/test_get_document_history.1.json @@ -276,6 +276,51 @@ 518400 ] ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "LastTimestamp" + }, + { + "string": "PROJ-001" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "LastTimestamp" + }, + { + "string": "PROJ-001" + } + ] + }, + "durability": "persistent", + "val": { + "u64": "0" + } + } + }, + "ext": "v0" + }, + 518400 + ] + ], [ { "contract_data": { diff --git a/stellar-core/verifiable-registry/contracts/registry_contract/test_snapshots/test/test_get_projects_by_anchorer.1.json b/stellar-core/verifiable-registry/contracts/registry_contract/test_snapshots/test/test_get_projects_by_anchorer.1.json index c85cc5f6..b1fb7f43 100644 --- a/stellar-core/verifiable-registry/contracts/registry_contract/test_snapshots/test/test_get_projects_by_anchorer.1.json +++ b/stellar-core/verifiable-registry/contracts/registry_contract/test_snapshots/test/test_get_projects_by_anchorer.1.json @@ -347,6 +347,96 @@ 518400 ] ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "LastTimestamp" + }, + { + "string": "PROJ-001" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "LastTimestamp" + }, + { + "string": "PROJ-001" + } + ] + }, + "durability": "persistent", + "val": { + "u64": "0" + } + } + }, + "ext": "v0" + }, + 518400 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "LastTimestamp" + }, + { + "string": "PROJ-002" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "LastTimestamp" + }, + { + "string": "PROJ-002" + } + ] + }, + "durability": "persistent", + "val": { + "u64": "0" + } + } + }, + "ext": "v0" + }, + 518400 + ] + ], [ { "contract_data": { diff --git a/stellar-core/verifiable-registry/contracts/time_lock/Cargo.toml b/stellar-core/verifiable-registry/contracts/time_lock/Cargo.toml index 044e47a9..d6b09c40 100644 --- a/stellar-core/verifiable-registry/contracts/time_lock/Cargo.toml +++ b/stellar-core/verifiable-registry/contracts/time_lock/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "time-lock" +name = "time_lock" version = "0.1.0" edition = "2021" description = "Time lock contract for vintage locking mechanisms"