package ledger import ( "encoding/hex" "fmt" "strings" "time" ) type AccountClass string const ( AccountClassUserAvailable AccountClass = "USER_AVAILABLE" AccountClassUserFrozen AccountClass = "USER_FROZEN" AccountClassExternalBlockchain AccountClass = "EXTERNAL_BLOCKCHAIN" AccountClassTreasury AccountClass = "TREASURY" AccountClassMarketClearing AccountClass = "MARKET_CLEARING" AccountClassIPGClearing AccountClass = "IPG_CLEARING" AccountClassCommissionRevenue AccountClass = "COMMISSION_REVENUE" ) func (c AccountClass) Valid() bool { switch c { case AccountClassUserAvailable, AccountClassUserFrozen, AccountClassExternalBlockchain, AccountClassTreasury, AccountClassMarketClearing, AccountClassIPGClearing, AccountClassCommissionRevenue: return true default: return false } } type AccountReference struct { Class AccountClass OwnerType string OwnerID string AssetID int64 } func (a AccountReference) Validate() error { if !a.Class.Valid() { return fmt.Errorf("invalid account class %q", a.Class) } if a.AssetID <= 0 { return fmt.Errorf("asset id must be positive") } if a.Class == AccountClassUserAvailable || a.Class == AccountClassUserFrozen { if a.OwnerType == "" || a.OwnerID == "" { return fmt.Errorf("user account requires owner type and id") } } return nil } type Entry struct { LineNumber uint32 Account AccountReference Amount Amount Description string } type BlockchainReference struct { Network string TransactionHash string LedgerSequence string } type Journal struct { ID string SourceService string IdempotencyKey string SourceTransactionID string TrackingCode string EffectKind string EventVersion uint32 Entries []Entry ReversalOfJournalID string OccurredAt time.Time RecordedAt time.Time CorrelationID string ActorID string Blockchain BlockchainReference Metadata map[string]string PayloadHash string } type AppendResult struct { JournalID string AlreadyExists bool } type JournalFilter struct { Account *AccountReference AssetID *int64 RecordedFrom *time.Time RecordedTo *time.Time Limit int Offset int } func (j Journal) Validate() error { if j.ID == "" || j.SourceService == "" || j.IdempotencyKey == "" || j.SourceTransactionID == "" || j.EffectKind == "" { return fmt.Errorf("journal identity fields are required") } if j.EventVersion == 0 { return fmt.Errorf("event version must be positive") } if j.OccurredAt.IsZero() { return fmt.Errorf("occurred time is required") } if len(j.PayloadHash) != 64 { return fmt.Errorf("payload hash must be a SHA-256 hex string") } if _, err := hex.DecodeString(j.PayloadHash); err != nil { return fmt.Errorf("payload hash must be a SHA-256 hex string") } if j.PayloadHash != strings.ToLower(j.PayloadHash) { return fmt.Errorf("payload hash must use lowercase hexadecimal") } if len(j.Entries) < 2 { return fmt.Errorf("journal requires at least two entries") } lines := make(map[uint32]struct{}, len(j.Entries)) balances := make(map[int64]Amount) for _, entry := range j.Entries { if entry.LineNumber == 0 { return fmt.Errorf("entry line number must be positive") } if _, exists := lines[entry.LineNumber]; exists { return fmt.Errorf("duplicate entry line number %d", entry.LineNumber) } lines[entry.LineNumber] = struct{}{} if err := entry.Account.Validate(); err != nil { return fmt.Errorf("entry %d: %w", entry.LineNumber, err) } if entry.Amount.IsZero() { return fmt.Errorf("entry %d amount must not be zero", entry.LineNumber) } balances[entry.Account.AssetID] = balances[entry.Account.AssetID].Add(entry.Amount) } for assetID, balance := range balances { if !balance.IsZero() { return fmt.Errorf("asset %d entries are unbalanced by %s", assetID, balance.String()) } } return nil }