diff --git a/ocp/data/history/memory/store.go b/ocp/data/history/memory/store.go new file mode 100644 index 0000000..ab1ca65 --- /dev/null +++ b/ocp/data/history/memory/store.go @@ -0,0 +1,293 @@ +package memory + +import ( + "context" + "sort" + "sync" + "time" + + "github.com/code-payments/ocp-server/database/query" + "github.com/code-payments/ocp-server/ocp/data/history" + "github.com/code-payments/ocp-server/pointer" +) + +// ByCreatedAt orders records the way a history is read: by event time, with the +// record ID breaking ties so the order is total. +type ByCreatedAt []*history.Record + +func (a ByCreatedAt) Len() int { return len(a) } +func (a ByCreatedAt) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a ByCreatedAt) Less(i, j int) bool { + if !a[i].CreatedAt.Equal(a[j].CreatedAt) { + return a[i].CreatedAt.Before(a[j].CreatedAt) + } + return a[i].Id < a[j].Id +} + +type store struct { + mu sync.RWMutex + records []*history.Record + last uint64 +} + +func New() history.Store { + return &store{} +} + +func (s *store) Save(_ context.Context, data *history.Record) error { + if err := data.Validate(); err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + if data.Id == 0 { + if s.findByOwnerAndReference(data.OwnerAccount, data.ReferenceType, data.ReferenceId) != nil { + return history.ErrExists + } + + s.last++ + data.Id = s.last + data.Version++ + data.UpdatedAt = data.CreatedAt + + cloned := data.Clone() + s.records = append(s.records, &cloned) + + return nil + } + + item := s.findById(data.Id) + if item == nil { + return history.ErrNotFound + } + if item.Version != data.Version { + return history.ErrStaleVersion + } + + // Only the mutable part of a record is applied. The caller then gets the + // record back as stored, so an edit to an immutable field is neither + // persisted nor left behind on the caller's copy. + item.State = data.State + item.DestinationQuantity = pointer.Uint64Copy(data.DestinationQuantity) + item.Fees = cloneFees(data.Fees) + item.Version++ + item.UpdatedAt = time.Now() + + item.CopyTo(data) + + return nil +} + +func (s *store) GetAllByOwner(_ context.Context, owner string, cursor query.Cursor, limit uint64, direction query.Ordering) ([]*history.Record, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + items := s.findByOwner(owner) + + res, err := s.page(items, cursor, limit, direction) + if err != nil { + return nil, err + } + if len(res) == 0 { + return nil, history.ErrNotFound + } + return res, nil +} + +func (s *store) GetAllByOwnerMint(_ context.Context, owner, mint string, cursor query.Cursor, limit uint64, direction query.Ordering) ([]*history.Record, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + items := s.filterByMint(s.findByOwner(owner), mint) + + res, err := s.page(items, cursor, limit, direction) + if err != nil { + return nil, err + } + if len(res) == 0 { + return nil, history.ErrNotFound + } + return res, nil +} + +func (s *store) GetAllByIds(_ context.Context, ids []uint64) ([]*history.Record, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + wanted := make(map[uint64]struct{}, len(ids)) + for _, id := range ids { + wanted[id] = struct{}{} + } + + var items []*history.Record + for _, item := range s.records { + if _, ok := wanted[item.Id]; ok { + items = append(items, item) + } + } + + sort.Slice(items, func(i, j int) bool { return items[i].Id < items[j].Id }) + + if len(items) == 0 { + return nil, history.ErrNotFound + } + return cloneRecords(items), nil +} + +func (s *store) GetAllByReference(_ context.Context, referenceType history.ReferenceType, referenceId string) ([]*history.Record, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + var items []*history.Record + for _, item := range s.records { + if item.ReferenceType == referenceType && item.ReferenceId == referenceId { + items = append(items, item) + } + } + + if len(items) == 0 { + return nil, history.ErrNotFound + } + return cloneRecords(items), nil +} + +func (s *store) GetAllByGiftCardVault(_ context.Context, vault string) ([]*history.Record, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + var items []*history.Record + for _, item := range s.records { + if item.GiftCardVault != nil && *item.GiftCardVault == vault { + items = append(items, item) + } + } + + if len(items) == 0 { + return nil, history.ErrNotFound + } + return cloneRecords(items), nil +} + +func (s *store) findById(id uint64) *history.Record { + for _, item := range s.records { + if item.Id == id { + return item + } + } + return nil +} + +func (s *store) findByOwnerAndReference(owner string, referenceType history.ReferenceType, referenceId string) *history.Record { + for _, item := range s.records { + if item.OwnerAccount == owner && item.ReferenceType == referenceType && item.ReferenceId == referenceId { + return item + } + } + return nil +} + +func (s *store) findByOwner(owner string) []*history.Record { + var res []*history.Record + for _, item := range s.records { + if item.OwnerAccount == owner { + res = append(res, item) + } + } + return res +} + +func (s *store) filterByMint(items []*history.Record, mint string) []*history.Record { + var res []*history.Record + for _, item := range items { + if item.MintAccount == mint { + res = append(res, item) + continue + } + if item.DestinationMintAccount != nil && *item.DestinationMintAccount == mint { + res = append(res, item) + } + } + return res +} + +func (s *store) page(items []*history.Record, cursor query.Cursor, limit uint64, direction query.Ordering) ([]*history.Record, error) { + var res []*history.Record + if len(cursor) == 0 { + res = append(res, items...) + } else { + createdAt, id, ok := history.FromCursor(cursor) + if !ok { + return nil, history.ErrInvalidCursor + } + + for _, item := range items { + cmp := compareToCursor(item, createdAt, id) + if direction == query.Ascending && cmp > 0 { + res = append(res, item) + } + if direction == query.Descending && cmp < 0 { + res = append(res, item) + } + } + } + + if direction == query.Descending { + sort.Sort(sort.Reverse(ByCreatedAt(res))) + } else { + sort.Sort(ByCreatedAt(res)) + } + + if limit > 0 && uint64(len(res)) > limit { + res = res[:limit] + } + + return cloneRecords(res), nil +} + +// compareToCursor orders a record against a cursor position on the same +// (event time, ID) terms the history itself is ordered by. +func compareToCursor(item *history.Record, createdAt time.Time, id uint64) int { + if !item.CreatedAt.Equal(createdAt) { + if item.CreatedAt.Before(createdAt) { + return -1 + } + return 1 + } + + switch { + case item.Id < id: + return -1 + case item.Id > id: + return 1 + default: + return 0 + } +} + +func cloneRecords(items []*history.Record) []*history.Record { + res := make([]*history.Record, 0, len(items)) + for _, item := range items { + cloned := item.Clone() + res = append(res, &cloned) + } + return res +} + +func cloneFees(fees []history.Fee) []history.Fee { + if fees == nil { + return nil + } + cloned := make([]history.Fee, len(fees)) + copy(cloned, fees) + return cloned +} + +func (s *store) reset() { + s.mu.Lock() + defer s.mu.Unlock() + + s.records = nil + s.last = 0 +} diff --git a/ocp/data/history/memory/store_test.go b/ocp/data/history/memory/store_test.go new file mode 100644 index 0000000..e1b58c7 --- /dev/null +++ b/ocp/data/history/memory/store_test.go @@ -0,0 +1,15 @@ +package memory + +import ( + "testing" + + "github.com/code-payments/ocp-server/ocp/data/history/tests" +) + +func TestHistoryMemoryStore(t *testing.T) { + testStore := New() + teardown := func() { + testStore.(*store).reset() + } + tests.RunTests(t, testStore, teardown) +} diff --git a/ocp/data/history/model.go b/ocp/data/history/model.go new file mode 100644 index 0000000..26e063d --- /dev/null +++ b/ocp/data/history/model.go @@ -0,0 +1,333 @@ +package history + +import ( + "errors" + "fmt" + "time" + + "github.com/code-payments/ocp-server/currency" + "github.com/code-payments/ocp-server/pointer" +) + +type Type uint8 + +const ( + UnknownType Type = iota + DirectlySent + DirectlyReceived + IndirectlySent + IndirectlyReceived + Withdrawn + Deposited + Swap +) + +// ReferenceType is the kind of thing a record's reference names. A reference +// is only unique within its own kind: intent IDs and swap IDs are both client +// supplied public keys drawn from the same space, and a transaction signature +// is a third space again. Pairing the two is what keeps one kind's reference +// from landing on another's. +type ReferenceType uint8 + +const ( + UnknownReferenceType ReferenceType = iota + IntentReference + SwapReference + SignatureReference +) + +type State uint8 + +const ( + StateUnknown State = iota + StatePending + StateCompleted + StateFailed + StateVoided + StateReturned +) + +// FeeType is persisted as its ordinal, inside the fees blob rather than in a +// column of its own, so this block is append-only. Inserting a value anywhere +// but the end re-labels every fee already stored, and nothing would report it. +type FeeType uint8 + +const ( + UnknownFeeType FeeType = iota + ReserveBuyFee + ReserveSellFee + WithdrawalAccountCreationFee + CurrencyLaunchFee +) + +// The tags are what a stored fee is keyed by, so they are the storage schema +// and the field names are not. Renaming a field without them would leave every +// stored fee decoding to a zero value rather than failing. +// +// They are abbreviated because a key is repeated in full on every fee of every +// record, and the field names carry the meaning that the keys give up. +type Fee struct { + Type FeeType `json:"t"` + NativeAmount float64 `json:"na"` +} + +type Record struct { + Id uint64 + + ReferenceId string + ReferenceType ReferenceType + + Type Type + + OwnerAccount string + CounterpartyOwnerAccount *string + + ExchangeCurrency currency.Code + NativeAmount float64 + + Fees []Fee + + MintAccount string + Quantity uint64 + + DestinationMintAccount *string + DestinationQuantity *uint64 + + GiftCardVault *string + AppMetadata []byte + + Version uint64 + + State State + + CreatedAt time.Time + UpdatedAt time.Time +} + +func (r *Record) Validate() error { + if len(r.ReferenceId) == 0 { + return errors.New("reference id is required") + } + + if r.ReferenceType == UnknownReferenceType { + return errors.New("reference type is required") + } + + if r.Type == UnknownType { + return errors.New("type is required") + } + + if len(r.OwnerAccount) == 0 { + return errors.New("owner account is required") + } + + if r.CounterpartyOwnerAccount != nil && len(*r.CounterpartyOwnerAccount) == 0 { + return errors.New("counterparty owner account must not be empty") + } + + if len(r.ExchangeCurrency) == 0 { + return errors.New("exchange currency is required") + } + + if r.NativeAmount == 0 { + return errors.New("native amount is required") + } + + for i, fee := range r.Fees { + if err := fee.Validate(); err != nil { + return fmt.Errorf("invalid fee at index %d: %w", i, err) + } + } + + if len(r.MintAccount) == 0 { + return errors.New("mint account is required") + } + + if r.Quantity == 0 { + return errors.New("quantity is required") + } + + switch r.Type { + case Swap: + if r.DestinationMintAccount == nil { + return errors.New("destination mint account is required") + } + case Withdrawn, Deposited: + default: + if r.DestinationMintAccount != nil || r.DestinationQuantity != nil { + return errors.New("destination leg must not be present") + } + } + + if r.DestinationMintAccount != nil { + if len(*r.DestinationMintAccount) == 0 { + return errors.New("destination mint account must not be empty") + } + + if *r.DestinationMintAccount == r.MintAccount { + return errors.New("source and destination mints must differ") + } + } + + if r.DestinationQuantity != nil { + if *r.DestinationQuantity == 0 { + return errors.New("destination quantity must not be zero") + } + + if r.DestinationMintAccount == nil { + return errors.New("destination quantity requires a destination mint account") + } + } + + switch r.Type { + case IndirectlySent, IndirectlyReceived: + if r.GiftCardVault == nil || len(*r.GiftCardVault) == 0 { + return errors.New("gift card vault is required") + } + default: + if r.GiftCardVault != nil { + return errors.New("gift card vault must not be present") + } + } + + if r.State == StateUnknown { + return errors.New("state is required") + } + + switch r.State { + case StateVoided, StateReturned: + if r.Type != IndirectlySent { + return fmt.Errorf("state %s is only valid for %s", r.State, IndirectlySent) + } + } + + if r.CreatedAt.IsZero() { + return errors.New("creation time is required") + } + + return nil +} + +func (r *Record) Clone() Record { + var cloned Record + r.CopyTo(&cloned) + return cloned +} + +func (r *Record) CopyTo(dst *Record) { + dst.Id = r.Id + + dst.ReferenceId = r.ReferenceId + dst.ReferenceType = r.ReferenceType + + dst.Type = r.Type + + dst.OwnerAccount = r.OwnerAccount + dst.CounterpartyOwnerAccount = pointer.StringCopy(r.CounterpartyOwnerAccount) + + dst.ExchangeCurrency = r.ExchangeCurrency + dst.NativeAmount = r.NativeAmount + + if r.Fees != nil { + dst.Fees = make([]Fee, len(r.Fees)) + copy(dst.Fees, r.Fees) + } else { + dst.Fees = nil + } + + dst.MintAccount = r.MintAccount + dst.Quantity = r.Quantity + + dst.DestinationMintAccount = pointer.StringCopy(r.DestinationMintAccount) + dst.DestinationQuantity = pointer.Uint64Copy(r.DestinationQuantity) + + dst.GiftCardVault = pointer.StringCopy(r.GiftCardVault) + + if r.AppMetadata != nil { + dst.AppMetadata = make([]byte, len(r.AppMetadata)) + copy(dst.AppMetadata, r.AppMetadata) + } else { + dst.AppMetadata = nil + } + + dst.Version = r.Version + + dst.State = r.State + + dst.CreatedAt = r.CreatedAt + dst.UpdatedAt = r.UpdatedAt +} + +func (f *Fee) Validate() error { + if f.Type == UnknownFeeType { + return errors.New("fee type is required") + } + + if f.NativeAmount == 0 { + return errors.New("fee native amount is required") + } + + return nil +} + +func (t Type) String() string { + switch t { + case DirectlySent: + return "directly_sent" + case DirectlyReceived: + return "directly_received" + case IndirectlySent: + return "indirectly_sent" + case IndirectlyReceived: + return "indirectly_received" + case Withdrawn: + return "withdrawn" + case Deposited: + return "deposited" + case Swap: + return "swap" + } + return "unknown" +} + +func (r ReferenceType) String() string { + switch r { + case IntentReference: + return "intent" + case SwapReference: + return "swap" + case SignatureReference: + return "signature" + } + return "unknown" +} + +func (s State) String() string { + switch s { + case StatePending: + return "pending" + case StateCompleted: + return "completed" + case StateFailed: + return "failed" + case StateVoided: + return "voided" + case StateReturned: + return "returned" + } + return "unknown" +} + +func (f FeeType) String() string { + switch f { + case ReserveBuyFee: + return "reserve_buy" + case ReserveSellFee: + return "reserve_sell" + case WithdrawalAccountCreationFee: + return "withdrawal_account_creation" + case CurrencyLaunchFee: + return "currency_launch" + } + return "unknown" +} diff --git a/ocp/data/history/postgres/model.go b/ocp/data/history/postgres/model.go new file mode 100644 index 0000000..d08cddf --- /dev/null +++ b/ocp/data/history/postgres/model.go @@ -0,0 +1,403 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + + "github.com/code-payments/ocp-server/currency" + pgutil "github.com/code-payments/ocp-server/database/postgres" + q "github.com/code-payments/ocp-server/database/query" + "github.com/code-payments/ocp-server/ocp/data/history" + "github.com/code-payments/ocp-server/pointer" +) + +const ( + tableName = "ocp__core_transactionhistory" + + allColumns = `id, reference_id, reference_type, type, owner_account, counterparty_owner_account, exchange_currency, native_amount, fees, mint_account, quantity, destination_mint_account, destination_quantity, gift_card_vault, app_metadata, version, state, created_at, updated_at` +) + +type model struct { + Id sql.NullInt64 `db:"id"` + ReferenceId string `db:"reference_id"` + ReferenceType uint8 `db:"reference_type"` + Type uint8 `db:"type"` + OwnerAccount string `db:"owner_account"` + CounterpartyOwnerAccount sql.NullString `db:"counterparty_owner_account"` + ExchangeCurrency string `db:"exchange_currency"` + NativeAmount float64 `db:"native_amount"` + Fees string `db:"fees"` + MintAccount string `db:"mint_account"` + Quantity uint64 `db:"quantity"` + DestinationMintAccount sql.NullString `db:"destination_mint_account"` + DestinationQuantity sql.NullInt64 `db:"destination_quantity"` + GiftCardVault sql.NullString `db:"gift_card_vault"` + AppMetadata []byte `db:"app_metadata"` + Version uint64 `db:"version"` + State uint8 `db:"state"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +func toModel(obj *history.Record) (*model, error) { + if err := obj.Validate(); err != nil { + return nil, err + } + + fees, err := marshalFees(obj.Fees) + if err != nil { + return nil, err + } + + return &model{ + Id: sql.NullInt64{Int64: int64(obj.Id), Valid: true}, + ReferenceId: obj.ReferenceId, + ReferenceType: uint8(obj.ReferenceType), + Type: uint8(obj.Type), + OwnerAccount: obj.OwnerAccount, + CounterpartyOwnerAccount: toNullString(obj.CounterpartyOwnerAccount), + ExchangeCurrency: string(obj.ExchangeCurrency), + NativeAmount: obj.NativeAmount, + Fees: fees, + MintAccount: obj.MintAccount, + Quantity: obj.Quantity, + DestinationMintAccount: toNullString(obj.DestinationMintAccount), + DestinationQuantity: toNullInt64(obj.DestinationQuantity), + GiftCardVault: toNullString(obj.GiftCardVault), + AppMetadata: obj.AppMetadata, + Version: obj.Version, + State: uint8(obj.State), + CreatedAt: obj.CreatedAt, + UpdatedAt: obj.UpdatedAt, + }, nil +} + +func fromModel(m *model) (*history.Record, error) { + fees, err := unmarshalFees(m.Fees) + if err != nil { + return nil, err + } + + return &history.Record{ + Id: uint64(m.Id.Int64), + ReferenceId: m.ReferenceId, + ReferenceType: history.ReferenceType(m.ReferenceType), + Type: history.Type(m.Type), + OwnerAccount: m.OwnerAccount, + CounterpartyOwnerAccount: fromNullString(m.CounterpartyOwnerAccount), + ExchangeCurrency: currency.Code(m.ExchangeCurrency), + NativeAmount: m.NativeAmount, + Fees: fees, + MintAccount: m.MintAccount, + Quantity: m.Quantity, + DestinationMintAccount: fromNullString(m.DestinationMintAccount), + DestinationQuantity: fromNullInt64(m.DestinationQuantity), + GiftCardVault: fromNullString(m.GiftCardVault), + AppMetadata: m.AppMetadata, + Version: m.Version, + State: history.State(m.State), + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + }, nil +} + +// dbSave inserts a new record or applies an update to an existing one. Which of +// the two is decided by whether the record carries an ID, so that a write with +// no ID can never silently land on top of a record already stored: an owner +// already holding a record for the reference is reported as history.ErrExists, +// which is what makes a retried write a no-op rather than a double entry. +func (m *model) dbSave(ctx context.Context, db *sqlx.DB) error { + if m.Id.Int64 == 0 { + return m.dbInsert(ctx, db) + } + return m.dbUpdate(ctx, db) +} + +func (m *model) dbInsert(ctx context.Context, db *sqlx.DB) error { + return pgutil.ExecuteInTx(ctx, db, sql.LevelDefault, func(tx *sqlx.Tx) error { + query := `INSERT INTO ` + tableName + ` + (reference_id, reference_type, type, owner_account, counterparty_owner_account, exchange_currency, native_amount, fees, mint_account, quantity, destination_mint_account, destination_quantity, gift_card_vault, app_metadata, version, state, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15 + 1, $16, $17, $17) + + ON CONFLICT (owner_account, reference_type, reference_id) DO NOTHING + + RETURNING ` + allColumns + + err := tx.QueryRowxContext( + ctx, + query, + m.ReferenceId, + m.ReferenceType, + m.Type, + m.OwnerAccount, + m.CounterpartyOwnerAccount, + m.ExchangeCurrency, + m.NativeAmount, + m.Fees, + m.MintAccount, + m.Quantity, + m.DestinationMintAccount, + m.DestinationQuantity, + m.GiftCardVault, + m.AppMetadata, + m.Version, + m.State, + m.CreatedAt, + ).StructScan(m) + if err != nil { + return pgutil.CheckNoRows(err, history.ErrExists) + } + return nil + }) +} + +// dbUpdate applies the mutable part of a record: the state it has reached, the +// destination leg a swap only learns on finalizing, and the fees only known by +// then. Everything else is settled when the record is written and is returned +// as stored, so a caller that edited an immutable field does not persist it. +// +// The write time is stamped here rather than taken from the caller, since the +// caller's copy holds the time of the write it read the record from. Taking it +// would leave updated_at frozen at the creation time for a record's whole life. +func (m *model) dbUpdate(ctx context.Context, db *sqlx.DB) error { + return pgutil.ExecuteInTx(ctx, db, sql.LevelDefault, func(tx *sqlx.Tx) error { + query := `UPDATE ` + tableName + ` + SET state = $3, destination_quantity = $4, fees = $5, version = version + 1, updated_at = NOW() + WHERE id = $1 AND version = $2 + RETURNING ` + allColumns + + err := tx.QueryRowxContext( + ctx, + query, + m.Id, + m.Version, + m.State, + m.DestinationQuantity, + m.Fees, + ).StructScan(m) + if err == nil { + return nil + } + if err != sql.ErrNoRows { + return err + } + + // The update matched nothing, which is either a record that never existed + // or one that has since moved on. Distinguishing them costs a query only + // on this path, and callers act differently on each. + var exists bool + if err := tx.GetContext(ctx, &exists, `SELECT EXISTS (SELECT 1 FROM `+tableName+` WHERE id = $1)`, m.Id); err != nil { + return err + } + if !exists { + return history.ErrNotFound + } + return history.ErrStaleVersion + }) +} + +// paginate applies the history's ordering to a query: by event time, then by +// ID to break ties. q.PaginateQuery cannot be used because it orders on a +// single column, and time alone is not a total order. +// +// The cursor predicate is a row comparison rather than a pair of conditions on +// each column, so it stays a single range scan over the (created_at, id) part +// of an index rather than a filter over everything sharing a timestamp. +func paginate(query string, opts []any, cursor q.Cursor, limit uint64, direction q.Ordering) (string, []any, error) { + if len(cursor) > 0 { + createdAt, id, ok := history.FromCursor(cursor) + if !ok { + return "", nil, history.ErrInvalidCursor + } + + comparison := "<" + if direction == q.Ascending { + comparison = ">" + } + + query += fmt.Sprintf(" AND (created_at, id) %s ($%d, $%d)", comparison, len(opts)+1, len(opts)+2) + opts = append(opts, createdAt, id) + } + + if direction == q.Ascending { + query += " ORDER BY created_at ASC, id ASC" + } else { + query += " ORDER BY created_at DESC, id DESC" + } + + if limit > 0 { + query += fmt.Sprintf(" LIMIT $%d", len(opts)+1) + opts = append(opts, limit) + } + + return query, opts, nil +} + +func dbGetAllByOwner(ctx context.Context, db *sqlx.DB, owner string, cursor q.Cursor, limit uint64, direction q.Ordering) ([]*model, error) { + res := []*model{} + + query := `SELECT ` + allColumns + ` + FROM ` + tableName + ` + WHERE owner_account = $1` + + query, opts, err := paginate(query, []any{owner}, cursor, limit, direction) + if err != nil { + return nil, err + } + + err = db.SelectContext(ctx, &res, query, opts...) + if err != nil { + return nil, pgutil.CheckNoRows(err, history.ErrNotFound) + } + if len(res) == 0 { + return nil, history.ErrNotFound + } + return res, nil +} + +// dbGetAllByOwnerMint matches a mint on either leg, so that a mint's history +// holds what was traded into it as well as out of it. +func dbGetAllByOwnerMint(ctx context.Context, db *sqlx.DB, owner, mint string, cursor q.Cursor, limit uint64, direction q.Ordering) ([]*model, error) { + res := []*model{} + + query := `SELECT ` + allColumns + ` + FROM ` + tableName + ` + WHERE owner_account = $1 AND (mint_account = $2 OR destination_mint_account = $2)` + + query, opts, err := paginate(query, []any{owner, mint}, cursor, limit, direction) + if err != nil { + return nil, err + } + + err = db.SelectContext(ctx, &res, query, opts...) + if err != nil { + return nil, pgutil.CheckNoRows(err, history.ErrNotFound) + } + if len(res) == 0 { + return nil, history.ErrNotFound + } + return res, nil +} + +func dbGetAllByIds(ctx context.Context, db *sqlx.DB, ids []uint64) ([]*model, error) { + if len(ids) == 0 { + return nil, history.ErrNotFound + } + + res := []*model{} + + query, opts, err := sqlx.In(`SELECT `+allColumns+` + FROM `+tableName+` + WHERE id IN (?) + ORDER BY id ASC`, ids) + if err != nil { + return nil, err + } + + err = db.SelectContext(ctx, &res, db.Rebind(query), opts...) + if err != nil { + return nil, pgutil.CheckNoRows(err, history.ErrNotFound) + } + if len(res) == 0 { + return nil, history.ErrNotFound + } + return res, nil +} + +func dbGetAllByReference(ctx context.Context, db *sqlx.DB, referenceType history.ReferenceType, referenceId string) ([]*model, error) { + res := []*model{} + + query := `SELECT ` + allColumns + ` + FROM ` + tableName + ` + WHERE reference_type = $1 AND reference_id = $2 + ORDER BY id ASC` + + err := db.SelectContext(ctx, &res, query, uint8(referenceType), referenceId) + if err != nil { + return nil, pgutil.CheckNoRows(err, history.ErrNotFound) + } + if len(res) == 0 { + return nil, history.ErrNotFound + } + return res, nil +} + +func dbGetAllByGiftCardVault(ctx context.Context, db *sqlx.DB, vault string) ([]*model, error) { + res := []*model{} + + query := `SELECT ` + allColumns + ` + FROM ` + tableName + ` + WHERE gift_card_vault = $1 + ORDER BY id ASC` + + err := db.SelectContext(ctx, &res, query, vault) + if err != nil { + return nil, pgutil.CheckNoRows(err, history.ErrNotFound) + } + if len(res) == 0 { + return nil, history.ErrNotFound + } + return res, nil +} + +func marshalFees(fees []history.Fee) (string, error) { + if len(fees) == 0 { + return "[]", nil + } + + data, err := json.Marshal(fees) + if err != nil { + return "", err + } + return string(data), nil +} + +// unmarshalFees reports a blob it cannot decode rather than returning no fees. +// The difference matters because a record is updated by reading it, changing +// the state it has reached, and writing the whole thing back, so fees that +// decoded to nothing would be written back as nothing and the stored blob would +// be gone rather than merely misread. +func unmarshalFees(data string) ([]history.Fee, error) { + if data == "[]" { + return nil, nil + } + + var fees []history.Fee + if err := json.Unmarshal([]byte(data), &fees); err != nil { + return nil, err + } + return fees, nil +} + +func toNullString(val *string) sql.NullString { + if val == nil { + return sql.NullString{} + } + return sql.NullString{String: *val, Valid: true} +} + +func fromNullString(val sql.NullString) *string { + if !val.Valid { + return nil + } + return pointer.String(val.String) +} + +func toNullInt64(val *uint64) sql.NullInt64 { + if val == nil { + return sql.NullInt64{} + } + return sql.NullInt64{Int64: int64(*val), Valid: true} +} + +func fromNullInt64(val sql.NullInt64) *uint64 { + if !val.Valid { + return nil + } + return pointer.Uint64(uint64(val.Int64)) +} diff --git a/ocp/data/history/postgres/model_test.go b/ocp/data/history/postgres/model_test.go new file mode 100644 index 0000000..11bf8ed --- /dev/null +++ b/ocp/data/history/postgres/model_test.go @@ -0,0 +1,51 @@ +package postgres + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/code-payments/ocp-server/ocp/data/history" +) + +// The encoded form is the storage schema, so it is asserted literally rather +// than round tripped. A round trip passes when a field is renamed or a fee type +// is reordered, because both sides of the trip move together, and every fee +// already stored is left decoding to a zero value. +func TestMarshalFees_StorageSchema(t *testing.T) { + fees := []history.Fee{ + {Type: history.ReserveSellFee, NativeAmount: 0.25}, + {Type: history.CurrencyLaunchFee, NativeAmount: 10}, + } + + actual, err := marshalFees(fees) + require.NoError(t, err) + assert.Equal(t, `[{"t":2,"na":0.25},{"t":4,"na":10}]`, actual) + + decoded, err := unmarshalFees(actual) + require.NoError(t, err) + assert.Equal(t, fees, decoded) +} + +func TestMarshalFees_Empty(t *testing.T) { + for _, fees := range [][]history.Fee{nil, {}} { + actual, err := marshalFees(fees) + require.NoError(t, err) + assert.Equal(t, "[]", actual) + + decoded, err := unmarshalFees(actual) + require.NoError(t, err) + assert.Nil(t, decoded) + } +} + +// A blob that cannot be decoded must be reported. Returning no fees would be +// written back as no fees by the next state transition, which reads a record, +// changes the state it has reached, and saves the whole thing. +func TestUnmarshalFees_Undecodable(t *testing.T) { + for _, invalid := range []string{"", "not json", `{"t":1}`, `[{"t":`} { + _, err := unmarshalFees(invalid) + assert.Error(t, err, "expected an error for %q", invalid) + } +} diff --git a/ocp/data/history/postgres/store.go b/ocp/data/history/postgres/store.go new file mode 100644 index 0000000..f4fa12d --- /dev/null +++ b/ocp/data/history/postgres/store.go @@ -0,0 +1,92 @@ +package postgres + +import ( + "context" + "database/sql" + + "github.com/jmoiron/sqlx" + + "github.com/code-payments/ocp-server/database/query" + "github.com/code-payments/ocp-server/ocp/data/history" +) + +type store struct { + db *sqlx.DB +} + +func New(db *sql.DB) history.Store { + return &store{ + db: sqlx.NewDb(db, "pgx"), + } +} + +func (s *store) Save(ctx context.Context, record *history.Record) error { + obj, err := toModel(record) + if err != nil { + return err + } + + if err := obj.dbSave(ctx, s.db); err != nil { + return err + } + + res, err := fromModel(obj) + if err != nil { + return err + } + res.CopyTo(record) + + return nil +} + +func (s *store) GetAllByOwner(ctx context.Context, owner string, cursor query.Cursor, limit uint64, direction query.Ordering) ([]*history.Record, error) { + models, err := dbGetAllByOwner(ctx, s.db, owner, cursor, limit, direction) + if err != nil { + return nil, err + } + return fromModels(models) +} + +func (s *store) GetAllByOwnerMint(ctx context.Context, owner, mint string, cursor query.Cursor, limit uint64, direction query.Ordering) ([]*history.Record, error) { + models, err := dbGetAllByOwnerMint(ctx, s.db, owner, mint, cursor, limit, direction) + if err != nil { + return nil, err + } + return fromModels(models) +} + +func (s *store) GetAllByIds(ctx context.Context, ids []uint64) ([]*history.Record, error) { + models, err := dbGetAllByIds(ctx, s.db, ids) + if err != nil { + return nil, err + } + return fromModels(models) +} + +func (s *store) GetAllByReference(ctx context.Context, referenceType history.ReferenceType, referenceId string) ([]*history.Record, error) { + models, err := dbGetAllByReference(ctx, s.db, referenceType, referenceId) + if err != nil { + return nil, err + } + return fromModels(models) +} + +func (s *store) GetAllByGiftCardVault(ctx context.Context, vault string) ([]*history.Record, error) { + models, err := dbGetAllByGiftCardVault(ctx, s.db, vault) + if err != nil { + return nil, err + } + return fromModels(models) +} + +func fromModels(models []*model) ([]*history.Record, error) { + records := make([]*history.Record, len(models)) + for i, m := range models { + record, err := fromModel(m) + if err != nil { + return nil, err + } + records[i] = record + } + return records, nil +} diff --git a/ocp/data/history/postgres/store_test.go b/ocp/data/history/postgres/store_test.go new file mode 100644 index 0000000..2eba816 --- /dev/null +++ b/ocp/data/history/postgres/store_test.go @@ -0,0 +1,138 @@ +package postgres + +import ( + "database/sql" + "os" + "testing" + + "github.com/ory/dockertest/v3" + "go.uber.org/zap" + + "github.com/code-payments/ocp-server/ocp/data/history" + "github.com/code-payments/ocp-server/ocp/data/history/tests" + + postgrestest "github.com/code-payments/ocp-server/database/postgres/test" + + _ "github.com/jackc/pgx/v4/stdlib" +) + +const ( + // Used for testing ONLY, the table and migrations are external to this repository + tableCreate = ` + CREATE TABLE ocp__core_transactionhistory( + id SERIAL NOT NULL PRIMARY KEY, + + reference_id TEXT NOT NULL, + reference_type INTEGER NOT NULL, + + type INTEGER NOT NULL, + + owner_account TEXT NOT NULL, + counterparty_owner_account TEXT NULL, + + exchange_currency TEXT NOT NULL, + native_amount DOUBLE PRECISION NOT NULL, + + fees TEXT NOT NULL DEFAULT '[]', + + mint_account TEXT NOT NULL, + quantity BIGINT NOT NULL CHECK (quantity > 0), + + destination_mint_account TEXT NULL, + destination_quantity BIGINT NULL CHECK (destination_quantity > 0), + + gift_card_vault TEXT NULL, + app_metadata BYTEA NULL, + + version INTEGER NOT NULL, + + state INTEGER NOT NULL, + + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + + CONSTRAINT ocp__core_transactionhistory__uniq__owner__and__reference UNIQUE (owner_account, reference_type, reference_id) + ); + + CREATE INDEX ocp__core_transactionhistory__idx__owner__and__time ON ocp__core_transactionhistory(owner_account, created_at, id); + CREATE INDEX ocp__core_transactionhistory__idx__owner__and__mint ON ocp__core_transactionhistory(owner_account, mint_account, created_at, id); + CREATE INDEX ocp__core_transactionhistory__idx__owner__and__destmint ON ocp__core_transactionhistory(owner_account, destination_mint_account, created_at, id) WHERE destination_mint_account IS NOT NULL; + CREATE INDEX ocp__core_transactionhistory__idx__reference ON ocp__core_transactionhistory(reference_type, reference_id); + CREATE INDEX ocp__core_transactionhistory__idx__giftcardvault ON ocp__core_transactionhistory(gift_card_vault) WHERE gift_card_vault IS NOT NULL; + ` + + // Used for testing ONLY, the table and migrations are external to this repository + tableDestroy = ` + DROP TABLE ocp__core_transactionhistory; + ` +) + +var ( + testStore history.Store + teardown func() +) + +func TestMain(m *testing.M) { + log := zap.Must(zap.NewDevelopment()) + + testPool, err := dockertest.NewPool("") + if err != nil { + log.With(zap.Error(err)).Error("Error creating docker pool") + os.Exit(1) + } + + var cleanUpFunc func() + db, cleanUpFunc, err := postgrestest.StartPostgresDB(testPool) + if err != nil { + log.With(zap.Error(err)).Error("Error starting postgres image") + os.Exit(1) + } + defer db.Close() + + if err := createTestTables(log, db); err != nil { + log.With(zap.Error(err)).Error("Error creating test tables") + cleanUpFunc() + os.Exit(1) + } + + testStore = New(db) + teardown = func() { + if pc := recover(); pc != nil { + cleanUpFunc() + panic(pc) + } + + if err := resetTestTables(log, db); err != nil { + log.With(zap.Error(err)).Error("Error resetting test tables") + cleanUpFunc() + os.Exit(1) + } + } + + code := m.Run() + cleanUpFunc() + os.Exit(code) +} + +func TestHistoryPostgresStore(t *testing.T) { + tests.RunTests(t, testStore, teardown) +} + +func createTestTables(log *zap.Logger, db *sql.DB) error { + _, err := db.Exec(tableCreate) + if err != nil { + log.With(zap.Error(err)).Error("could not create test tables") + return err + } + return nil +} + +func resetTestTables(log *zap.Logger, db *sql.DB) error { + _, err := db.Exec(tableDestroy) + if err != nil { + log.With(zap.Error(err)).Error("could not drop test tables") + return err + } + + return createTestTables(log, db) +} diff --git a/ocp/data/history/store.go b/ocp/data/history/store.go new file mode 100644 index 0000000..3a9ea39 --- /dev/null +++ b/ocp/data/history/store.go @@ -0,0 +1,106 @@ +package history + +import ( + "context" + "encoding/binary" + "errors" + "time" + + "github.com/code-payments/ocp-server/database/query" +) + +var ( + ErrNotFound = errors.New("no records could be found") + ErrExists = errors.New("history record already exists") + ErrStaleVersion = errors.New("history record version is stale") + ErrInvalidCursor = errors.New("cursor is invalid") +) + +// cursorSize is the byte length of an encoded cursor: the event time as +// big-endian unix nanoseconds, followed by the record ID. +const cursorSize = 16 + +// ToCursor encodes a record's position in a history. A history is ordered by +// event time, which is not unique, so a position is the time paired with the +// record ID that breaks ties. Ordering on the time alone would let a page skip +// or repeat the records sharing a boundary timestamp. +func ToCursor(createdAt time.Time, id uint64) query.Cursor { + b := make([]byte, cursorSize) + binary.BigEndian.PutUint64(b[0:8], uint64(createdAt.UnixNano())) + binary.BigEndian.PutUint64(b[8:16], id) + return b +} + +// FromCursor reverses ToCursor. It reports ok false for anything that is not a +// cursor this package produced. +func FromCursor(cursor query.Cursor) (createdAt time.Time, id uint64, ok bool) { + if len(cursor) != cursorSize { + return time.Time{}, 0, false + } + createdAt = time.Unix(0, int64(binary.BigEndian.Uint64(cursor[0:8]))).UTC() + id = binary.BigEndian.Uint64(cursor[8:16]) + return createdAt, id, true +} + +// Store stores a per-owner history of ledger events. A record is one owner's +// view of one event, so an event involving two owners is two records. +type Store interface { + // Save creates or updates a record. + // + // Returns ErrExists if the owner already has a record for the reference, and + // ErrStaleVersion if the stored record has moved on. + Save(ctx context.Context, record *Record) error + + // GetAllByOwner gets a page of an owner's history across all mints, ordered + // by event time and then by ID, from the position named by cursor. A limit + // of zero is unbounded. + // + // The order is the one a history is read in, so it is the event time rather + // than the order records happened to be written. The two differ whenever an + // event is recorded late — a backfill, or a deposit noticed after the fact — + // and ordering by the write would put those records somewhere their own + // timestamps do not explain. The cost is that such a record lands behind a + // cursor a caller has already passed and is seen on a later read from the + // start, rather than never. + // + // Returns ErrInvalidCursor for a cursor this package did not produce, and + // ErrNotFound if no records are found. + GetAllByOwner(ctx context.Context, owner string, cursor query.Cursor, limit uint64, direction query.Ordering) ([]*Record, error) + + // GetAllByOwnerMint gets a page of an owner's history for records involving a + // mint, as either the source or the destination, so that a mint's history + // holds what was traded into it as well as out of it. It is otherwise + // GetAllByOwner. + // + // Returns ErrNotFound if no records are found. + GetAllByOwnerMint(ctx context.Context, owner, mint string, cursor query.Cursor, limit uint64, direction query.Ordering) ([]*Record, error) + + // GetAllByIds gets a set of records by ID in one query, ordered by ID. An ID + // with no record is omitted rather than reported, so a partial result is + // normal and a caller should not read anything into the count. + // + // It is not scoped to an owner, so a caller serving a request on an owner's + // behalf has to check the records it gets back belong to that owner. + // + // Returns ErrNotFound if no records are found. + GetAllByIds(ctx context.Context, ids []uint64) ([]*Record, error) + + // GetAllByReference gets every owner's records for a reference. It is how an + // outcome that arrives naming the intent or swap it concerns, rather than any + // record, finds the records to transition. + // + // The reference is qualified by its type, since an ID is only unique within + // its own kind, so a caller gets back only the records the thing it named + // produced. + // + // Returns ErrNotFound if no records are found. + GetAllByReference(ctx context.Context, referenceType ReferenceType, referenceId string) ([]*Record, error) + + // GetAllByGiftCardVault gets the records for a gift card: the issuer's + // IndirectlySent record and, once claimed, the claimant's IndirectlyReceived + // record. A card being claimed, voided, or returned is reported by vault, so + // it cannot reach those records by reference. + // + // Returns ErrNotFound if no records are found. + GetAllByGiftCardVault(ctx context.Context, vault string) ([]*Record, error) +} diff --git a/ocp/data/history/tests/tests.go b/ocp/data/history/tests/tests.go new file mode 100644 index 0000000..bfee787 --- /dev/null +++ b/ocp/data/history/tests/tests.go @@ -0,0 +1,508 @@ +package tests + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/code-payments/ocp-server/currency" + "github.com/code-payments/ocp-server/database/query" + "github.com/code-payments/ocp-server/ocp/data/history" + "github.com/code-payments/ocp-server/pointer" +) + +func RunTests(t *testing.T, s history.Store, teardown func()) { + for _, tf := range []func(t *testing.T, s history.Store){ + testRoundTrip, + testSaveDuplicateReference, + testUpdateHappyPath, + testUpdateAppliesOnlyMutableFields, + testUpdateStaleRecord, + testGetAllByOwner, + testGetAllByOwnerOrdersByEventTime, + testGetAllByOwnerMint, + testGetAllByIds, + testGetAllByReference, + testGetAllByGiftCardVault, + } { + tf(t, s) + teardown() + } +} + +func testRoundTrip(t *testing.T, s history.Store) { + t.Run("testRoundTrip", func(t *testing.T) { + ctx := context.Background() + + actual, err := s.GetAllByReference(ctx, history.IntentReference, "test_reference") + require.Error(t, err) + assert.Equal(t, history.ErrNotFound, err) + assert.Nil(t, actual) + + expected := newRecord("test_owner", "test_reference") + require.NoError(t, s.Save(ctx, expected)) + + assert.True(t, expected.Id > 0) + assert.EqualValues(t, 1, expected.Version) + assert.Equal(t, expected.CreatedAt, expected.UpdatedAt) + + actual, err = s.GetAllByReference(ctx, history.IntentReference, "test_reference") + require.NoError(t, err) + require.Len(t, actual, 1) + assertEquivalentRecords(t, expected, actual[0]) + }) +} + +func testSaveDuplicateReference(t *testing.T, s history.Store) { + t.Run("testSaveDuplicateReference", func(t *testing.T) { + ctx := context.Background() + + require.NoError(t, s.Save(ctx, newRecord("test_owner", "test_reference"))) + + // The same owner cannot hold two records for one reference, which is what + // makes a retried write a no-op instead of a double entry. + err := s.Save(ctx, newRecord("test_owner", "test_reference")) + require.Error(t, err) + assert.Equal(t, history.ErrExists, err) + + // A different owner's view of the same event is a separate record. + require.NoError(t, s.Save(ctx, newRecord("test_other_owner", "test_reference"))) + + actual, err := s.GetAllByReference(ctx, history.IntentReference, "test_reference") + require.NoError(t, err) + assert.Len(t, actual, 2) + + // An ID is only unique within its own kind. Intent IDs and swap IDs are + // both client supplied public keys, so the same owner can hold a record + // for each without either write landing on the other. + sameIdOtherKind := newRecord("test_owner", "test_reference") + sameIdOtherKind.ReferenceType = history.SwapReference + require.NoError(t, s.Save(ctx, sameIdOtherKind)) + + actual, err = s.GetAllByReference(ctx, history.SwapReference, "test_reference") + require.NoError(t, err) + require.Len(t, actual, 1) + assert.Equal(t, sameIdOtherKind.Id, actual[0].Id) + assert.Equal(t, "test_owner", actual[0].OwnerAccount) + + // And a lookup of one kind never returns the other's. + actual, err = s.GetAllByReference(ctx, history.IntentReference, "test_reference") + require.NoError(t, err) + assert.Len(t, actual, 2) + for _, record := range actual { + assert.Equal(t, history.IntentReference, record.ReferenceType) + } + }) +} + +func testUpdateHappyPath(t *testing.T, s history.Store) { + t.Run("testUpdateHappyPath", func(t *testing.T) { + ctx := context.Background() + + record := newSwapRecord("test_owner", "test_reference") + record.State = history.StatePending + require.NoError(t, s.Save(ctx, record)) + require.Nil(t, record.DestinationQuantity) + writtenAt := record.UpdatedAt + + // A swap finalizing is what fills in the destination leg and the fee taken + // out of the trade, neither of which is known at submission. + record.State = history.StateCompleted + record.DestinationQuantity = pointer.Uint64(999) + record.Fees = []history.Fee{{ + Type: history.ReserveSellFee, + NativeAmount: 0.25, + }} + require.NoError(t, s.Save(ctx, record)) + assert.EqualValues(t, 2, record.Version) + + actual, err := s.GetAllByReference(ctx, history.IntentReference, "test_reference") + require.NoError(t, err) + require.Len(t, actual, 1) + + assert.Equal(t, history.StateCompleted, actual[0].State) + require.NotNil(t, actual[0].DestinationQuantity) + assert.EqualValues(t, 999, *actual[0].DestinationQuantity) + require.Len(t, actual[0].Fees, 1) + assert.Equal(t, history.ReserveSellFee, actual[0].Fees[0].Type) + assert.EqualValues(t, 0.25, actual[0].Fees[0].NativeAmount) + assert.EqualValues(t, 2, actual[0].Version) + + // An update is a new write, so it moves updated_at off the time of the + // write before it, and leaves the event time alone. + assert.True(t, actual[0].UpdatedAt.After(writtenAt), "an update must advance updated_at") + assert.True(t, record.UpdatedAt.After(writtenAt), "an update must advance the caller's updated_at") + assert.True(t, actual[0].CreatedAt.Equal(record.CreatedAt), "an update must not move created_at") + }) +} + +func testUpdateAppliesOnlyMutableFields(t *testing.T, s history.Store) { + t.Run("testUpdateAppliesOnlyMutableFields", func(t *testing.T) { + ctx := context.Background() + + record := newRecord("test_owner", "test_reference") + require.NoError(t, s.Save(ctx, record)) + + // A caller editing what an update doesn't carry neither persists the + // edit nor keeps it: the record comes back as stored. + record.State = history.StateFailed + record.MintAccount = "edited_mint" + record.Quantity = 999 + + require.NoError(t, s.Save(ctx, record)) + + assert.Equal(t, history.StateFailed, record.State) + assert.Equal(t, "test_mint", record.MintAccount) + assert.EqualValues(t, 12345, record.Quantity) + + actual, err := s.GetAllByReference(ctx, history.IntentReference, "test_reference") + require.NoError(t, err) + require.Len(t, actual, 1) + assert.Equal(t, history.StateFailed, actual[0].State) + assert.Equal(t, "test_mint", actual[0].MintAccount) + assert.EqualValues(t, 12345, actual[0].Quantity) + }) +} + +func testUpdateStaleRecord(t *testing.T, s history.Store) { + t.Run("testUpdateStaleRecord", func(t *testing.T) { + ctx := context.Background() + + record := newRecord("test_owner", "test_reference") + require.NoError(t, s.Save(ctx, record)) + + stale := record.Clone() + require.NoError(t, s.Save(ctx, record)) + + stale.State = history.StateCompleted + err := s.Save(ctx, &stale) + require.Error(t, err) + assert.Equal(t, history.ErrStaleVersion, err) + }) +} + +func testGetAllByOwner(t *testing.T, s history.Store) { + t.Run("testGetAllByOwner", func(t *testing.T) { + ctx := context.Background() + + _, err := s.GetAllByOwner(ctx, "test_owner", query.EmptyCursor, 10, query.Ascending) + assert.Equal(t, history.ErrNotFound, err) + + var saved []*history.Record + for i := 0; i < 5; i++ { + record := newRecord("test_owner", fmt.Sprintf("test_reference_%d", i)) + require.NoError(t, s.Save(ctx, record)) + saved = append(saved, record) + } + require.NoError(t, s.Save(ctx, newRecord("test_other_owner", "test_other_reference"))) + + // An owner sees only their own records, oldest first. + actual, err := s.GetAllByOwner(ctx, "test_owner", query.EmptyCursor, 10, query.Ascending) + require.NoError(t, err) + require.Len(t, actual, 5) + for i, record := range actual { + assert.Equal(t, saved[i].Id, record.Id) + assert.Equal(t, "test_owner", record.OwnerAccount) + } + + // Descending is the feed order, newest first. + actual, err = s.GetAllByOwner(ctx, "test_owner", query.EmptyCursor, 10, query.Descending) + require.NoError(t, err) + require.Len(t, actual, 5) + for i, record := range actual { + assert.Equal(t, saved[len(saved)-1-i].Id, record.Id) + } + + // A limit bounds the page. + actual, err = s.GetAllByOwner(ctx, "test_owner", query.EmptyCursor, 2, query.Ascending) + require.NoError(t, err) + require.Len(t, actual, 2) + assert.Equal(t, saved[0].Id, actual[0].Id) + assert.Equal(t, saved[1].Id, actual[1].Id) + + // A cursor resumes strictly after the record it names. + cursor := history.ToCursor(saved[1].CreatedAt, saved[1].Id) + + actual, err = s.GetAllByOwner(ctx, "test_owner", cursor, 10, query.Ascending) + require.NoError(t, err) + require.Len(t, actual, 3) + assert.Equal(t, saved[2].Id, actual[0].Id) + + actual, err = s.GetAllByOwner(ctx, "test_owner", cursor, 10, query.Descending) + require.NoError(t, err) + require.Len(t, actual, 1) + assert.Equal(t, saved[0].Id, actual[0].Id) + + // Paging past the end is empty rather than an error-free short page. + _, err = s.GetAllByOwner(ctx, "test_owner", history.ToCursor(saved[4].CreatedAt, saved[4].Id), 10, query.Ascending) + assert.Equal(t, history.ErrNotFound, err) + + // A cursor this package did not produce is rejected rather than treated + // as a position. + _, err = s.GetAllByOwner(ctx, "test_owner", query.ToCursor(saved[1].Id), 10, query.Ascending) + assert.Equal(t, history.ErrInvalidCursor, err) + }) +} + +func testGetAllByOwnerOrdersByEventTime(t *testing.T, s history.Store) { + t.Run("testGetAllByOwnerOrdersByEventTime", func(t *testing.T) { + ctx := context.Background() + + recent := newRecord("test_owner", "test_reference_recent") + recent.CreatedAt = baseTime.Add(time.Hour) + require.NoError(t, s.Save(ctx, recent)) + + // Written second but happened first, as a backfill or a deposit noticed + // after the fact would be. A history is read in the order events + // happened, so this belongs before the record already stored, even + // though it was written after it and carries a higher ID. + backdated := newRecord("test_owner", "test_reference_backdated") + backdated.CreatedAt = baseTime + require.NoError(t, s.Save(ctx, backdated)) + require.True(t, backdated.Id > recent.Id) + + actual, err := s.GetAllByOwner(ctx, "test_owner", query.EmptyCursor, 10, query.Ascending) + require.NoError(t, err) + require.Len(t, actual, 2) + assert.Equal(t, backdated.Id, actual[0].Id) + assert.Equal(t, recent.Id, actual[1].Id) + + actual, err = s.GetAllByOwner(ctx, "test_owner", query.EmptyCursor, 10, query.Descending) + require.NoError(t, err) + require.Len(t, actual, 2) + assert.Equal(t, recent.Id, actual[0].Id) + assert.Equal(t, backdated.Id, actual[1].Id) + + // The tiebreaker is the ID, so records sharing an event time still have a + // total order and a cursor cannot skip or repeat one. + tied := newRecord("test_owner", "test_reference_tied") + tied.CreatedAt = recent.CreatedAt + require.NoError(t, s.Save(ctx, tied)) + + actual, err = s.GetAllByOwner(ctx, "test_owner", history.ToCursor(recent.CreatedAt, recent.Id), 10, query.Ascending) + require.NoError(t, err) + require.Len(t, actual, 1) + assert.Equal(t, tied.Id, actual[0].Id) + }) +} + +func testGetAllByOwnerMint(t *testing.T, s history.Store) { + t.Run("testGetAllByOwnerMint", func(t *testing.T) { + ctx := context.Background() + + sent := newRecord("test_owner", "test_reference_sent") + sent.MintAccount = "mint_a" + require.NoError(t, s.Save(ctx, sent)) + + // A trade out of mint_a and a trade into it both belong to mint_a's + // history, so a mint matches on either leg. + sell := newSwapRecord("test_owner", "test_reference_sell") + sell.MintAccount = "mint_a" + sell.DestinationMintAccount = pointer.String("mint_b") + require.NoError(t, s.Save(ctx, sell)) + + buy := newSwapRecord("test_owner", "test_reference_buy") + buy.MintAccount = "mint_b" + buy.DestinationMintAccount = pointer.String("mint_a") + require.NoError(t, s.Save(ctx, buy)) + + unrelated := newRecord("test_owner", "test_reference_unrelated") + unrelated.MintAccount = "mint_c" + require.NoError(t, s.Save(ctx, unrelated)) + + actual, err := s.GetAllByOwnerMint(ctx, "test_owner", "mint_a", query.EmptyCursor, 10, query.Ascending) + require.NoError(t, err) + require.Len(t, actual, 3) + assert.Equal(t, sent.Id, actual[0].Id) + assert.Equal(t, sell.Id, actual[1].Id) + assert.Equal(t, buy.Id, actual[2].Id) + + actual, err = s.GetAllByOwnerMint(ctx, "test_owner", "mint_c", query.EmptyCursor, 10, query.Ascending) + require.NoError(t, err) + require.Len(t, actual, 1) + assert.Equal(t, unrelated.Id, actual[0].Id) + + _, err = s.GetAllByOwnerMint(ctx, "test_owner", "mint_unknown", query.EmptyCursor, 10, query.Ascending) + assert.Equal(t, history.ErrNotFound, err) + + _, err = s.GetAllByOwnerMint(ctx, "test_other_owner", "mint_a", query.EmptyCursor, 10, query.Ascending) + assert.Equal(t, history.ErrNotFound, err) + }) +} + +func testGetAllByIds(t *testing.T, s history.Store) { + t.Run("testGetAllByIds", func(t *testing.T) { + ctx := context.Background() + + first := newRecord("test_owner", "test_reference_1") + require.NoError(t, s.Save(ctx, first)) + second := newRecord("test_owner", "test_reference_2") + require.NoError(t, s.Save(ctx, second)) + other := newRecord("test_other_owner", "test_reference_3") + require.NoError(t, s.Save(ctx, other)) + + // Results come back ordered by ID whatever order they were asked for, so + // a caller cannot rely on them lining up with the IDs it passed. + actual, err := s.GetAllByIds(ctx, []uint64{second.Id, first.Id}) + require.NoError(t, err) + require.Len(t, actual, 2) + assert.Equal(t, first.Id, actual[0].Id) + assert.Equal(t, second.Id, actual[1].Id) + + // An ID with no record is omitted rather than reported, so a short result + // is normal. + actual, err = s.GetAllByIds(ctx, []uint64{first.Id, 999999}) + require.NoError(t, err) + require.Len(t, actual, 1) + assert.Equal(t, first.Id, actual[0].Id) + + // The lookup is not scoped to an owner: a record belonging to someone + // else comes back, and it is the caller's job to reject it. + actual, err = s.GetAllByIds(ctx, []uint64{other.Id}) + require.NoError(t, err) + require.Len(t, actual, 1) + assert.Equal(t, "test_other_owner", actual[0].OwnerAccount) + + _, err = s.GetAllByIds(ctx, []uint64{999999}) + assert.Equal(t, history.ErrNotFound, err) + + _, err = s.GetAllByIds(ctx, nil) + assert.Equal(t, history.ErrNotFound, err) + }) +} + +func testGetAllByReference(t *testing.T, s history.Store) { + t.Run("testGetAllByReference", func(t *testing.T) { + ctx := context.Background() + + // Both sides of one payment, which is how an outcome naming only the + // intent or swap finds every record it has to transition. + sender := newRecord("test_sender", "test_reference") + sender.Type = history.DirectlySent + sender.CounterpartyOwnerAccount = pointer.String("test_receiver") + require.NoError(t, s.Save(ctx, sender)) + + receiver := newRecord("test_receiver", "test_reference") + receiver.Type = history.DirectlyReceived + receiver.CounterpartyOwnerAccount = pointer.String("test_sender") + require.NoError(t, s.Save(ctx, receiver)) + + require.NoError(t, s.Save(ctx, newRecord("test_sender", "test_other_reference"))) + + actual, err := s.GetAllByReference(ctx, history.IntentReference, "test_reference") + require.NoError(t, err) + require.Len(t, actual, 2) + + owners := map[string]history.Type{} + for _, record := range actual { + owners[record.OwnerAccount] = record.Type + } + assert.Equal(t, history.DirectlySent, owners["test_sender"]) + assert.Equal(t, history.DirectlyReceived, owners["test_receiver"]) + + _, err = s.GetAllByReference(ctx, history.IntentReference, "test_reference_unknown") + assert.Equal(t, history.ErrNotFound, err) + }) +} + +func testGetAllByGiftCardVault(t *testing.T, s history.Store) { + t.Run("testGetAllByGiftCardVault", func(t *testing.T) { + ctx := context.Background() + + _, err := s.GetAllByGiftCardVault(ctx, "test_vault") + assert.Equal(t, history.ErrNotFound, err) + + // The issuer's record and the claimant's record share a vault but not a + // reference, since claiming is its own event. + issued := newRecord("test_issuer", "test_reference_issued") + issued.Type = history.IndirectlySent + issued.State = history.StatePending + issued.GiftCardVault = pointer.String("test_vault") + require.NoError(t, s.Save(ctx, issued)) + + claimed := newRecord("test_claimant", "test_reference_claimed") + claimed.Type = history.IndirectlyReceived + claimed.GiftCardVault = pointer.String("test_vault") + require.NoError(t, s.Save(ctx, claimed)) + + require.NoError(t, s.Save(ctx, newRecord("test_issuer", "test_reference_unrelated"))) + + actual, err := s.GetAllByGiftCardVault(ctx, "test_vault") + require.NoError(t, err) + require.Len(t, actual, 2) + + types := map[string]history.Type{} + for _, record := range actual { + types[record.OwnerAccount] = record.Type + } + assert.Equal(t, history.IndirectlySent, types["test_issuer"]) + assert.Equal(t, history.IndirectlyReceived, types["test_claimant"]) + + _, err = s.GetAllByGiftCardVault(ctx, "test_vault_unknown") + assert.Equal(t, history.ErrNotFound, err) + }) +} + +// baseTime anchors record timestamps so a suite can rely on the order it +// creates records in. Timestamps are staggered rather than taken from the clock +// because a history is ordered by event time, and records created in the same +// microsecond would leave that order down to the ID tiebreaker instead. +var baseTime = time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC) + +var recordCount int + +func newRecord(owner, referenceId string) *history.Record { + recordCount++ + + return &history.Record{ + ReferenceId: referenceId, + ReferenceType: history.IntentReference, + + Type: history.DirectlySent, + + OwnerAccount: owner, + + ExchangeCurrency: currency.USD, + NativeAmount: 1.23, + + MintAccount: "test_mint", + Quantity: 12345, + + State: history.StateCompleted, + + CreatedAt: baseTime.Add(time.Duration(recordCount) * time.Minute), + } +} + +func newSwapRecord(owner, referenceId string) *history.Record { + record := newRecord(owner, referenceId) + record.Type = history.Swap + record.DestinationMintAccount = pointer.String("test_destination_mint") + return record +} + +func assertEquivalentRecords(t *testing.T, obj1, obj2 *history.Record) { + assert.Equal(t, obj1.Id, obj2.Id) + assert.Equal(t, obj1.ReferenceId, obj2.ReferenceId) + assert.Equal(t, obj1.ReferenceType, obj2.ReferenceType) + assert.Equal(t, obj1.Type, obj2.Type) + assert.Equal(t, obj1.OwnerAccount, obj2.OwnerAccount) + assert.Equal(t, obj1.CounterpartyOwnerAccount, obj2.CounterpartyOwnerAccount) + assert.Equal(t, obj1.ExchangeCurrency, obj2.ExchangeCurrency) + assert.Equal(t, obj1.NativeAmount, obj2.NativeAmount) + assert.Equal(t, obj1.Fees, obj2.Fees) + assert.Equal(t, obj1.MintAccount, obj2.MintAccount) + assert.Equal(t, obj1.Quantity, obj2.Quantity) + assert.Equal(t, obj1.DestinationMintAccount, obj2.DestinationMintAccount) + assert.Equal(t, obj1.DestinationQuantity, obj2.DestinationQuantity) + assert.Equal(t, obj1.GiftCardVault, obj2.GiftCardVault) + assert.Equal(t, obj1.AppMetadata, obj2.AppMetadata) + assert.Equal(t, obj1.Version, obj2.Version) + assert.Equal(t, obj1.State, obj2.State) + assert.Equal(t, obj1.CreatedAt.Unix(), obj2.CreatedAt.Unix()) + assert.Equal(t, obj1.UpdatedAt.Unix(), obj2.UpdatedAt.Unix()) +} diff --git a/ocp/data/internal.go b/ocp/data/internal.go index 3a21613..1304fa4 100644 --- a/ocp/data/internal.go +++ b/ocp/data/internal.go @@ -25,6 +25,7 @@ import ( currency_metadata "github.com/code-payments/ocp-server/ocp/data/currency/metadata" "github.com/code-payments/ocp-server/ocp/data/deposit" "github.com/code-payments/ocp-server/ocp/data/fulfillment" + "github.com/code-payments/ocp-server/ocp/data/history" "github.com/code-payments/ocp-server/ocp/data/intent" "github.com/code-payments/ocp-server/ocp/data/nonce" "github.com/code-payments/ocp-server/ocp/data/rendezvous" @@ -43,6 +44,7 @@ import ( currency_metadata_memory_client "github.com/code-payments/ocp-server/ocp/data/currency/metadata/memory" deposit_memory_client "github.com/code-payments/ocp-server/ocp/data/deposit/memory" fulfillment_memory_client "github.com/code-payments/ocp-server/ocp/data/fulfillment/memory" + history_memory_client "github.com/code-payments/ocp-server/ocp/data/history/memory" intent_memory_client "github.com/code-payments/ocp-server/ocp/data/intent/memory" nonce_memory_client "github.com/code-payments/ocp-server/ocp/data/nonce/memory" rendezvous_memory_client "github.com/code-payments/ocp-server/ocp/data/rendezvous/memory" @@ -61,6 +63,7 @@ import ( currency_metadata_postgres_client "github.com/code-payments/ocp-server/ocp/data/currency/metadata/postgres" deposit_postgres_client "github.com/code-payments/ocp-server/ocp/data/deposit/postgres" fulfillment_postgres_client "github.com/code-payments/ocp-server/ocp/data/fulfillment/postgres" + history_postgres_client "github.com/code-payments/ocp-server/ocp/data/history/postgres" intent_postgres_client "github.com/code-payments/ocp-server/ocp/data/intent/postgres" nonce_postgres_client "github.com/code-payments/ocp-server/ocp/data/nonce/postgres" rendezvous_postgres_client "github.com/code-payments/ocp-server/ocp/data/rendezvous/postgres" @@ -220,6 +223,15 @@ type DatabaseData interface { GetAllTimelocksByState(ctx context.Context, state timelock_token.TimelockState, opts ...query.Option) ([]*timelock.Record, error) GetTimelockCountByState(ctx context.Context, state timelock_token.TimelockState) (uint64, error) + // Transaction History + // -------------------------------------------------------------------------------- + SaveTransactionHistory(ctx context.Context, record *history.Record) error + GetAllTransactionHistoryByOwner(ctx context.Context, owner string, opts ...query.Option) ([]*history.Record, error) + GetAllTransactionHistoryByOwnerMint(ctx context.Context, owner, mint string, opts ...query.Option) ([]*history.Record, error) + GetAllTransactionHistoryByIds(ctx context.Context, ids []uint64) ([]*history.Record, error) + GetAllTransactionHistoryByReference(ctx context.Context, referenceType history.ReferenceType, referenceId string) ([]*history.Record, error) + GetAllTransactionHistoryByGiftCardVault(ctx context.Context, vault string) ([]*history.Record, error) + // Transactions // -------------------------------------------------------------------------------- GetTransaction(ctx context.Context, sig string) (*transaction.Record, error) @@ -263,23 +275,24 @@ type DatabaseData interface { } type DatabaseProvider struct { - accounts account.Store - actions action.Store - balance balance.Store - currencies currency_metadata.Store - deposits deposit.Store - fulfillments fulfillment.Store - intents intent.Store - nonces nonce.Store - rendezvous rendezvous.Store - swaps swap.Store - tasks task.Store - timelocks timelock.Store - transactions transaction.Store - vault vault.Store - vmMetadata vm_metadata.Store - vmRam vm_ram.Store - vmStorage vm_storage.Store + accounts account.Store + actions action.Store + balance balance.Store + currencies currency_metadata.Store + deposits deposit.Store + fulfillments fulfillment.Store + intents intent.Store + nonces nonce.Store + rendezvous rendezvous.Store + swaps swap.Store + tasks task.Store + timelocks timelock.Store + transactionHistory history.Store + transactions transaction.Store + vault vault.Store + vmMetadata vm_metadata.Store + vmRam vm_ram.Store + vmStorage vm_storage.Store timelockCache cache.Cache @@ -308,23 +321,24 @@ func NewDatabaseProvider(dbConfig *pg.Config) (DatabaseData, error) { db.SetConnMaxLifetime(time.Hour) return &DatabaseProvider{ - accounts: account_postgres_client.New(db), - actions: action_postgres_client.New(db), - balance: balance_postgres_client.New(db), - currencies: currency_metadata_postgres_client.New(db), - deposits: deposit_postgres_client.New(db), - fulfillments: fulfillment_postgres_client.New(db), - intents: intent_postgres_client.New(db), - nonces: nonce_postgres_client.New(db), - rendezvous: rendezvous_postgres_client.New(db), - swaps: swap_postgres_client.New(db), - tasks: task_postgres_client.New(db), - timelocks: timelock_postgres_client.New(db), - transactions: transaction_postgres_client.New(db), - vault: vault_postgres_client.New(db), - vmMetadata: vm_metadata_postgres_client.New(db), - vmRam: vm_ram_postgres_client.New(db), - vmStorage: vm_storage_postgres_client.New(db), + accounts: account_postgres_client.New(db), + actions: action_postgres_client.New(db), + balance: balance_postgres_client.New(db), + currencies: currency_metadata_postgres_client.New(db), + deposits: deposit_postgres_client.New(db), + fulfillments: fulfillment_postgres_client.New(db), + intents: intent_postgres_client.New(db), + nonces: nonce_postgres_client.New(db), + rendezvous: rendezvous_postgres_client.New(db), + swaps: swap_postgres_client.New(db), + tasks: task_postgres_client.New(db), + timelocks: timelock_postgres_client.New(db), + transactionHistory: history_postgres_client.New(db), + transactions: transaction_postgres_client.New(db), + vault: vault_postgres_client.New(db), + vmMetadata: vm_metadata_postgres_client.New(db), + vmRam: vm_ram_postgres_client.New(db), + vmStorage: vm_storage_postgres_client.New(db), timelockCache: cache.NewCache(maxTimelockCacheBudget), @@ -334,23 +348,24 @@ func NewDatabaseProvider(dbConfig *pg.Config) (DatabaseData, error) { func NewTestDatabaseProvider() DatabaseData { return &DatabaseProvider{ - accounts: account_memory_client.New(), - actions: action_memory_client.New(), - balance: balance_memory_client.New(), - currencies: currency_metadata_memory_client.New(), - deposits: deposit_memory_client.New(), - fulfillments: fulfillment_memory_client.New(), - intents: intent_memory_client.New(), - nonces: nonce_memory_client.New(), - rendezvous: rendezvous_memory_client.New(), - swaps: swap_memory_client.New(), - tasks: task_memory_client.New(), - timelocks: timelock_memory_client.New(), - transactions: transaction_memory_client.New(), - vault: vault_memory_client.New(), - vmMetadata: vm_metadata_memory_client.New(), - vmRam: vm_ram_memory_client.New(), - vmStorage: vm_storage_memory_client.New(), + accounts: account_memory_client.New(), + actions: action_memory_client.New(), + balance: balance_memory_client.New(), + currencies: currency_metadata_memory_client.New(), + deposits: deposit_memory_client.New(), + fulfillments: fulfillment_memory_client.New(), + intents: intent_memory_client.New(), + nonces: nonce_memory_client.New(), + rendezvous: rendezvous_memory_client.New(), + swaps: swap_memory_client.New(), + tasks: task_memory_client.New(), + timelocks: timelock_memory_client.New(), + transactionHistory: history_memory_client.New(), + transactions: transaction_memory_client.New(), + vault: vault_memory_client.New(), + vmMetadata: vm_metadata_memory_client.New(), + vmRam: vm_ram_memory_client.New(), + vmStorage: vm_storage_memory_client.New(), timelockCache: nil, // Shouldn't be used for tests } @@ -603,7 +618,6 @@ func (dp *DatabaseProvider) GetUsdCostBasisBatch(ctx context.Context, mint strin return dp.intents.GetUsdCostBasisBatch(ctx, mint, owners...) } - // Nonces // -------------------------------------------------------------------------------- func (dp *DatabaseProvider) GetNonce(ctx context.Context, address string) (*nonce.Record, error) { @@ -813,6 +827,37 @@ func (dp *DatabaseProvider) GetTimelockCountByState(ctx context.Context, state t return dp.timelocks.GetCountByState(ctx, state) } +// Transaction History +// -------------------------------------------------------------------------------- +func (dp *DatabaseProvider) SaveTransactionHistory(ctx context.Context, record *history.Record) error { + return dp.transactionHistory.Save(ctx, record) +} +func (dp *DatabaseProvider) GetAllTransactionHistoryByOwner(ctx context.Context, owner string, opts ...query.Option) ([]*history.Record, error) { + req, err := query.DefaultPaginationHandler(opts...) + if err != nil { + return nil, err + } + + return dp.transactionHistory.GetAllByOwner(ctx, owner, req.Cursor, req.Limit, req.SortBy) +} +func (dp *DatabaseProvider) GetAllTransactionHistoryByOwnerMint(ctx context.Context, owner, mint string, opts ...query.Option) ([]*history.Record, error) { + req, err := query.DefaultPaginationHandler(opts...) + if err != nil { + return nil, err + } + + return dp.transactionHistory.GetAllByOwnerMint(ctx, owner, mint, req.Cursor, req.Limit, req.SortBy) +} +func (dp *DatabaseProvider) GetAllTransactionHistoryByIds(ctx context.Context, ids []uint64) ([]*history.Record, error) { + return dp.transactionHistory.GetAllByIds(ctx, ids) +} +func (dp *DatabaseProvider) GetAllTransactionHistoryByReference(ctx context.Context, referenceType history.ReferenceType, referenceId string) ([]*history.Record, error) { + return dp.transactionHistory.GetAllByReference(ctx, referenceType, referenceId) +} +func (dp *DatabaseProvider) GetAllTransactionHistoryByGiftCardVault(ctx context.Context, vault string) ([]*history.Record, error) { + return dp.transactionHistory.GetAllByGiftCardVault(ctx, vault) +} + // Transactions // -------------------------------------------------------------------------------- func (dp *DatabaseProvider) GetTransaction(ctx context.Context, sig string) (*transaction.Record, error) { diff --git a/ocp/history/gift_card.go b/ocp/history/gift_card.go new file mode 100644 index 0000000..b79306f --- /dev/null +++ b/ocp/history/gift_card.go @@ -0,0 +1,56 @@ +package history + +import ( + "context" + + "github.com/pkg/errors" + + ocp_data "github.com/code-payments/ocp-server/ocp/data" + "github.com/code-payments/ocp-server/ocp/data/history" +) + +// MarkGiftCardIssuanceAsClaimed completes the issuer's IndirectlySent record +// after the gift card is claimed. +func MarkGiftCardIssuanceAsClaimed(ctx context.Context, data ocp_data.DatabaseData, giftCardVault string) error { + return markGiftCardIssuance(ctx, data, giftCardVault, history.StateCompleted) +} + +// MarkGiftCardIssuanceAsVoided transitions the issuer's IndirectlySent record +// after the issuer voids the gift card. +func MarkGiftCardIssuanceAsVoided(ctx context.Context, data ocp_data.DatabaseData, giftCardVault string) error { + return markGiftCardIssuance(ctx, data, giftCardVault, history.StateVoided) +} + +// MarkGiftCardIssuanceAsReturned transitions the issuer's IndirectlySent record +// after the gift card expires unclaimed and is auto-returned. +func MarkGiftCardIssuanceAsReturned(ctx context.Context, data ocp_data.DatabaseData, giftCardVault string) error { + return markGiftCardIssuance(ctx, data, giftCardVault, history.StateReturned) +} + +func markGiftCardIssuance(ctx context.Context, data ocp_data.DatabaseData, giftCardVault string, newState history.State) error { + records, err := data.GetAllTransactionHistoryByGiftCardVault(ctx, giftCardVault) + if errors.Is(err, history.ErrNotFound) { + // The gift card predates history integration + return nil + } + if err != nil { + return err + } + + for _, record := range records { + if record.Type != history.IndirectlySent { + continue + } + + // An issuance transitions exactly once, from pending, so anything else + // is a flow violation + if record.State != history.StatePending { + return errors.Errorf("gift card issuance record is %s, expected %s", record.State, history.StatePending) + } + + record.State = newState + return data.SaveTransactionHistory(ctx, record) + } + + return nil +} diff --git a/ocp/history/gift_card_test.go b/ocp/history/gift_card_test.go new file mode 100644 index 0000000..b52b9d2 --- /dev/null +++ b/ocp/history/gift_card_test.go @@ -0,0 +1,90 @@ +package history + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + ocp_data "github.com/code-payments/ocp-server/ocp/data" + "github.com/code-payments/ocp-server/ocp/data/history" +) + +func TestMarkGiftCardIssuance_StateTransitions(t *testing.T) { + for _, tc := range []struct { + mark func(context.Context, ocp_data.DatabaseData, string) error + expected history.State + }{ + {MarkGiftCardIssuanceAsClaimed, history.StateCompleted}, + {MarkGiftCardIssuanceAsVoided, history.StateVoided}, + {MarkGiftCardIssuanceAsReturned, history.StateReturned}, + } { + ctx := context.Background() + data := ocp_data.NewTestDataProvider() + + issuedIntentRecord := newGiftCardIssuedIntentRecord() + saveRecordsForIntent(t, data, issuedIntentRecord) + + giftCardVault := issuedIntentRecord.SendPublicPaymentMetadata.DestinationTokenAccount + require.NoError(t, tc.mark(ctx, data, giftCardVault)) + + records, err := data.GetAllTransactionHistoryByGiftCardVault(ctx, giftCardVault) + require.NoError(t, err) + require.Len(t, records, 1) + assert.Equal(t, history.IndirectlySent, records[0].Type) + assert.Equal(t, tc.expected, records[0].State) + + // An issuance transitions exactly once + assert.Error(t, tc.mark(ctx, data, giftCardVault)) + } +} + +func TestMarkGiftCardIssuance_AlreadyClaimed(t *testing.T) { + ctx := context.Background() + data := ocp_data.NewTestDataProvider() + + issuedIntentRecord := newGiftCardIssuedIntentRecord() + saveRecordsForIntent(t, data, issuedIntentRecord) + + giftCardVault := issuedIntentRecord.SendPublicPaymentMetadata.DestinationTokenAccount + require.NoError(t, MarkGiftCardIssuanceAsClaimed(ctx, data, giftCardVault)) + + // A claimed issuance cannot transition again + assert.Error(t, MarkGiftCardIssuanceAsReturned(ctx, data, giftCardVault)) + + records, err := data.GetAllTransactionHistoryByGiftCardVault(ctx, giftCardVault) + require.NoError(t, err) + require.Len(t, records, 1) + assert.Equal(t, history.StateCompleted, records[0].State) +} + +func TestMarkGiftCardIssuance_ClaimRecordUntouched(t *testing.T) { + ctx := context.Background() + data := ocp_data.NewTestDataProvider() + + issuedIntentRecord := newGiftCardIssuedIntentRecord() + require.NoError(t, data.SaveIntent(ctx, issuedIntentRecord)) + saveRecordsForIntent(t, data, issuedIntentRecord) + + claimIntentRecord := newGiftCardClaimedIntentRecord(issuedIntentRecord) + saveRecordsForIntent(t, data, claimIntentRecord) + + giftCardVault := issuedIntentRecord.SendPublicPaymentMetadata.DestinationTokenAccount + require.NoError(t, MarkGiftCardIssuanceAsClaimed(ctx, data, giftCardVault)) + + records, err := data.GetAllTransactionHistoryByGiftCardVault(ctx, giftCardVault) + require.NoError(t, err) + require.Len(t, records, 2) + for _, record := range records { + assert.Equal(t, history.StateCompleted, record.State) + } +} + +func TestMarkGiftCardIssuance_NoHistory(t *testing.T) { + ctx := context.Background() + data := ocp_data.NewTestDataProvider() + + // Gift cards predating history integration are a no-op + assert.NoError(t, MarkGiftCardIssuanceAsClaimed(ctx, data, "missing_gift_card_vault")) +} diff --git a/ocp/history/intent.go b/ocp/history/intent.go new file mode 100644 index 0000000..88302fc --- /dev/null +++ b/ocp/history/intent.go @@ -0,0 +1,283 @@ +package history + +import ( + "context" + + "github.com/pkg/errors" + + transactionpb "github.com/code-payments/ocp-protobuf-api/generated/go/transaction/v1" + + "github.com/code-payments/ocp-server/ocp/common" + ocp_data "github.com/code-payments/ocp-server/ocp/data" + "github.com/code-payments/ocp-server/ocp/data/account" + "github.com/code-payments/ocp-server/ocp/data/action" + "github.com/code-payments/ocp-server/ocp/data/history" + "github.com/code-payments/ocp-server/ocp/data/intent" + "github.com/code-payments/ocp-server/pointer" +) + +// BuildRecordsForIntent builds the transaction history records for an intent. +// An intent that isn't a history-visible money movement (eg. account opens, or +// payments whose history is owned by another flow) yields no records. +// +// Records are created in a completed state, since the sequencer guarantees a +// submitted intent is eventually fulfilled. The exception is a gift card +// issuance, which is pending until the gift card is claimed, voided, or +// auto-returned. +// +// withdrawalFeeQuarks is the fee the server charges to create a withdrawal's +// destination account, in core mint quarks as a USD value, which is what the +// charge on the intent was validated against. +func BuildRecordsForIntent(ctx context.Context, data ocp_data.DatabaseData, intentRecord *intent.Record, protoMetadata *transactionpb.Metadata, actionRecords []*action.Record, withdrawalFeeQuarks uint64) ([]*history.Record, error) { + switch intentRecord.IntentType { + case intent.SendPublicPayment: + return buildRecordsForSendPublicPaymentIntent(ctx, data, intentRecord, protoMetadata, actionRecords, withdrawalFeeQuarks) + case intent.ReceivePaymentsPublicly: + return buildRecordsForReceivePaymentsPubliclyIntent(ctx, data, intentRecord) + default: + // todo: Support remaining money movement intent types + return nil, nil + } +} + +// BuildRecordForExternalDeposit builds the transaction history record for an +// external deposit intent. External deposit intents are created by workers +// rather than SubmitIntent, so they don't flow through BuildRecordsForIntent, +// and the transaction signature is provided by the observing worker because +// the intent doesn't store it. +// +// The record is referenced by the deposit's transaction signature, which maps +// it directly to the deposit record and the transaction on chain. +func BuildRecordForExternalDeposit(intentRecord *intent.Record, signature string) *history.Record { + metadata := intentRecord.ExternalDepositMetadata + + // Deposits initiated by a swap (buys and refunds) are not external money + // movements. Their history is owned by the swap flow. + // + // todo: Support the swap flow + if metadata.IsSwapBuy || metadata.IsReturned { + return nil + } + + // The funds are already on chain by the time the deposit is observed, so + // the record is immediately complete + return &history.Record{ + ReferenceId: signature, + ReferenceType: history.SignatureReference, + Type: history.Deposited, + OwnerAccount: intentRecord.InitiatorOwnerAccount, + ExchangeCurrency: metadata.ExchangeCurrency, + NativeAmount: metadata.NativeAmount, + MintAccount: intentRecord.MintAccount, + Quantity: metadata.Quantity, + AppMetadata: intentRecord.AppMetadata, + State: history.StateCompleted, + CreatedAt: intentRecord.CreatedAt, + } +} + +// ApplyStateTransitionsForIntent applies the state transitions an intent +// triggers on other flows' history records. +func ApplyStateTransitionsForIntent(ctx context.Context, data ocp_data.DatabaseData, intentRecord *intent.Record) error { + switch intentRecord.IntentType { + case intent.ReceivePaymentsPublicly: + metadata := intentRecord.ReceivePaymentsPubliclyMetadata + + // A gift card claim completes the issuer's IndirectlySent record + if metadata.IsIndirectSend && !metadata.IsReturned && !metadata.IsIssuerVoidingGiftCard { + return MarkGiftCardIssuanceAsClaimed(ctx, data, metadata.Source) + } + } + return nil +} + +func buildRecordsForSendPublicPaymentIntent(ctx context.Context, data ocp_data.DatabaseData, intentRecord *intent.Record, protoMetadata *transactionpb.Metadata, actionRecords []*action.Record, withdrawalFeeQuarks uint64) ([]*history.Record, error) { + metadata := intentRecord.SendPublicPaymentMetadata + + // Swap funding payments, including withdrawals executed as a stablecoin + // swap, are not payments of their own. Their history is owned by the swap + // flow. + // + // todo: Support the stablecoin swap flow + if metadata.IsSwapSell { + return nil, nil + } + + if metadata.IsWithdrawal { + return buildRecordsForWithdrawalIntent(ctx, data, intentRecord, protoMetadata, actionRecords, withdrawalFeeQuarks) + } + + // A gift card issuance is an indirect send to a not-yet-known counterparty. + // The record is pending until the gift card is claimed, voided, or + // auto-returned. + if metadata.IsIndirectSend { + return []*history.Record{{ + ReferenceId: intentRecord.IntentId, + ReferenceType: history.IntentReference, + Type: history.IndirectlySent, + OwnerAccount: intentRecord.InitiatorOwnerAccount, + ExchangeCurrency: metadata.ExchangeCurrency, + NativeAmount: metadata.NativeAmount, + MintAccount: intentRecord.MintAccount, + Quantity: metadata.Quantity, + GiftCardVault: pointer.String(metadata.DestinationTokenAccount), + AppMetadata: intentRecord.AppMetadata, + State: history.StatePending, + CreatedAt: intentRecord.CreatedAt, + }}, nil + } + + sent := &history.Record{ + ReferenceId: intentRecord.IntentId, + ReferenceType: history.IntentReference, + Type: history.DirectlySent, + OwnerAccount: intentRecord.InitiatorOwnerAccount, + ExchangeCurrency: metadata.ExchangeCurrency, + NativeAmount: metadata.NativeAmount, + MintAccount: intentRecord.MintAccount, + Quantity: metadata.Quantity, + AppMetadata: intentRecord.AppMetadata, + State: history.StateCompleted, + CreatedAt: intentRecord.CreatedAt, + } + if len(metadata.DestinationOwnerAccount) > 0 { + sent.CounterpartyOwnerAccount = pointer.String(metadata.DestinationOwnerAccount) + } + + // A record is one owner's view of one event, so a payment to self gets a + // single record, and one without a resolvable destination owner has no + // receiving side to record. + if len(metadata.DestinationOwnerAccount) == 0 || metadata.DestinationOwnerAccount == intentRecord.InitiatorOwnerAccount { + return []*history.Record{sent}, nil + } + + received := &history.Record{ + ReferenceId: intentRecord.IntentId, + ReferenceType: history.IntentReference, + Type: history.DirectlyReceived, + OwnerAccount: metadata.DestinationOwnerAccount, + CounterpartyOwnerAccount: pointer.String(intentRecord.InitiatorOwnerAccount), + ExchangeCurrency: metadata.ExchangeCurrency, + NativeAmount: metadata.NativeAmount, + MintAccount: intentRecord.MintAccount, + Quantity: metadata.Quantity, + AppMetadata: intentRecord.AppMetadata, + State: history.StateCompleted, + CreatedAt: intentRecord.CreatedAt, + } + + return []*history.Record{sent, received}, nil +} + +func buildRecordsForWithdrawalIntent(ctx context.Context, data ocp_data.DatabaseData, intentRecord *intent.Record, protoMetadata *transactionpb.Metadata, actionRecords []*action.Record, withdrawalFeeQuarks uint64) ([]*history.Record, error) { + metadata := intentRecord.SendPublicPaymentMetadata + + // The withdrawn quantity and value are gross, with the destination + // receiving the quantity less any fees, so fees are broken out. The fee is + // a fixed USD value, so its native value is that USD value at the client's + // verified fiat exchange rate. The value comes from the fee the server + // charges rather than the quarks the action moved, because those quarks are + // only that USD value when the intent's mint is the core mint: a launchpad + // currency pays the same fee in its own quarks. + var fees []history.Fee + for _, actionRecord := range actionRecords { + if actionRecord.FeeType == nil { + continue + } + + if *actionRecord.FeeType != transactionpb.FeePaymentAction_CREATE_ON_SEND_WITHDRAWAL { + return nil, errors.Errorf("unhandled fee type %s", *actionRecord.FeeType) + } + + clientExchangeData := protoMetadata.GetSendPublicPayment().GetClientExchangeData() + if clientExchangeData == nil { + return nil, errors.New("client exchange data is required for fee payments") + } + + feeUsdValue := float64(withdrawalFeeQuarks) / float64(common.CoreMintQuarksPerUnit) + fees = append(fees, history.Fee{ + Type: history.WithdrawalAccountCreationFee, + NativeAmount: clientExchangeData.CoreMintFiatExchangeRate.ExchangeRate.ExchangeRate * feeUsdValue, + }) + } + + withdrawn := &history.Record{ + ReferenceId: intentRecord.IntentId, + ReferenceType: history.IntentReference, + Type: history.Withdrawn, + OwnerAccount: intentRecord.InitiatorOwnerAccount, + ExchangeCurrency: metadata.ExchangeCurrency, + NativeAmount: metadata.NativeAmount, + Fees: fees, + MintAccount: intentRecord.MintAccount, + Quantity: metadata.Quantity, + AppMetadata: intentRecord.AppMetadata, + State: history.StateCompleted, + CreatedAt: intentRecord.CreatedAt, + } + if len(metadata.DestinationOwnerAccount) > 0 { + withdrawn.CounterpartyOwnerAccount = pointer.String(metadata.DestinationOwnerAccount) + } + + // A Code->Code withdrawal lands in another owner's primary account, which + // that owner sees as a deposit + destinationAccountInfoRecord, err := data.GetAccountInfoByTokenAddress(ctx, metadata.DestinationTokenAccount) + if err == account.ErrAccountInfoNotFound { + return []*history.Record{withdrawn}, nil + } else if err != nil { + return nil, err + } + if destinationAccountInfoRecord.OwnerAccount == intentRecord.InitiatorOwnerAccount { + return []*history.Record{withdrawn}, nil + } + + deposited := &history.Record{ + ReferenceId: intentRecord.IntentId, + ReferenceType: history.IntentReference, + Type: history.Deposited, + OwnerAccount: destinationAccountInfoRecord.OwnerAccount, + CounterpartyOwnerAccount: pointer.String(intentRecord.InitiatorOwnerAccount), + ExchangeCurrency: metadata.ExchangeCurrency, + NativeAmount: metadata.NativeAmount, + MintAccount: intentRecord.MintAccount, + Quantity: metadata.Quantity, + AppMetadata: intentRecord.AppMetadata, + State: history.StateCompleted, + CreatedAt: intentRecord.CreatedAt, + } + + return []*history.Record{withdrawn, deposited}, nil +} + +func buildRecordsForReceivePaymentsPubliclyIntent(ctx context.Context, data ocp_data.DatabaseData, intentRecord *intent.Record) ([]*history.Record, error) { + metadata := intentRecord.ReceivePaymentsPubliclyMetadata + + // Voids and auto-returns are server-initiated intents that are reflected + // as state transitions on the issuer's IndirectlySent record. + if !metadata.IsIndirectSend || metadata.IsReturned || metadata.IsIssuerVoidingGiftCard { + return nil, nil + } + + // The issuer is the claim's counterparty, and is only discoverable through + // the intent that issued the gift card. + giftCardIssuedIntentRecord, err := data.GetOriginalGiftCardIssuedIntent(ctx, metadata.Source) + if err != nil { + return nil, err + } + + return []*history.Record{{ + ReferenceId: intentRecord.IntentId, + ReferenceType: history.IntentReference, + Type: history.IndirectlyReceived, + OwnerAccount: intentRecord.InitiatorOwnerAccount, + CounterpartyOwnerAccount: pointer.String(giftCardIssuedIntentRecord.InitiatorOwnerAccount), + ExchangeCurrency: metadata.OriginalExchangeCurrency, + NativeAmount: metadata.OriginalNativeAmount, + MintAccount: intentRecord.MintAccount, + Quantity: metadata.Quantity, + GiftCardVault: pointer.String(metadata.Source), + AppMetadata: intentRecord.AppMetadata, + State: history.StateCompleted, + CreatedAt: intentRecord.CreatedAt, + }}, nil +} diff --git a/ocp/history/intent_test.go b/ocp/history/intent_test.go new file mode 100644 index 0000000..34236ac --- /dev/null +++ b/ocp/history/intent_test.go @@ -0,0 +1,486 @@ +package history + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + commonpb "github.com/code-payments/ocp-protobuf-api/generated/go/common/v1" + currencypb "github.com/code-payments/ocp-protobuf-api/generated/go/currency/v1" + transactionpb "github.com/code-payments/ocp-protobuf-api/generated/go/transaction/v1" + + "github.com/code-payments/ocp-server/ocp/common" + ocp_data "github.com/code-payments/ocp-server/ocp/data" + "github.com/code-payments/ocp-server/ocp/data/account" + "github.com/code-payments/ocp-server/ocp/data/action" + "github.com/code-payments/ocp-server/ocp/data/history" + "github.com/code-payments/ocp-server/ocp/data/intent" + "github.com/code-payments/ocp-server/pointer" + "github.com/code-payments/ocp-server/solana/currencycreator" +) + +// testWithdrawalFeeQuarks mirrors the transaction server's default withdrawal +// fee, in core mint quarks as a USD value: $0.50. +var testWithdrawalFeeQuarks = common.CoreMintQuarksPerUnit / 2 + +func TestBuildRecordsForIntent_DirectPayment(t *testing.T) { + ctx := context.Background() + data := ocp_data.NewTestDataProvider() + + intentRecord := newSendPublicPaymentIntentRecord() + + records, err := BuildRecordsForIntent(ctx, data, intentRecord, nil, nil, testWithdrawalFeeQuarks) + require.NoError(t, err) + require.Len(t, records, 2) + + sent := records[0] + assert.Equal(t, intentRecord.IntentId, sent.ReferenceId) + assert.Equal(t, history.DirectlySent, sent.Type) + assert.Equal(t, intentRecord.InitiatorOwnerAccount, sent.OwnerAccount) + require.NotNil(t, sent.CounterpartyOwnerAccount) + assert.Equal(t, intentRecord.SendPublicPaymentMetadata.DestinationOwnerAccount, *sent.CounterpartyOwnerAccount) + + received := records[1] + assert.Equal(t, intentRecord.IntentId, received.ReferenceId) + assert.Equal(t, history.DirectlyReceived, received.Type) + assert.Equal(t, intentRecord.SendPublicPaymentMetadata.DestinationOwnerAccount, received.OwnerAccount) + require.NotNil(t, received.CounterpartyOwnerAccount) + assert.Equal(t, intentRecord.InitiatorOwnerAccount, *received.CounterpartyOwnerAccount) + + for _, record := range records { + assert.Equal(t, intentRecord.SendPublicPaymentMetadata.ExchangeCurrency, record.ExchangeCurrency) + assert.Equal(t, intentRecord.SendPublicPaymentMetadata.NativeAmount, record.NativeAmount) + assert.Equal(t, intentRecord.MintAccount, record.MintAccount) + assert.Equal(t, intentRecord.SendPublicPaymentMetadata.Quantity, record.Quantity) + assert.Equal(t, intentRecord.AppMetadata, record.AppMetadata) + assert.Nil(t, record.GiftCardVault) + assert.Equal(t, history.StateCompleted, record.State) + assert.Equal(t, intentRecord.CreatedAt, record.CreatedAt) + assert.NoError(t, record.Validate()) + } +} + +func TestBuildRecordsForIntent_SelfPayment(t *testing.T) { + ctx := context.Background() + data := ocp_data.NewTestDataProvider() + + intentRecord := newSendPublicPaymentIntentRecord() + intentRecord.SendPublicPaymentMetadata.DestinationOwnerAccount = intentRecord.InitiatorOwnerAccount + + records, err := BuildRecordsForIntent(ctx, data, intentRecord, nil, nil, testWithdrawalFeeQuarks) + require.NoError(t, err) + require.Len(t, records, 1) + assert.Equal(t, history.DirectlySent, records[0].Type) + assert.Equal(t, intentRecord.InitiatorOwnerAccount, records[0].OwnerAccount) + assert.NoError(t, records[0].Validate()) +} + +func TestBuildRecordsForIntent_GiftCardIssuance(t *testing.T) { + ctx := context.Background() + data := ocp_data.NewTestDataProvider() + + intentRecord := newGiftCardIssuedIntentRecord() + + records, err := BuildRecordsForIntent(ctx, data, intentRecord, nil, nil, testWithdrawalFeeQuarks) + require.NoError(t, err) + require.Len(t, records, 1) + + issued := records[0] + assert.Equal(t, intentRecord.IntentId, issued.ReferenceId) + assert.Equal(t, history.IndirectlySent, issued.Type) + assert.Equal(t, intentRecord.InitiatorOwnerAccount, issued.OwnerAccount) + assert.Nil(t, issued.CounterpartyOwnerAccount) + assert.Equal(t, intentRecord.SendPublicPaymentMetadata.ExchangeCurrency, issued.ExchangeCurrency) + assert.Equal(t, intentRecord.SendPublicPaymentMetadata.NativeAmount, issued.NativeAmount) + assert.Equal(t, intentRecord.MintAccount, issued.MintAccount) + assert.Equal(t, intentRecord.SendPublicPaymentMetadata.Quantity, issued.Quantity) + require.NotNil(t, issued.GiftCardVault) + assert.Equal(t, intentRecord.SendPublicPaymentMetadata.DestinationTokenAccount, *issued.GiftCardVault) + assert.Equal(t, history.StatePending, issued.State) + assert.NoError(t, issued.Validate()) +} + +func TestBuildRecordsForIntent_GiftCardClaim(t *testing.T) { + ctx := context.Background() + data := ocp_data.NewTestDataProvider() + + issuedIntentRecord := newGiftCardIssuedIntentRecord() + require.NoError(t, data.SaveIntent(ctx, issuedIntentRecord)) + + intentRecord := newGiftCardClaimedIntentRecord(issuedIntentRecord) + + records, err := BuildRecordsForIntent(ctx, data, intentRecord, nil, nil, testWithdrawalFeeQuarks) + require.NoError(t, err) + require.Len(t, records, 1) + + claimed := records[0] + assert.Equal(t, intentRecord.IntentId, claimed.ReferenceId) + assert.Equal(t, history.IndirectlyReceived, claimed.Type) + assert.Equal(t, intentRecord.InitiatorOwnerAccount, claimed.OwnerAccount) + require.NotNil(t, claimed.CounterpartyOwnerAccount) + assert.Equal(t, issuedIntentRecord.InitiatorOwnerAccount, *claimed.CounterpartyOwnerAccount) + assert.Equal(t, intentRecord.ReceivePaymentsPubliclyMetadata.OriginalExchangeCurrency, claimed.ExchangeCurrency) + assert.Equal(t, intentRecord.ReceivePaymentsPubliclyMetadata.OriginalNativeAmount, claimed.NativeAmount) + assert.Equal(t, intentRecord.MintAccount, claimed.MintAccount) + assert.Equal(t, intentRecord.ReceivePaymentsPubliclyMetadata.Quantity, claimed.Quantity) + require.NotNil(t, claimed.GiftCardVault) + assert.Equal(t, intentRecord.ReceivePaymentsPubliclyMetadata.Source, *claimed.GiftCardVault) + assert.Equal(t, history.StateCompleted, claimed.State) + assert.NoError(t, claimed.Validate()) +} + +func TestBuildRecordsForIntent_GiftCardVoidAndAutoReturn(t *testing.T) { + ctx := context.Background() + data := ocp_data.NewTestDataProvider() + + issuedIntentRecord := newGiftCardIssuedIntentRecord() + + for _, mutate := range []func(*intent.Record){ + func(r *intent.Record) { r.ReceivePaymentsPubliclyMetadata.IsReturned = true }, + func(r *intent.Record) { r.ReceivePaymentsPubliclyMetadata.IsIssuerVoidingGiftCard = true }, + } { + intentRecord := newGiftCardClaimedIntentRecord(issuedIntentRecord) + mutate(intentRecord) + + records, err := BuildRecordsForIntent(ctx, data, intentRecord, nil, nil, testWithdrawalFeeQuarks) + require.NoError(t, err) + assert.Empty(t, records) + } +} + +func TestBuildRecordForExternalDeposit(t *testing.T) { + intentRecord := newExternalDepositIntentRecord() + + record := BuildRecordForExternalDeposit(intentRecord, "deposit_signature") + require.NotNil(t, record) + + assert.Equal(t, "deposit_signature", record.ReferenceId) + assert.Equal(t, history.Deposited, record.Type) + assert.Equal(t, intentRecord.InitiatorOwnerAccount, record.OwnerAccount) + assert.Nil(t, record.CounterpartyOwnerAccount) + assert.Equal(t, intentRecord.ExternalDepositMetadata.ExchangeCurrency, record.ExchangeCurrency) + assert.Equal(t, intentRecord.ExternalDepositMetadata.NativeAmount, record.NativeAmount) + assert.Equal(t, intentRecord.MintAccount, record.MintAccount) + assert.Equal(t, intentRecord.ExternalDepositMetadata.Quantity, record.Quantity) + assert.Nil(t, record.GiftCardVault) + assert.Equal(t, history.StateCompleted, record.State) + assert.Equal(t, intentRecord.CreatedAt, record.CreatedAt) + assert.NoError(t, record.Validate()) +} + +func TestBuildRecordForExternalDeposit_DepositsOwnedBySwapFlow(t *testing.T) { + for _, mutate := range []func(*intent.Record){ + func(r *intent.Record) { r.ExternalDepositMetadata.IsSwapBuy = true }, + func(r *intent.Record) { r.ExternalDepositMetadata.IsReturned = true }, + } { + intentRecord := newExternalDepositIntentRecord() + mutate(intentRecord) + assert.Nil(t, BuildRecordForExternalDeposit(intentRecord, "deposit_signature")) + } +} + +func TestBuildRecordsForIntent_ExternalWithdrawal(t *testing.T) { + ctx := context.Background() + data := ocp_data.NewTestDataProvider() + + intentRecord := newSendPublicPaymentIntentRecord() + intentRecord.SendPublicPaymentMetadata.IsWithdrawal = true + + records, err := BuildRecordsForIntent(ctx, data, intentRecord, nil, nil, testWithdrawalFeeQuarks) + require.NoError(t, err) + require.Len(t, records, 1) + + withdrawn := records[0] + assert.Equal(t, intentRecord.IntentId, withdrawn.ReferenceId) + assert.Equal(t, history.Withdrawn, withdrawn.Type) + assert.Equal(t, intentRecord.InitiatorOwnerAccount, withdrawn.OwnerAccount) + require.NotNil(t, withdrawn.CounterpartyOwnerAccount) + assert.Equal(t, intentRecord.SendPublicPaymentMetadata.DestinationOwnerAccount, *withdrawn.CounterpartyOwnerAccount) + assert.Equal(t, intentRecord.SendPublicPaymentMetadata.ExchangeCurrency, withdrawn.ExchangeCurrency) + assert.Equal(t, intentRecord.SendPublicPaymentMetadata.NativeAmount, withdrawn.NativeAmount) + assert.Empty(t, withdrawn.Fees) + assert.Equal(t, intentRecord.MintAccount, withdrawn.MintAccount) + assert.Equal(t, intentRecord.SendPublicPaymentMetadata.Quantity, withdrawn.Quantity) + assert.Equal(t, history.StateCompleted, withdrawn.State) + assert.NoError(t, withdrawn.Validate()) +} + +func TestBuildRecordsForIntent_ExternalWithdrawalWithFee(t *testing.T) { + ctx := context.Background() + data := ocp_data.NewTestDataProvider() + + intentRecord := newSendPublicPaymentIntentRecord() + intentRecord.SendPublicPaymentMetadata.IsWithdrawal = true + + // The $0.50 fee at a verified 2.0 fiat exchange rate is worth 1.00 EUR + protoMetadata := &transactionpb.Metadata{ + Type: &transactionpb.Metadata_SendPublicPayment{ + SendPublicPayment: &transactionpb.SendPublicPaymentMetadata{ + ExchangeData: &transactionpb.SendPublicPaymentMetadata_ClientExchangeData{ + ClientExchangeData: &transactionpb.VerifiedExchangeData{ + CoreMintFiatExchangeRate: ¤cypb.VerifiedCoreMintFiatExchangeRate{ + ExchangeRate: ¤cypb.CoreMintFiatExchangeRate{ + CurrencyCode: "eur", + ExchangeRate: 2.0, + }, + }, + }, + }, + }, + }, + } + + // The fee is the same $0.50 whatever mint it was charged in, so the quarks + // the action moved are only that USD value for the core mint. A launchpad + // currency pays the same fee in its own quarks, at its own price. + for _, chargedFeeQuarks := range []uint64{ + common.CoreMintQuarksPerUnit / 2, + 5_000 * currencycreator.DefaultMintQuarksPerUnit, + } { + feeType := transactionpb.FeePaymentAction_CREATE_ON_SEND_WITHDRAWAL + actionRecords := []*action.Record{ + { + Intent: intentRecord.IntentId, + IntentType: intentRecord.IntentType, + ActionId: 0, + ActionType: action.NoPrivacyTransfer, + Source: "source_token_account", + Quantity: pointer.Uint64(intentRecord.SendPublicPaymentMetadata.Quantity), + }, + { + Intent: intentRecord.IntentId, + IntentType: intentRecord.IntentType, + ActionId: 1, + ActionType: action.NoPrivacyTransfer, + Source: "source_token_account", + Quantity: pointer.Uint64(chargedFeeQuarks), + FeeType: &feeType, + }, + } + + records, err := BuildRecordsForIntent(ctx, data, intentRecord, protoMetadata, actionRecords, testWithdrawalFeeQuarks) + require.NoError(t, err) + require.Len(t, records, 1) + + require.Len(t, records[0].Fees, 1) + assert.Equal(t, history.WithdrawalAccountCreationFee, records[0].Fees[0].Type) + assert.InDelta(t, 1.0, records[0].Fees[0].NativeAmount, 0.0001) + assert.NoError(t, records[0].Validate()) + + // The verified exchange data is required when a fee is paid + _, err = BuildRecordsForIntent(ctx, data, intentRecord, nil, actionRecords, testWithdrawalFeeQuarks) + assert.Error(t, err) + } +} + +func TestBuildRecordsForIntent_ExternalWithdrawalWithUnhandledFee(t *testing.T) { + ctx := context.Background() + data := ocp_data.NewTestDataProvider() + + intentRecord := newSendPublicPaymentIntentRecord() + intentRecord.SendPublicPaymentMetadata.IsWithdrawal = true + + // A fee this package doesn't price would otherwise be dropped, leaving the + // record understating what the withdrawal cost + feeType := transactionpb.FeePaymentAction_UNKNOWN + actionRecords := []*action.Record{ + { + Intent: intentRecord.IntentId, + IntentType: intentRecord.IntentType, + ActionId: 0, + ActionType: action.NoPrivacyTransfer, + Source: "source_token_account", + Quantity: pointer.Uint64(testWithdrawalFeeQuarks), + FeeType: &feeType, + }, + } + + _, err := BuildRecordsForIntent(ctx, data, intentRecord, nil, actionRecords, testWithdrawalFeeQuarks) + assert.Error(t, err) +} + +func TestBuildRecordsForIntent_CodeToCodeWithdrawal(t *testing.T) { + ctx := context.Background() + data := ocp_data.NewTestDataProvider() + + intentRecord := newSendPublicPaymentIntentRecord() + intentRecord.SendPublicPaymentMetadata.IsWithdrawal = true + + require.NoError(t, data.CreateAccountInfo(ctx, &account.Record{ + OwnerAccount: intentRecord.SendPublicPaymentMetadata.DestinationOwnerAccount, + AuthorityAccount: intentRecord.SendPublicPaymentMetadata.DestinationOwnerAccount, + TokenAccount: intentRecord.SendPublicPaymentMetadata.DestinationTokenAccount, + MintAccount: intentRecord.MintAccount, + AccountType: commonpb.AccountType_PRIMARY, + })) + + records, err := BuildRecordsForIntent(ctx, data, intentRecord, nil, nil, testWithdrawalFeeQuarks) + require.NoError(t, err) + require.Len(t, records, 2) + + assert.Equal(t, history.Withdrawn, records[0].Type) + + // The receiving side of a Code->Code withdrawal is a deposit + deposited := records[1] + assert.Equal(t, intentRecord.IntentId, deposited.ReferenceId) + assert.Equal(t, history.Deposited, deposited.Type) + assert.Equal(t, intentRecord.SendPublicPaymentMetadata.DestinationOwnerAccount, deposited.OwnerAccount) + require.NotNil(t, deposited.CounterpartyOwnerAccount) + assert.Equal(t, intentRecord.InitiatorOwnerAccount, *deposited.CounterpartyOwnerAccount) + assert.Equal(t, intentRecord.SendPublicPaymentMetadata.Quantity, deposited.Quantity) + assert.Equal(t, history.StateCompleted, deposited.State) + assert.NoError(t, deposited.Validate()) +} + +func TestBuildRecordsForIntent_PaymentsOwnedByOtherFlows(t *testing.T) { + ctx := context.Background() + data := ocp_data.NewTestDataProvider() + + for _, mutate := range []func(*intent.Record){ + func(r *intent.Record) { r.SendPublicPaymentMetadata.IsSwapSell = true }, + func(r *intent.Record) { + r.SendPublicPaymentMetadata.IsWithdrawal = true + r.SendPublicPaymentMetadata.IsSwapSell = true + }, + } { + intentRecord := newSendPublicPaymentIntentRecord() + mutate(intentRecord) + + records, err := BuildRecordsForIntent(ctx, data, intentRecord, nil, nil, testWithdrawalFeeQuarks) + require.NoError(t, err) + assert.Empty(t, records) + } +} + +func TestBuildRecordsForIntent_UnsupportedIntentTypes(t *testing.T) { + ctx := context.Background() + data := ocp_data.NewTestDataProvider() + + intentRecord := &intent.Record{ + IntentId: "open_accounts_intent", + IntentType: intent.OpenAccounts, + MintAccount: "mint", + InitiatorOwnerAccount: "owner", + OpenAccountsMetadata: &intent.OpenAccountsMetadata{}, + State: intent.StatePending, + CreatedAt: time.Now(), + } + + records, err := BuildRecordsForIntent(ctx, data, intentRecord, nil, nil, testWithdrawalFeeQuarks) + require.NoError(t, err) + assert.Empty(t, records) +} + +func TestApplyStateTransitionsForIntent_GiftCardClaim(t *testing.T) { + ctx := context.Background() + data := ocp_data.NewTestDataProvider() + + issuedIntentRecord := newGiftCardIssuedIntentRecord() + require.NoError(t, data.SaveIntent(ctx, issuedIntentRecord)) + saveRecordsForIntent(t, data, issuedIntentRecord) + + claimIntentRecord := newGiftCardClaimedIntentRecord(issuedIntentRecord) + saveRecordsForIntent(t, data, claimIntentRecord) + + require.NoError(t, ApplyStateTransitionsForIntent(ctx, data, claimIntentRecord)) + + issuanceRecords, err := data.GetAllTransactionHistoryByReference(ctx, history.IntentReference, issuedIntentRecord.IntentId) + require.NoError(t, err) + require.Len(t, issuanceRecords, 1) + assert.Equal(t, history.IndirectlySent, issuanceRecords[0].Type) + assert.Equal(t, history.StateCompleted, issuanceRecords[0].State) +} + +func TestApplyStateTransitionsForIntent_NoTransitions(t *testing.T) { + ctx := context.Background() + data := ocp_data.NewTestDataProvider() + + intentRecord := newSendPublicPaymentIntentRecord() + assert.NoError(t, ApplyStateTransitionsForIntent(ctx, data, intentRecord)) +} + +func saveRecordsForIntent(t *testing.T, data ocp_data.Provider, intentRecord *intent.Record) { + ctx := context.Background() + + records, err := BuildRecordsForIntent(ctx, data, intentRecord, nil, nil, testWithdrawalFeeQuarks) + require.NoError(t, err) + for _, record := range records { + require.NoError(t, data.SaveTransactionHistory(ctx, record)) + } +} + +func newSendPublicPaymentIntentRecord() *intent.Record { + return &intent.Record{ + IntentId: "send_public_payment_intent", + IntentType: intent.SendPublicPayment, + MintAccount: "mint", + InitiatorOwnerAccount: "sender_owner", + SendPublicPaymentMetadata: &intent.SendPublicPaymentMetadata{ + DestinationOwnerAccount: "receiver_owner", + DestinationTokenAccount: "receiver_token_account", + Quantity: 100_000, + ExchangeCurrency: "usd", + ExchangeRate: 1.0, + NativeAmount: 10.0, + UsdMarketValue: 10.0, + }, + AppMetadata: []byte("app_metadata"), + State: intent.StatePending, + CreatedAt: time.Now(), + } +} + +func newExternalDepositIntentRecord() *intent.Record { + return &intent.Record{ + IntentId: "external_deposit_intent", + IntentType: intent.ExternalDeposit, + MintAccount: "mint", + InitiatorOwnerAccount: "depositor_owner", + ExternalDepositMetadata: &intent.ExternalDepositMetadata{ + DestinationTokenAccount: "depositor_token_account", + Quantity: 100_000, + ExchangeCurrency: "usd", + ExchangeRate: 1.0, + NativeAmount: 10.0, + UsdMarketValue: 10.0, + }, + State: intent.StateConfirmed, + CreatedAt: time.Now(), + } +} + +func newGiftCardIssuedIntentRecord() *intent.Record { + intentRecord := newSendPublicPaymentIntentRecord() + intentRecord.IntentId = "gift_card_issued_intent" + intentRecord.SendPublicPaymentMetadata.DestinationOwnerAccount = "" + intentRecord.SendPublicPaymentMetadata.DestinationTokenAccount = "gift_card_vault" + intentRecord.SendPublicPaymentMetadata.IsIndirectSend = true + return intentRecord +} + +func newGiftCardClaimedIntentRecord(issuedIntentRecord *intent.Record) *intent.Record { + return &intent.Record{ + IntentId: "gift_card_claimed_intent", + IntentType: intent.ReceivePaymentsPublicly, + MintAccount: issuedIntentRecord.MintAccount, + InitiatorOwnerAccount: "claimer_owner", + ReceivePaymentsPubliclyMetadata: &intent.ReceivePaymentsPubliclyMetadata{ + Source: issuedIntentRecord.SendPublicPaymentMetadata.DestinationTokenAccount, + Quantity: issuedIntentRecord.SendPublicPaymentMetadata.Quantity, + + IsIndirectSend: true, + + OriginalExchangeCurrency: issuedIntentRecord.SendPublicPaymentMetadata.ExchangeCurrency, + OriginalExchangeRate: issuedIntentRecord.SendPublicPaymentMetadata.ExchangeRate, + OriginalNativeAmount: issuedIntentRecord.SendPublicPaymentMetadata.NativeAmount, + + UsdMarketValue: issuedIntentRecord.SendPublicPaymentMetadata.UsdMarketValue, + }, + AppMetadata: []byte("claim_app_metadata"), + State: intent.StatePending, + CreatedAt: time.Now(), + } +} diff --git a/ocp/history/swap.go b/ocp/history/swap.go new file mode 100644 index 0000000..5d22778 --- /dev/null +++ b/ocp/history/swap.go @@ -0,0 +1,187 @@ +package history + +import ( + "context" + + "github.com/pkg/errors" + + currency_lib "github.com/code-payments/ocp-server/currency" + "github.com/code-payments/ocp-server/ocp/common" + currency_util "github.com/code-payments/ocp-server/ocp/currency" + ocp_data "github.com/code-payments/ocp-server/ocp/data" + "github.com/code-payments/ocp-server/ocp/data/history" + "github.com/code-payments/ocp-server/ocp/data/swap" + "github.com/code-payments/ocp-server/pointer" +) + +// BuildRecordForFundedSwap builds the transaction history record for a swap +// whose funding has come through and been validated. The value and the +// client's verified fiat exchange rate are provided by the swap worker, since +// their derivation depends on the funding source. +// +// The record is referenced by the swap's ID, mapping it directly to the swap +// record. The value is the gross amount funding the swap, with fees broken +// out, so the net value is the gross value less fees. The record is pending +// until the swap reaches a terminal state. +// +// launchTerms marks a swap as a currency's initial purchase and carries what +// that launch is charged. It is nil for every other swap. The swap worker +// resolves it, since neither the fact of the launch nor its terms are on the +// swap record. +func BuildRecordForFundedSwap(swapRecord *swap.Record, exchangeCurrency currency_lib.Code, nativeAmount, fiatExchangeRate float64, launchTerms *CurrencyLaunchTerms) (*history.Record, error) { + switch swapRecord.Kind { + case swap.KindReserve: + return buildRecordForFundedReserveSwap(swapRecord, exchangeCurrency, nativeAmount, launchTerms) + case swap.KindStablecoin: + return buildRecordForFundedStablecoinSwap(swapRecord, exchangeCurrency, nativeAmount, fiatExchangeRate) + default: + return nil, errors.New("unsupported swap kind") + } +} + +// CurrencyLaunchTerms are the amounts a currency launch is charged, in core +// mint quarks as USD values, matching what the launch was validated against. +type CurrencyLaunchTerms struct { + PurchaseQuarks uint64 + FeeQuarks uint64 +} + +// A reserve swap's destination quantity is set once the realized amount is +// known at finalization. +func buildRecordForFundedReserveSwap(swapRecord *swap.Record, exchangeCurrency currency_lib.Code, nativeAmount float64, launchTerms *CurrencyLaunchTerms) (*history.Record, error) { + fromMint, err := common.NewAccountFromPublicKeyString(swapRecord.FromMint) + if err != nil { + return nil, err + } + + var fees []history.Fee + if swapRecord.FeeAmount > 0 { + if launchTerms != nil { + // A launch is charged a fixed fee alongside a fixed purchase, so the + // fee's share of the value is those two amounts' ratio. The quarks + // funding the swap don't give that ratio: a launchpad currency is + // priced on a bonding curve, so a fee leg worth half the trade is + // not half of its quarks. + fundedQuarks := launchTerms.PurchaseQuarks + launchTerms.FeeQuarks + if fundedQuarks == 0 { + return nil, errors.New("currency launch terms are empty") + } + + fees = append(fees, history.Fee{ + Type: history.CurrencyLaunchFee, + NativeAmount: nativeAmount * float64(launchTerms.FeeQuarks) / float64(fundedQuarks), + }) + } else { + // A buy is charged a percentage of what it bought, which its own + // quarks do give, since they and the fee are the same mint + fees = append(fees, history.Fee{ + Type: history.ReserveBuyFee, + NativeAmount: nativeAmount - currency_util.DiscountValueForBuyFee(nativeAmount, swapRecord.SwapAmount, swapRecord.FeeAmount), + }) + } + } + + // Selling a launchpad currency incurs the liquidity pool's sell fee. A + // launch is the exception: the treasury sells the whole funding amount for + // protocol revenue and buys a fixed value on the swapper's behalf, so the + // pool's fee comes out of the protocol's side rather than theirs. + if !common.IsCoreMint(fromMint) && launchTerms == nil { + fees = append(fees, history.Fee{ + Type: history.ReserveSellFee, + NativeAmount: nativeAmount - currency_util.ApplySellFee(nativeAmount), + }) + } + + return &history.Record{ + ReferenceId: swapRecord.SwapId, + ReferenceType: history.SwapReference, + Type: history.Swap, + OwnerAccount: swapRecord.Owner, + ExchangeCurrency: exchangeCurrency, + NativeAmount: nativeAmount, + Fees: fees, + MintAccount: swapRecord.FromMint, + Quantity: swapRecord.SwapAmount + swapRecord.FeeAmount, + DestinationMintAccount: pointer.String(swapRecord.ToMint), + State: history.StatePending, + CreatedAt: swapRecord.CreatedAt, + }, nil +} + +// A stablecoin swap withdraws the core mint as an external stablecoin, so the +// user sees it as a withdrawal. The swap is 1:1, so the destination quantity +// is known upfront, and its fee is the withdrawal ATA creation fee, quoted in +// core mint quarks as a USD value, whose native value is that USD value at the +// client's verified fiat exchange rate. +func buildRecordForFundedStablecoinSwap(swapRecord *swap.Record, exchangeCurrency currency_lib.Code, nativeAmount, fiatExchangeRate float64) (*history.Record, error) { + var fees []history.Fee + if swapRecord.FeeAmount > 0 { + feeUsdValue := float64(swapRecord.FeeAmount) / float64(common.CoreMintQuarksPerUnit) + fees = append(fees, history.Fee{ + Type: history.WithdrawalAccountCreationFee, + NativeAmount: fiatExchangeRate * feeUsdValue, + }) + } + + record := &history.Record{ + ReferenceId: swapRecord.SwapId, + ReferenceType: history.SwapReference, + Type: history.Withdrawn, + OwnerAccount: swapRecord.Owner, + ExchangeCurrency: exchangeCurrency, + NativeAmount: nativeAmount, + Fees: fees, + MintAccount: swapRecord.FromMint, + Quantity: swapRecord.SwapAmount + swapRecord.FeeAmount, + DestinationMintAccount: pointer.String(swapRecord.ToMint), + DestinationQuantity: pointer.Uint64(swapRecord.SwapAmount), + State: history.StatePending, + CreatedAt: swapRecord.CreatedAt, + } + if len(swapRecord.DestinationOwner) > 0 { + record.CounterpartyOwnerAccount = pointer.String(swapRecord.DestinationOwner) + } + + return record, nil +} + +// MarkSwapAsCompleted completes a swap's record with the realized destination +// quantity after the swap transaction is finalized. +func MarkSwapAsCompleted(ctx context.Context, data ocp_data.DatabaseData, swapId string, destinationQuantity uint64) error { + return markSwapTerminal(ctx, data, swapId, history.StateCompleted, pointer.Uint64(destinationQuantity)) +} + +// MarkSwapAsFailed fails a swap's record after the swap could not be executed +// and the funds are refunded. +func MarkSwapAsFailed(ctx context.Context, data ocp_data.DatabaseData, swapId string) error { + return markSwapTerminal(ctx, data, swapId, history.StateFailed, nil) +} + +func markSwapTerminal(ctx context.Context, data ocp_data.DatabaseData, swapId string, newState history.State, destinationQuantity *uint64) error { + records, err := data.GetAllTransactionHistoryByReference(ctx, history.SwapReference, swapId) + if errors.Is(err, history.ErrNotFound) { + // The swap predates history integration + return nil + } + if err != nil { + return err + } + + // A swap is one owner's trade, so it has exactly one record. The reference + // type is what makes that hold: an ID only names a swap within its own + // kind, so a swap ID shares no space with an intent's. + if len(records) != 1 { + return errors.Errorf("found %d records for swap, expected 1", len(records)) + } + record := records[0] + + // A swap transitions exactly once, from pending, so anything else is a + // flow violation + if record.State != history.StatePending { + return errors.Errorf("swap record is %s, expected %s", record.State, history.StatePending) + } + + record.State = newState + record.DestinationQuantity = destinationQuantity + return data.SaveTransactionHistory(ctx, record) +} diff --git a/ocp/history/swap_test.go b/ocp/history/swap_test.go new file mode 100644 index 0000000..e40db45 --- /dev/null +++ b/ocp/history/swap_test.go @@ -0,0 +1,303 @@ +package history + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/code-payments/ocp-server/ocp/common" + ocp_data "github.com/code-payments/ocp-server/ocp/data" + "github.com/code-payments/ocp-server/ocp/data/history" + "github.com/code-payments/ocp-server/ocp/data/swap" + "github.com/code-payments/ocp-server/pointer" + "github.com/code-payments/ocp-server/testutil" +) + +// testLaunchTerms mirrors the swap worker's default launch amounts: $10 buys +// the currency's initial supply and $10 is the fee for creating it. +var testLaunchTerms = &CurrencyLaunchTerms{ + PurchaseQuarks: 10 * common.CoreMintQuarksPerUnit, + FeeQuarks: 10 * common.CoreMintQuarksPerUnit, +} + +func TestBuildRecordForFundedReserveSwap_Buy(t *testing.T) { + swapRecord := newReserveSwapRecord() + + record, err := BuildRecordForFundedSwap(swapRecord, "usd", 10.1, 1.0, nil) + require.NoError(t, err) + + assert.Equal(t, swapRecord.SwapId, record.ReferenceId) + assert.Equal(t, history.Swap, record.Type) + assert.Equal(t, swapRecord.Owner, record.OwnerAccount) + assert.Nil(t, record.CounterpartyOwnerAccount) + assert.EqualValues(t, "usd", record.ExchangeCurrency) + assert.Equal(t, 10.1, record.NativeAmount) + assert.Equal(t, swapRecord.FromMint, record.MintAccount) + assert.Equal(t, swapRecord.SwapAmount+swapRecord.FeeAmount, record.Quantity) + require.NotNil(t, record.DestinationMintAccount) + assert.Equal(t, swapRecord.ToMint, *record.DestinationMintAccount) + assert.Nil(t, record.DestinationQuantity) + assert.Equal(t, history.StatePending, record.State) + assert.Equal(t, swapRecord.CreatedAt, record.CreatedAt) + + // A buy funded with the core mint pays the protocol buy fee + require.Len(t, record.Fees, 1) + assert.Equal(t, history.ReserveBuyFee, record.Fees[0].Type) + assert.InDelta(t, 0.1, record.Fees[0].NativeAmount, 0.0001) + + assert.NoError(t, record.Validate()) +} + +func TestBuildRecordForFundedReserveSwap_LegacyBuyWithoutFee(t *testing.T) { + swapRecord := newReserveSwapRecord() + swapRecord.FeeAmount = 0 + + record, err := BuildRecordForFundedSwap(swapRecord, "usd", 10.0, 1.0, nil) + require.NoError(t, err) + assert.Empty(t, record.Fees) + assert.Equal(t, swapRecord.SwapAmount, record.Quantity) + assert.NoError(t, record.Validate()) +} + +func TestBuildRecordForFundedReserveSwap_Sell(t *testing.T) { + swapRecord := newReserveSwapRecord() + swapRecord.FromMint = testutil.NewRandomAccount(t).PublicKey().ToBase58() + swapRecord.ToMint = common.CoreMintAccount.PublicKey().ToBase58() + swapRecord.FeeAmount = 0 + + record, err := BuildRecordForFundedSwap(swapRecord, "usd", 10.0, 1.0, nil) + require.NoError(t, err) + + // Selling a launchpad currency incurs the pool's sell fee + require.Len(t, record.Fees, 1) + assert.Equal(t, history.ReserveSellFee, record.Fees[0].Type) + assert.InDelta(t, 0.1, record.Fees[0].NativeAmount, 0.0001) + + assert.NoError(t, record.Validate()) +} + +func TestBuildRecordForFundedReserveSwap_CurrencyLaunchWithCoreMint(t *testing.T) { + // $10 buys the currency's initial supply and $10 is the launch fee + swapRecord := newReserveSwapRecord() + swapRecord.SwapAmount = 10 * common.CoreMintQuarksPerUnit + swapRecord.FeeAmount = 10 * common.CoreMintQuarksPerUnit + + record, err := BuildRecordForFundedSwap(swapRecord, "usd", 20.0, 1.0, testLaunchTerms) + require.NoError(t, err) + + // The fee creates the currency, so it is a launch fee rather than the + // percentage a buy is charged + require.Len(t, record.Fees, 1) + assert.Equal(t, history.CurrencyLaunchFee, record.Fees[0].Type) + assert.InDelta(t, 10.0, record.Fees[0].NativeAmount, 0.0001) + + assert.NoError(t, record.Validate()) +} + +func TestBuildRecordForFundedReserveSwap_CurrencyLaunchWithLaunchpadCurrency(t *testing.T) { + // The same launch, paid for with a launchpad currency the creator already + // holds rather than the core mint. + // + // The quark amounts are what the bonding curve actually quotes for the two + // legs against a 100k token supply: the $10 fee leg costs slightly fewer + // quarks than the $10 purchase leg, because selling the fee leg first moves + // the price down for the rest. So the legs split the value evenly while + // splitting the quarks unevenly, and only the terms give the right ratio. + swapRecord := newReserveSwapRecord() + swapRecord.FromMint = testutil.NewRandomAccount(t).PublicKey().ToBase58() + swapRecord.FeeAmount = 9_164_286_143_681 + swapRecord.SwapAmount = 18_335_942_827_079 - swapRecord.FeeAmount + + record, err := BuildRecordForFundedSwap(swapRecord, "usd", 20.0, 1.0, testLaunchTerms) + require.NoError(t, err) + + // The treasury sells the whole funding amount for protocol revenue and buys + // a fixed value on the swapper's behalf, so the pool's sell fee is not the + // swapper's to pay and the launch fee is all they were charged + require.Len(t, record.Fees, 1) + assert.Equal(t, history.CurrencyLaunchFee, record.Fees[0].Type) + assert.InDelta(t, 10.0, record.Fees[0].NativeAmount, 0.0001) + + assert.NoError(t, record.Validate()) +} + +func TestBuildRecordForFundedStablecoinSwap(t *testing.T) { + swapRecord := newStablecoinSwapRecord() + + // A $0.50 USD fee at a verified 2.0 fiat exchange rate + swapRecord.FeeAmount = common.CoreMintQuarksPerUnit / 2 + + record, err := BuildRecordForFundedSwap(swapRecord, "eur", 20.0, 2.0, nil) + require.NoError(t, err) + + assert.Equal(t, swapRecord.SwapId, record.ReferenceId) + assert.Equal(t, history.Withdrawn, record.Type) + assert.Equal(t, swapRecord.Owner, record.OwnerAccount) + require.NotNil(t, record.CounterpartyOwnerAccount) + assert.Equal(t, swapRecord.DestinationOwner, *record.CounterpartyOwnerAccount) + assert.EqualValues(t, "eur", record.ExchangeCurrency) + assert.Equal(t, 20.0, record.NativeAmount) + assert.Equal(t, swapRecord.FromMint, record.MintAccount) + assert.Equal(t, swapRecord.SwapAmount+swapRecord.FeeAmount, record.Quantity) + require.NotNil(t, record.DestinationMintAccount) + assert.Equal(t, swapRecord.ToMint, *record.DestinationMintAccount) + + // The destination quantity is known upfront because the swap is 1:1 + require.NotNil(t, record.DestinationQuantity) + assert.Equal(t, swapRecord.SwapAmount, *record.DestinationQuantity) + + // The fee is the USD fee value at the client's verified fiat exchange rate + require.Len(t, record.Fees, 1) + assert.Equal(t, history.WithdrawalAccountCreationFee, record.Fees[0].Type) + assert.InDelta(t, 1.0, record.Fees[0].NativeAmount, 0.0001) + + assert.Equal(t, history.StatePending, record.State) + assert.NoError(t, record.Validate()) +} + +func TestBuildRecordForFundedSwap_UnsupportedKind(t *testing.T) { + swapRecord := newReserveSwapRecord() + swapRecord.Kind = swap.KindUnknown + + _, err := BuildRecordForFundedSwap(swapRecord, "usd", 10.0, 1.0, nil) + assert.Error(t, err) +} + +func TestMarkSwapAsCompleted_StablecoinWithdrawal(t *testing.T) { + ctx := context.Background() + data := ocp_data.NewTestDataProvider() + + swapRecord := newStablecoinSwapRecord() + + record, err := BuildRecordForFundedSwap(swapRecord, "usd", 10.0, 1.0, nil) + require.NoError(t, err) + require.NoError(t, data.SaveTransactionHistory(ctx, record)) + + require.NoError(t, MarkSwapAsCompleted(ctx, data, swapRecord.SwapId, swapRecord.SwapAmount)) + + records, err := data.GetAllTransactionHistoryByReference(ctx, history.SwapReference, swapRecord.SwapId) + require.NoError(t, err) + require.Len(t, records, 1) + assert.Equal(t, history.Withdrawn, records[0].Type) + assert.Equal(t, history.StateCompleted, records[0].State) + require.NotNil(t, records[0].DestinationQuantity) + assert.Equal(t, swapRecord.SwapAmount, *records[0].DestinationQuantity) +} + +func TestMarkSwapAsCompleted(t *testing.T) { + ctx := context.Background() + data := ocp_data.NewTestDataProvider() + + swapRecord := newReserveSwapRecord() + saveFundedReserveSwapRecord(t, data, swapRecord) + + require.NoError(t, MarkSwapAsCompleted(ctx, data, swapRecord.SwapId, 420_000)) + + records, err := data.GetAllTransactionHistoryByReference(ctx, history.SwapReference, swapRecord.SwapId) + require.NoError(t, err) + require.Len(t, records, 1) + assert.Equal(t, history.StateCompleted, records[0].State) + require.NotNil(t, records[0].DestinationQuantity) + assert.EqualValues(t, 420_000, *records[0].DestinationQuantity) + + // A swap transitions exactly once + assert.Error(t, MarkSwapAsCompleted(ctx, data, swapRecord.SwapId, 420_000)) + assert.Error(t, MarkSwapAsFailed(ctx, data, swapRecord.SwapId)) +} + +func TestMarkSwapAsFailed(t *testing.T) { + ctx := context.Background() + data := ocp_data.NewTestDataProvider() + + swapRecord := newReserveSwapRecord() + saveFundedReserveSwapRecord(t, data, swapRecord) + + require.NoError(t, MarkSwapAsFailed(ctx, data, swapRecord.SwapId)) + + records, err := data.GetAllTransactionHistoryByReference(ctx, history.SwapReference, swapRecord.SwapId) + require.NoError(t, err) + require.Len(t, records, 1) + assert.Equal(t, history.StateFailed, records[0].State) + assert.Nil(t, records[0].DestinationQuantity) + + // A swap transitions exactly once + assert.Error(t, MarkSwapAsFailed(ctx, data, swapRecord.SwapId)) +} + +func TestMarkSwapTerminal_NoHistory(t *testing.T) { + ctx := context.Background() + data := ocp_data.NewTestDataProvider() + + // Swaps predating history integration are a no-op + assert.NoError(t, MarkSwapAsCompleted(ctx, data, "missing_swap_id", 420_000)) + assert.NoError(t, MarkSwapAsFailed(ctx, data, "missing_swap_id")) +} + +func TestMarkSwapTerminal_IgnoresOtherReferenceKinds(t *testing.T) { + ctx := context.Background() + data := ocp_data.NewTestDataProvider() + + // An intent whose ID happens to equal a swap's. Both are client supplied + // public keys, so nothing stops a client from picking one for the other. + record := &history.Record{ + ReferenceId: "swap_id", + ReferenceType: history.IntentReference, + Type: history.DirectlySent, + OwnerAccount: "owner", + CounterpartyOwnerAccount: pointer.String("counterparty_owner"), + ExchangeCurrency: "usd", + NativeAmount: 10.0, + MintAccount: "mint", + Quantity: 100_000, + State: history.StateCompleted, + CreatedAt: time.Now(), + } + require.NoError(t, data.SaveTransactionHistory(ctx, record)) + + // The swap has no record of its own, so it is treated as predating history + // rather than finding the intent's and transitioning it + require.NoError(t, MarkSwapAsCompleted(ctx, data, "swap_id", 420_000)) + + actual, err := data.GetAllTransactionHistoryByReference(ctx, history.IntentReference, "swap_id") + require.NoError(t, err) + require.Len(t, actual, 1) + assert.Equal(t, history.DirectlySent, actual[0].Type) + assert.Equal(t, history.StateCompleted, actual[0].State) + assert.Nil(t, actual[0].DestinationQuantity) +} + +func saveFundedReserveSwapRecord(t *testing.T, data ocp_data.Provider, swapRecord *swap.Record) { + ctx := context.Background() + + record, err := BuildRecordForFundedSwap(swapRecord, "usd", 10.1, 1.0, nil) + require.NoError(t, err) + require.NoError(t, data.SaveTransactionHistory(ctx, record)) +} + +func newStablecoinSwapRecord() *swap.Record { + swapRecord := newReserveSwapRecord() + swapRecord.SwapId = "stablecoin_swap_id" + swapRecord.Kind = swap.KindStablecoin + swapRecord.ToMint = "usdc_mint" + swapRecord.DestinationOwner = "external_wallet_owner" + return swapRecord +} + +func newReserveSwapRecord() *swap.Record { + return &swap.Record{ + SwapId: "swap_id", + Kind: swap.KindReserve, + Owner: "swapper_owner", + FromMint: common.CoreMintAccount.PublicKey().ToBase58(), + ToMint: "launchpad_mint", + SwapAmount: 9_900_000, + FeeAmount: 99_000, + FundingId: "funding_id", + FundingSource: swap.FundingSourceSubmitIntent, + State: swap.StateFunded, + CreatedAt: time.Now(), + } +} diff --git a/ocp/rpc/transaction/intent.go b/ocp/rpc/transaction/intent.go index bf92d1e..b11fbf6 100644 --- a/ocp/rpc/transaction/intent.go +++ b/ocp/rpc/transaction/intent.go @@ -29,6 +29,7 @@ import ( "github.com/code-payments/ocp-server/ocp/data/intent" "github.com/code-payments/ocp-server/ocp/data/nonce" "github.com/code-payments/ocp-server/ocp/data/task" + history_util "github.com/code-payments/ocp-server/ocp/history" "github.com/code-payments/ocp-server/ocp/rpc" "github.com/code-payments/ocp-server/ocp/transaction" "github.com/code-payments/ocp-server/pointer" @@ -643,6 +644,28 @@ func (s *transactionServer) SubmitIntent(streamer transactionpb.Transaction_Subm return err } + // Save transaction history records for the intent + historyRecords, err := history_util.BuildRecordsForIntent(ctx, s.data, intentRecord, submitActionsReq.Metadata, actionRecords, s.conf.createOnSendWithdrawalFeeQuarks.Get(ctx)) + if err != nil { + log.With(zap.Error(err)).Warn("failure building transaction history records") + return err + } + for _, historyRecord := range historyRecords { + err = s.data.SaveTransactionHistory(ctx, historyRecord) + if err != nil { + log.With(zap.Error(err)).Warn("failure saving transaction history record") + return err + } + } + + // Apply state transitions the intent triggers on other flows' transaction + // history records + err = history_util.ApplyStateTransitionsForIntent(ctx, s.data, intentRecord) + if err != nil { + log.With(zap.Error(err)).Warn("failure applying transaction history state transitions") + return err + } + // Save additional state related to the intent err = intentHandler.OnCommitToDB(ctx) if err != nil { diff --git a/ocp/worker/account/gift_card.go b/ocp/worker/account/gift_card.go index 70962fc..41035ca 100644 --- a/ocp/worker/account/gift_card.go +++ b/ocp/worker/account/gift_card.go @@ -22,6 +22,7 @@ import ( "github.com/code-payments/ocp-server/ocp/data/action" "github.com/code-payments/ocp-server/ocp/data/fulfillment" "github.com/code-payments/ocp-server/ocp/data/intent" + history_util "github.com/code-payments/ocp-server/ocp/history" "github.com/code-payments/ocp-server/pointer" "github.com/code-payments/ocp-server/retry" ) @@ -174,6 +175,17 @@ func InitiateProcessToAutoReturnGiftCard(ctx context.Context, data ocp_data.Prov return err } + // Transition the issuer's transaction history record to reflect the + // returned funds + if isVoidedByUser { + err = history_util.MarkGiftCardIssuanceAsVoided(ctx, data, giftCardVaultAccount.PublicKey().ToBase58()) + } else { + err = history_util.MarkGiftCardIssuanceAsReturned(ctx, data, giftCardVaultAccount.PublicKey().ToBase58()) + } + if err != nil { + return err + } + // We need to update pre-sorting because auto-return fulfillments are always // inserted at the very last spot in the line. // diff --git a/ocp/worker/geyser/external_deposit.go b/ocp/worker/geyser/external_deposit.go index 57bf2e2..803ec2a 100644 --- a/ocp/worker/geyser/external_deposit.go +++ b/ocp/worker/geyser/external_deposit.go @@ -25,6 +25,7 @@ import ( "github.com/code-payments/ocp-server/ocp/data/intent" "github.com/code-payments/ocp-server/ocp/data/swap" "github.com/code-payments/ocp-server/ocp/data/transaction" + history_util "github.com/code-payments/ocp-server/ocp/history" "github.com/code-payments/ocp-server/ocp/integration" transaction_util "github.com/code-payments/ocp-server/ocp/transaction" vm_util "github.com/code-payments/ocp-server/ocp/vm" @@ -39,6 +40,16 @@ import ( const ( // todo: something better? codeVmDepositMemoValue = "vm_deposit" + + // The minimum value worth moving from a VM deposit ATA into the VM. Below + // it the deposit isn't initiated at all, so the funds stay in the deposit + // ATA and are swept by a later deposit once enough has accumulated. + // + // The floor belongs here rather than on the observing side, because a + // deposit that has already settled on chain has to be recorded: a balance + // is the sum of an account's deposit records, so declining to record one + // would leave the balance short of what the chain holds. + minExternalDepositUsdValue = 0.01 ) var ( @@ -46,7 +57,7 @@ var ( ) func fixMissingExternalDeposits(ctx context.Context, data ocp_data.Provider, exchangeRateStore exchange.Store, reserveStore reserve.Store, integration integration.Geyser, userAuthority, mint *common.Account) error { - err := maybeInitiateExternalDepositIntoVm(ctx, data, userAuthority, mint) + err := maybeInitiateExternalDepositIntoVm(ctx, data, exchangeRateStore, reserveStore, userAuthority, mint) if err != nil { return errors.Wrap(err, "error depositing into the vm") } @@ -70,7 +81,7 @@ func fixMissingExternalDeposits(ctx context.Context, data ocp_data.Provider, exc return markDepositsAsSynced(ctx, data, userAuthority, mint) } -func maybeInitiateExternalDepositIntoVm(ctx context.Context, data ocp_data.Provider, userAuthority, mint *common.Account) error { +func maybeInitiateExternalDepositIntoVm(ctx context.Context, data ocp_data.Provider, exchangeRateStore exchange.Store, reserveStore reserve.Store, userAuthority, mint *common.Account) error { vmConfig, err := common.GetVmConfigForMint(ctx, data, mint) if err != nil { return err @@ -91,10 +102,20 @@ func maybeInitiateExternalDepositIntoVm(ctx context.Context, data ocp_data.Provi if balance == 0 { return nil } - return initiateExternalDepositIntoVm(ctx, data, userAuthority, mint, balance) + return initiateExternalDepositIntoVm(ctx, data, exchangeRateStore, reserveStore, userAuthority, mint, balance) } -func initiateExternalDepositIntoVm(ctx context.Context, data ocp_data.Provider, userAuthority, mint *common.Account, balance uint64) error { +func initiateExternalDepositIntoVm(ctx context.Context, data ocp_data.Provider, exchangeRateStore exchange.Store, reserveStore reserve.Store, userAuthority, mint *common.Account, balance uint64) error { + // Checked before any of the setup a deposit needs, since the point is to + // not do the work at all for an amount that isn't worth moving + usdMarketValue, err := currency_util.CalculateUsdMarketValueFromTokenAmount(ctx, data, exchangeRateStore, reserveStore, mint, balance, time.Now()) + if err != nil { + return errors.Wrap(err, "error calculating usd market value") + } + if usdMarketValue < minExternalDepositUsdValue { + return nil + } + vmConfig, err := common.GetVmConfigForMint(ctx, data, mint) if err != nil { return errors.Wrap(err, "error getting vm config") @@ -360,6 +381,14 @@ func processPotentialExternalDepositIntoVm(ctx context.Context, data ocp_data.Pr return errors.Wrap(err, "error saving intent record") } + historyRecord := history_util.BuildRecordForExternalDeposit(intentRecord, signature) + if historyRecord != nil { + err = data.SaveTransactionHistory(ctx, historyRecord) + if err != nil { + return errors.Wrap(err, "error saving transaction history record") + } + } + // For tracking in cached balances externalDepositRecord := &deposit.Record{ Signature: signature, diff --git a/ocp/worker/geyser/handler.go b/ocp/worker/geyser/handler.go index d54fd54..6e89797 100644 --- a/ocp/worker/geyser/handler.go +++ b/ocp/worker/geyser/handler.go @@ -118,7 +118,7 @@ func (h *TokenProgramAccountHandler) Handle(ctx context.Context, update *geyserp } if unmarshalled.Amount > 0 { - err = initiateExternalDepositIntoVm(ctx, h.data, userAuthorityAccount, mintAccount, unmarshalled.Amount) + err = initiateExternalDepositIntoVm(ctx, h.data, h.exchangeRateStore, h.reserveStore, userAuthorityAccount, mintAccount, unmarshalled.Amount) if err != nil { return errors.Wrap(err, "error depositing into the vm") } diff --git a/ocp/worker/swap/config.go b/ocp/worker/swap/config.go index 22a82bd..0a03af4 100644 --- a/ocp/worker/swap/config.go +++ b/ocp/worker/swap/config.go @@ -5,6 +5,7 @@ import ( "github.com/code-payments/ocp-server/config" "github.com/code-payments/ocp-server/config/env" + "github.com/code-payments/ocp-server/ocp/common" ) const ( @@ -21,6 +22,21 @@ const ( CoinbaseOnrampOrderTimeoutConfigEnvName = envConfigPrefix + "COINBASE_ONRAMP_ORDER_TIMEOUT" defaultCoinbaseOnrampOrderTimeout = 5 * time.Minute + + // The amounts a currency launch is charged. These MUST be kept equal to the + // transaction service's TRANSACTION_SERVICE_NEW_CURRENCY_PURCHASE_QUARKS and + // TRANSACTION_SERVICE_NEW_CURRENCY_FEE_QUARKS, which are what a launch is + // actually validated and charged against. Nothing checks the two agree, and + // a divergence silently misstates the launch fee in transaction history. + NewCurrencyPurchaseQuarksConfigEnvName = envConfigPrefix + "NEW_CURRENCY_PURCHASE_QUARKS" + + NewCurrencyFeeQuarksConfigEnvName = envConfigPrefix + "NEW_CURRENCY_FEE_QUARKS" +) + +// Assumes a USD stable coin core mint +var ( + defaultNewCurrencyPurchaseQuarks = 10 * common.CoreMintQuarksPerUnit // $10 + defaultNewCurrencyFeeQuarks = 10 * common.CoreMintQuarksPerUnit // $10 ) type conf struct { @@ -28,6 +44,8 @@ type conf struct { clientTimeoutToFund config.Duration externalWalletFinalizationTimeout config.Duration coinbaseOnrampOrderTimeout config.Duration + newCurrencyPurchaseQuarks config.Uint64 + newCurrencyFeeQuarks config.Uint64 } // ConfigProvider defines how config values are pulled @@ -41,6 +59,8 @@ func WithEnvConfigs() ConfigProvider { clientTimeoutToFund: env.NewDurationConfig(ClientTimeoutToFundConfigEnvName, defaultClientTimeoutToFund), externalWalletFinalizationTimeout: env.NewDurationConfig(ExternalWalletFinalizationTimeoutConfigEnvName, defaultExternalWalletFinalizationTimeout), coinbaseOnrampOrderTimeout: env.NewDurationConfig(CoinbaseOnrampOrderTimeoutConfigEnvName, defaultCoinbaseOnrampOrderTimeout), + newCurrencyPurchaseQuarks: env.NewUint64Config(NewCurrencyPurchaseQuarksConfigEnvName, defaultNewCurrencyPurchaseQuarks), + newCurrencyFeeQuarks: env.NewUint64Config(NewCurrencyFeeQuarksConfigEnvName, defaultNewCurrencyFeeQuarks), } } } diff --git a/ocp/worker/swap/util.go b/ocp/worker/swap/util.go index 68af8b9..e07946a 100644 --- a/ocp/worker/swap/util.go +++ b/ocp/worker/swap/util.go @@ -20,6 +20,7 @@ import ( "github.com/code-payments/ocp-server/ocp/data/nonce" "github.com/code-payments/ocp-server/ocp/data/swap" "github.com/code-payments/ocp-server/ocp/data/transaction" + history_util "github.com/code-payments/ocp-server/ocp/history" transaction_util "github.com/code-payments/ocp-server/ocp/transaction" vm_util "github.com/code-payments/ocp-server/ocp/vm" "github.com/code-payments/ocp-server/solana" @@ -60,11 +61,103 @@ func (p *runtime) markSwapSubmitting(ctx context.Context, record *swap.Record) e return err } - record.State = swap.StateSubmitting - return p.data.SaveSwap(ctx, record) + // The funding has come through and been validated, so the swap enters + // transaction history + exchangeCurrency, nativeAmount, fiatExchangeRate, err := p.getFundedSwapValue(ctx, record) + if err != nil { + return err + } + isCurrencyLaunch, err := p.isCurrencyLaunch(ctx, record) + if err != nil { + return err + } + var launchTerms *history_util.CurrencyLaunchTerms + if isCurrencyLaunch { + launchTerms = &history_util.CurrencyLaunchTerms{ + PurchaseQuarks: p.conf.newCurrencyPurchaseQuarks.Get(ctx), + FeeQuarks: p.conf.newCurrencyFeeQuarks.Get(ctx), + } + } + historyRecord, err := history_util.BuildRecordForFundedSwap(record, exchangeCurrency, nativeAmount, fiatExchangeRate, launchTerms) + if err != nil { + return err + } + + return p.data.ExecuteInTx(ctx, sql.LevelDefault, func(ctx context.Context) error { + err := p.data.SaveTransactionHistory(ctx, historyRecord) + if err != nil { + return err + } + + record.State = swap.StateSubmitting + return p.data.SaveSwap(ctx, record) + }) } -func (p *runtime) markSwapFinalized(ctx context.Context, swapRecord *swap.Record) error { +// isCurrencyLaunch reports whether a swap is a currency's initial purchase. +// The swap record doesn't carry it, but the destination currency only reaches +// an available state once its initial purchase has completed, so a destination +// that hasn't is one this swap is launching. +func (p *runtime) isCurrencyLaunch(ctx context.Context, swapRecord *swap.Record) (bool, error) { + if swapRecord.Kind != swap.KindReserve { + return false, nil + } + + toMint, err := common.NewAccountFromPublicKeyString(swapRecord.ToMint) + if err != nil { + return false, err + } + + // Only a launchpad currency is ever launched, so a swap into the core mint + // is a sell and has no currency metadata to consult + if common.IsCoreMint(toMint) { + return false, nil + } + + destinationCurrencyMetadataRecord, err := p.data.GetCurrencyMetadata(ctx, swapRecord.ToMint) + if err != nil { + return false, err + } + return destinationCurrencyMetadataRecord.State != currency.MetadataStateAvailable, nil +} + +// getFundedSwapValue derives the value funding a swap, along with the client's +// verified fiat exchange rate. The derivation mirrors +// buildRefundRecordsForCancelledSwap: intent funded swaps inherit the funding +// payment's exchange data, while externally funded swaps are valued in USD at +// the current market rate. +func (p *runtime) getFundedSwapValue(ctx context.Context, swapRecord *swap.Record) (currency_lib.Code, float64, float64, error) { + switch swapRecord.FundingSource { + case swap.FundingSourceSubmitIntent: + fundingIntentRecord, err := p.data.GetIntent(ctx, swapRecord.FundingId) + if err != nil { + return "", 0, 0, err + } + if fundingIntentRecord.IntentType != intent.SendPublicPayment { + return "", 0, 0, errors.New("unexpected intent type") + } + metadata := fundingIntentRecord.SendPublicPaymentMetadata + return metadata.ExchangeCurrency, metadata.NativeAmount, metadata.ExchangeRate, nil + case swap.FundingSourceExternalWallet, swap.FundingSourceCoinbaseOnramp: + fromMint, err := common.NewAccountFromPublicKeyString(swapRecord.FromMint) + if err != nil { + return "", 0, 0, err + } + if !common.IsCoreMint(fromMint) { + return "", 0, 0, errors.New("unexpected source mint") + } + + usdMarketValue, err := currency_util.CalculateUsdMarketValueFromTokenAmount(ctx, p.data, p.exchangeRateStore, p.reserveStore, common.CoreMintAccount, swapRecord.SwapAmount+swapRecord.FeeAmount, time.Now()) + if err != nil { + return "", 0, 0, err + } + return currency_lib.USD, usdMarketValue, 1.0, nil + default: + return "", 0, 0, errors.New("unsupported funding source") + } +} + +func (p *runtime) markSwapFinalized(ctx context.Context, swapRecord *swap.Record, quarksBought uint64) error { toMint, err := common.NewAccountFromPublicKeyString(swapRecord.ToMint) if err != nil { return err @@ -103,6 +196,11 @@ func (p *runtime) markSwapFinalized(ctx context.Context, swapRecord *swap.Record } } + err = history_util.MarkSwapAsCompleted(ctx, p.data, swapRecord.SwapId, quarksBought) + if err != nil { + return err + } + swapRecord.TransactionBlob = nil swapRecord.State = swap.StateFinalized return p.data.SaveSwap(ctx, swapRecord) @@ -154,6 +252,11 @@ func (p *runtime) markSwapFailed(ctx context.Context, swapRecord *swap.Record) e } } + err = history_util.MarkSwapAsFailed(ctx, p.data, swapRecord.SwapId) + if err != nil { + return err + } + swapRecord.TransactionBlob = nil swapRecord.State = swap.StateFailed return p.data.SaveSwap(ctx, swapRecord) @@ -360,6 +463,13 @@ func (p *runtime) markSwapCancelled(ctx context.Context, swapRecord *swap.Record if err != nil { return err } + + // The swap was funded and entered transaction history, so its + // record must reflect the refund + err = history_util.MarkSwapAsFailed(ctx, p.data, swapRecord.SwapId) + if err != nil { + return err + } } if swapRecord.Kind == swap.KindReserve && !common.IsCoreMint(toMint) { diff --git a/ocp/worker/swap/worker.go b/ocp/worker/swap/worker.go index bc694df..420fb01 100644 --- a/ocp/worker/swap/worker.go +++ b/ocp/worker/swap/worker.go @@ -294,7 +294,7 @@ func (p *runtime) handleStateSubmitting(ctx context.Context, record *swap.Record return errors.Wrap(err, "error updating balances") } - err = p.markSwapFinalized(ctx, record) + err = p.markSwapFinalized(ctx, record, quarksBought) if err != nil { return errors.Wrap(err, "error marking swap as finalized") }