feat(gl): add immutable postgres ledger storage
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
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
|
||||
CorrelationID string
|
||||
ActorID string
|
||||
Blockchain BlockchainReference
|
||||
Metadata map[string]string
|
||||
PayloadHash string
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user