diff --git a/Makefile b/Makefile index 685c72c..8a46831 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ COMPOSE=docker compose -p eye --env-file deploy/.env COMPOSE_FILE=deploy/compose.yaml .PHONY: gen go-gen up debug down logs clean buf-gen docs-api \ - test test-unit test-integration schema-apply schema-diff \ + test test-unit test-integration test-smoke schema-apply schema-diff \ lint vet check # Generate all code @@ -50,6 +50,13 @@ test-integration: @echo "Running integration tests..." go test -v -p 1 -tags=integration ./internal/store/postgres/... +# Run smoke tests against the live compose stack. +# Starts eye-dev (and its deps postgres + migrate) automatically via depends_on. +# API keys come from deploy/secrets.env. Binance always runs; CoinGecko/Moralis need keys. +test-smoke: + $(COMPOSE) -f $(COMPOSE_FILE) --profile default --profile test run --rm \ + eye-test go test -v -p 1 -tags=smoke -timeout 120s ./test/smoke/... + # Atlas: apply schema to dev database (uses compose migrate service — no env vars needed) schema-apply: $(COMPOSE) -f $(COMPOSE_FILE) run --rm migrate diff --git a/internal/api/v1/apiv1connect/automation.connect.go b/api/v1/apiv1connect/automation.connect.go similarity index 99% rename from internal/api/v1/apiv1connect/automation.connect.go rename to api/v1/apiv1connect/automation.connect.go index b4a8b5c..489ac96 100644 --- a/internal/api/v1/apiv1connect/automation.connect.go +++ b/api/v1/apiv1connect/automation.connect.go @@ -8,7 +8,7 @@ import ( connect "connectrpc.com/connect" context "context" errors "errors" - v1 "github.com/foxcool/greedy-eye/internal/api/v1" + v1 "github.com/foxcool/greedy-eye/api/v1" emptypb "google.golang.org/protobuf/types/known/emptypb" http "net/http" strings "strings" diff --git a/internal/api/v1/apiv1connect/marketdata.connect.go b/api/v1/apiv1connect/marketdata.connect.go similarity index 99% rename from internal/api/v1/apiv1connect/marketdata.connect.go rename to api/v1/apiv1connect/marketdata.connect.go index 8c4bfb2..538a621 100644 --- a/internal/api/v1/apiv1connect/marketdata.connect.go +++ b/api/v1/apiv1connect/marketdata.connect.go @@ -8,7 +8,7 @@ import ( connect "connectrpc.com/connect" context "context" errors "errors" - v1 "github.com/foxcool/greedy-eye/internal/api/v1" + v1 "github.com/foxcool/greedy-eye/api/v1" emptypb "google.golang.org/protobuf/types/known/emptypb" http "net/http" strings "strings" diff --git a/internal/api/v1/apiv1connect/portfolio.connect.go b/api/v1/apiv1connect/portfolio.connect.go similarity index 99% rename from internal/api/v1/apiv1connect/portfolio.connect.go rename to api/v1/apiv1connect/portfolio.connect.go index 37e7f92..e6d583b 100644 --- a/internal/api/v1/apiv1connect/portfolio.connect.go +++ b/api/v1/apiv1connect/portfolio.connect.go @@ -8,7 +8,7 @@ import ( connect "connectrpc.com/connect" context "context" errors "errors" - v1 "github.com/foxcool/greedy-eye/internal/api/v1" + v1 "github.com/foxcool/greedy-eye/api/v1" emptypb "google.golang.org/protobuf/types/known/emptypb" http "net/http" strings "strings" diff --git a/internal/api/v1/automation.pb.go b/api/v1/automation.pb.go similarity index 99% rename from internal/api/v1/automation.pb.go rename to api/v1/automation.pb.go index 7d06939..c8c332c 100644 --- a/internal/api/v1/automation.pb.go +++ b/api/v1/automation.pb.go @@ -2748,9 +2748,9 @@ const file_v1_automation_proto_rawDesc = "" + "\x13CreateRuleExecution\x12\".eye.v1.CreateRuleExecutionRequest\x1a\x15.eye.v1.RuleExecution\"/\x82\xd3\xe4\x93\x02):\x0erule_execution\"\x17/api/v1/rule-executions\x12p\n" + "\x10GetRuleExecution\x12\x1f.eye.v1.GetRuleExecutionRequest\x1a\x15.eye.v1.RuleExecution\"$\x82\xd3\xe4\x93\x02\x1e\x12\x1c/api/v1/rule-executions/{id}\x12\x95\x01\n" + "\x13UpdateRuleExecution\x12\".eye.v1.UpdateRuleExecutionRequest\x1a\x15.eye.v1.RuleExecution\"C\x82\xd3\xe4\x93\x02=:\x0erule_execution\x1a+/api/v1/rule-executions/{rule_execution.id}\x12|\n" + - "\x12ListRuleExecutions\x12!.eye.v1.ListRuleExecutionsRequest\x1a\".eye.v1.ListRuleExecutionsResponse\"\x1f\x82\xd3\xe4\x93\x02\x19\x12\x17/api/v1/rule-executionsB\x8b\x01\n" + + "\x12ListRuleExecutions\x12!.eye.v1.ListRuleExecutionsRequest\x1a\".eye.v1.ListRuleExecutionsResponse\"\x1f\x82\xd3\xe4\x93\x02\x19\x12\x17/api/v1/rule-executionsB\x82\x01\n" + "\n" + - "com.eye.v1B\x0fAutomationProtoP\x01Z3github.com/foxcool/greedy-eye/internal/api/v1;apiv1\xa2\x02\x03EXX\xaa\x02\x06Eye.V1\xca\x02\x06Eye\\V1\xe2\x02\x12Eye\\V1\\GPBMetadata\xea\x02\aEye::V1b\x06proto3" + "com.eye.v1B\x0fAutomationProtoP\x01Z*github.com/foxcool/greedy-eye/api/v1;apiv1\xa2\x02\x03EXX\xaa\x02\x06Eye.V1\xca\x02\x06Eye\\V1\xe2\x02\x12Eye\\V1\\GPBMetadata\xea\x02\aEye::V1b\x06proto3" var ( file_v1_automation_proto_rawDescOnce sync.Once diff --git a/api/v1/automation.proto b/api/v1/automation.proto index 25c83bd..26dd85f 100644 --- a/api/v1/automation.proto +++ b/api/v1/automation.proto @@ -8,7 +8,7 @@ import "google/protobuf/field_mask.proto"; import "google/protobuf/struct.proto"; import "google/api/annotations.proto"; -option go_package = "github.com/foxcool/greedy-eye/internal/api/v1;apiv1"; +option go_package = "github.com/foxcool/greedy-eye/api/v1;apiv1"; // ============================================================================= // TYPES diff --git a/internal/api/v1/marketdata.pb.go b/api/v1/marketdata.pb.go similarity index 97% rename from internal/api/v1/marketdata.pb.go rename to api/v1/marketdata.pb.go index 9c13583..2fe4414 100644 --- a/internal/api/v1/marketdata.pb.go +++ b/api/v1/marketdata.pb.go @@ -191,13 +191,14 @@ type Price struct { // Examples: "1m", "5m", "1h", "4h", "1d", "tick", "latest". Interval string `protobuf:"bytes,5,opt,name=interval,proto3" json:"interval,omitempty"` Decimals uint32 `protobuf:"varint,6,opt,name=decimals,proto3" json:"decimals,omitempty"` - Last int64 `protobuf:"varint,7,opt,name=last,proto3" json:"last,omitempty"` + // Prices are raw integers scaled by `decimals`, sent as decimal strings (precision-safe). + Last string `protobuf:"bytes,7,opt,name=last,proto3" json:"last,omitempty"` // OHLCV data - applicable when 'interval' represents a standard candle type. - Open *int64 `protobuf:"varint,8,opt,name=open,proto3,oneof" json:"open,omitempty"` - High *int64 `protobuf:"varint,9,opt,name=high,proto3,oneof" json:"high,omitempty"` - Low *int64 `protobuf:"varint,10,opt,name=low,proto3,oneof" json:"low,omitempty"` - Close *int64 `protobuf:"varint,11,opt,name=close,proto3,oneof" json:"close,omitempty"` - Volume *int64 `protobuf:"varint,12,opt,name=volume,proto3,oneof" json:"volume,omitempty"` + Open *string `protobuf:"bytes,8,opt,name=open,proto3,oneof" json:"open,omitempty"` + High *string `protobuf:"bytes,9,opt,name=high,proto3,oneof" json:"high,omitempty"` + Low *string `protobuf:"bytes,10,opt,name=low,proto3,oneof" json:"low,omitempty"` + Close *string `protobuf:"bytes,11,opt,name=close,proto3,oneof" json:"close,omitempty"` + Volume *string `protobuf:"bytes,12,opt,name=volume,proto3,oneof" json:"volume,omitempty"` Timestamp *timestamppb.Timestamp `protobuf:"bytes,13,opt,name=timestamp,proto3" json:"timestamp,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -275,46 +276,46 @@ func (x *Price) GetDecimals() uint32 { return 0 } -func (x *Price) GetLast() int64 { +func (x *Price) GetLast() string { if x != nil { return x.Last } - return 0 + return "" } -func (x *Price) GetOpen() int64 { +func (x *Price) GetOpen() string { if x != nil && x.Open != nil { return *x.Open } - return 0 + return "" } -func (x *Price) GetHigh() int64 { +func (x *Price) GetHigh() string { if x != nil && x.High != nil { return *x.High } - return 0 + return "" } -func (x *Price) GetLow() int64 { +func (x *Price) GetLow() string { if x != nil && x.Low != nil { return *x.Low } - return 0 + return "" } -func (x *Price) GetClose() int64 { +func (x *Price) GetClose() string { if x != nil && x.Close != nil { return *x.Close } - return 0 + return "" } -func (x *Price) GetVolume() int64 { +func (x *Price) GetVolume() string { if x != nil && x.Volume != nil { return *x.Volume } - return 0 + return "" } func (x *Price) GetTimestamp() *timestamppb.Timestamp { @@ -1415,13 +1416,13 @@ const file_v1_marketdata_proto_rawDesc = "" + "\rbase_asset_id\x18\x04 \x01(\tR\vbaseAssetId\x12\x1a\n" + "\binterval\x18\x05 \x01(\tR\binterval\x12\x1a\n" + "\bdecimals\x18\x06 \x01(\rR\bdecimals\x12\x12\n" + - "\x04last\x18\a \x01(\x03R\x04last\x12\x17\n" + - "\x04open\x18\b \x01(\x03H\x00R\x04open\x88\x01\x01\x12\x17\n" + - "\x04high\x18\t \x01(\x03H\x01R\x04high\x88\x01\x01\x12\x15\n" + + "\x04last\x18\a \x01(\tR\x04last\x12\x17\n" + + "\x04open\x18\b \x01(\tH\x00R\x04open\x88\x01\x01\x12\x17\n" + + "\x04high\x18\t \x01(\tH\x01R\x04high\x88\x01\x01\x12\x15\n" + "\x03low\x18\n" + - " \x01(\x03H\x02R\x03low\x88\x01\x01\x12\x19\n" + - "\x05close\x18\v \x01(\x03H\x03R\x05close\x88\x01\x01\x12\x1b\n" + - "\x06volume\x18\f \x01(\x03H\x04R\x06volume\x88\x01\x01\x128\n" + + " \x01(\tH\x02R\x03low\x88\x01\x01\x12\x19\n" + + "\x05close\x18\v \x01(\tH\x03R\x05close\x88\x01\x01\x12\x1b\n" + + "\x06volume\x18\f \x01(\tH\x04R\x06volume\x88\x01\x01\x128\n" + "\ttimestamp\x18\r \x01(\v2\x1a.google.protobuf.TimestampR\ttimestampB\a\n" + "\x05_openB\a\n" + "\x05_highB\x06\n" + @@ -1549,9 +1550,9 @@ const file_v1_marketdata_proto_rawDesc = "" + "\x14ListPricesByInterval\x12#.eye.v1.ListPricesByIntervalRequest\x1a .eye.v1.ListPriceHistoryResponse\";\x82\xd3\xe4\x93\x025\x123/api/v1/prices/{asset_id}/{base_asset_id}/intervals\x12^\n" + "\vDeletePrice\x12\x1a.eye.v1.DeletePriceRequest\x1a\x16.google.protobuf.Empty\"\x1b\x82\xd3\xe4\x93\x02\x15*\x13/api/v1/prices/{id}\x12[\n" + "\fDeletePrices\x12\x1b.eye.v1.DeletePricesRequest\x1a\x16.google.protobuf.Empty\"\x16\x82\xd3\xe4\x93\x02\x10*\x0e/api/v1/prices\x12\x88\x01\n" + - "\x13FetchExternalPrices\x12\".eye.v1.FetchExternalPricesRequest\x1a#.eye.v1.FetchExternalPricesResponse\"(\x82\xd3\xe4\x93\x02\":\x01*\"\x1d/api/v1/prices/fetch-externalB\x8b\x01\n" + + "\x13FetchExternalPrices\x12\".eye.v1.FetchExternalPricesRequest\x1a#.eye.v1.FetchExternalPricesResponse\"(\x82\xd3\xe4\x93\x02\":\x01*\"\x1d/api/v1/prices/fetch-externalB\x82\x01\n" + "\n" + - "com.eye.v1B\x0fMarketdataProtoP\x01Z3github.com/foxcool/greedy-eye/internal/api/v1;apiv1\xa2\x02\x03EXX\xaa\x02\x06Eye.V1\xca\x02\x06Eye\\V1\xe2\x02\x12Eye\\V1\\GPBMetadata\xea\x02\aEye::V1b\x06proto3" + "com.eye.v1B\x0fMarketdataProtoP\x01Z*github.com/foxcool/greedy-eye/api/v1;apiv1\xa2\x02\x03EXX\xaa\x02\x06Eye.V1\xca\x02\x06Eye\\V1\xe2\x02\x12Eye\\V1\\GPBMetadata\xea\x02\aEye::V1b\x06proto3" var ( file_v1_marketdata_proto_rawDescOnce sync.Once diff --git a/api/v1/marketdata.proto b/api/v1/marketdata.proto index 7ef2f32..06097c0 100644 --- a/api/v1/marketdata.proto +++ b/api/v1/marketdata.proto @@ -7,7 +7,7 @@ import "google/protobuf/timestamp.proto"; import "google/protobuf/field_mask.proto"; import "google/api/annotations.proto"; -option go_package = "github.com/foxcool/greedy-eye/internal/api/v1;apiv1"; +option go_package = "github.com/foxcool/greedy-eye/api/v1;apiv1"; // ============================================================================= // TYPES @@ -45,13 +45,14 @@ message Price { // Examples: "1m", "5m", "1h", "4h", "1d", "tick", "latest". string interval = 5; uint32 decimals = 6; - int64 last = 7; + // Prices are raw integers scaled by `decimals`, sent as decimal strings (precision-safe). + string last = 7; // OHLCV data - applicable when 'interval' represents a standard candle type. - optional int64 open = 8; - optional int64 high = 9; - optional int64 low = 10; - optional int64 close = 11; - optional int64 volume = 12; + optional string open = 8; + optional string high = 9; + optional string low = 10; + optional string close = 11; + optional string volume = 12; google.protobuf.Timestamp timestamp = 13; } diff --git a/internal/api/v1/portfolio.pb.go b/api/v1/portfolio.pb.go similarity index 96% rename from internal/api/v1/portfolio.pb.go rename to api/v1/portfolio.pb.go index 859a1ea..55769a0 100644 --- a/internal/api/v1/portfolio.pb.go +++ b/api/v1/portfolio.pb.go @@ -292,15 +292,19 @@ func (x *Portfolio) GetUpdatedAt() *timestamppb.Timestamp { // Holding represents a specific quantity of an Asset held within an Account. type Holding struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - Amount int64 `protobuf:"varint,2,opt,name=amount,proto3" json:"amount,omitempty"` - Decimals uint32 `protobuf:"varint,3,opt,name=decimals,proto3" json:"decimals,omitempty"` - AssetId string `protobuf:"bytes,4,opt,name=asset_id,json=assetId,proto3" json:"asset_id,omitempty"` - AccountId string `protobuf:"bytes,5,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` - PortfolioId *string `protobuf:"bytes,6,opt,name=portfolio_id,json=portfolioId,proto3,oneof" json:"portfolio_id,omitempty"` - CreatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` - UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // Raw integer balance as a decimal string (scaled by `decimals`). + // String because uint256 on-chain balances overflow int64. + Amount string `protobuf:"bytes,2,opt,name=amount,proto3" json:"amount,omitempty"` + Decimals uint32 `protobuf:"varint,3,opt,name=decimals,proto3" json:"decimals,omitempty"` + AssetId string `protobuf:"bytes,4,opt,name=asset_id,json=assetId,proto3" json:"asset_id,omitempty"` + AccountId string `protobuf:"bytes,5,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` + PortfolioId *string `protobuf:"bytes,6,opt,name=portfolio_id,json=portfolioId,proto3,oneof" json:"portfolio_id,omitempty"` + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + // If true, explicitly excluded from all portfolio calculations (e.g. spam/scam tokens). + Excluded bool `protobuf:"varint,9,opt,name=excluded,proto3" json:"excluded,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -342,11 +346,11 @@ func (x *Holding) GetId() string { return "" } -func (x *Holding) GetAmount() int64 { +func (x *Holding) GetAmount() string { if x != nil { return x.Amount } - return 0 + return "" } func (x *Holding) GetDecimals() uint32 { @@ -391,6 +395,13 @@ func (x *Holding) GetUpdatedAt() *timestamppb.Timestamp { return nil } +func (x *Holding) GetExcluded() bool { + if x != nil { + return x.Excluded + } + return false +} + // Account represents a user's connection to an external financial entity. type Account struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -891,8 +902,9 @@ func (x *ListPortfoliosResponse) GetNextPageToken() string { } type CalculatePortfolioValueRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - PortfolioId string `protobuf:"bytes,1,opt,name=portfolio_id,json=portfolioId,proto3" json:"portfolio_id,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + PortfolioId string `protobuf:"bytes,1,opt,name=portfolio_id,json=portfolioId,proto3" json:"portfolio_id,omitempty"` + // Quote currency: either an asset UUID or a ticker symbol (e.g. "USD"). Defaults to USD. QuoteAssetId string `protobuf:"bytes,2,opt,name=quote_asset_id,json=quoteAssetId,proto3" json:"quote_asset_id,omitempty"` AtTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=at_time,json=atTime,proto3" json:"at_time,omitempty"` unknownFields protoimpl.UnknownFields @@ -951,10 +963,12 @@ func (x *CalculatePortfolioValueRequest) GetAtTime() *timestamppb.Timestamp { } type PortfolioValueResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - PortfolioId string `protobuf:"bytes,1,opt,name=portfolio_id,json=portfolioId,proto3" json:"portfolio_id,omitempty"` - QuoteAssetId string `protobuf:"bytes,2,opt,name=quote_asset_id,json=quoteAssetId,proto3" json:"quote_asset_id,omitempty"` - TotalValueAmount int64 `protobuf:"varint,3,opt,name=total_value_amount,json=totalValueAmount,proto3" json:"total_value_amount,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + PortfolioId string `protobuf:"bytes,1,opt,name=portfolio_id,json=portfolioId,proto3" json:"portfolio_id,omitempty"` + // Echoes the requested quote (UUID or symbol) used to value the portfolio. + QuoteAssetId string `protobuf:"bytes,2,opt,name=quote_asset_id,json=quoteAssetId,proto3" json:"quote_asset_id,omitempty"` + // Total value as a raw integer decimal string (scaled by `decimals`). + TotalValueAmount string `protobuf:"bytes,3,opt,name=total_value_amount,json=totalValueAmount,proto3" json:"total_value_amount,omitempty"` Decimals uint32 `protobuf:"varint,4,opt,name=decimals,proto3" json:"decimals,omitempty"` CalculationTime *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=calculation_time,json=calculationTime,proto3" json:"calculation_time,omitempty"` unknownFields protoimpl.UnknownFields @@ -1005,11 +1019,11 @@ func (x *PortfolioValueResponse) GetQuoteAssetId() string { return "" } -func (x *PortfolioValueResponse) GetTotalValueAmount() int64 { +func (x *PortfolioValueResponse) GetTotalValueAmount() string { if x != nil { return x.TotalValueAmount } - return 0 + return "" } func (x *PortfolioValueResponse) GetDecimals() uint32 { @@ -2148,10 +2162,10 @@ const file_v1_portfolio_proto_rawDesc = "" + "\tDataEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12*\n" + "\x05value\x18\x02 \x01(\v2\x14.google.protobuf.AnyR\x05value:\x028\x01B\x0e\n" + - "\f_description\"\xb6\x02\n" + + "\f_description\"\xd2\x02\n" + "\aHolding\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x16\n" + - "\x06amount\x18\x02 \x01(\x03R\x06amount\x12\x1a\n" + + "\x06amount\x18\x02 \x01(\tR\x06amount\x12\x1a\n" + "\bdecimals\x18\x03 \x01(\rR\bdecimals\x12\x19\n" + "\basset_id\x18\x04 \x01(\tR\aassetId\x12\x1d\n" + "\n" + @@ -2160,7 +2174,8 @@ const file_v1_portfolio_proto_rawDesc = "" + "\n" + "created_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" + "\n" + - "updated_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAtB\x0f\n" + + "updated_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12\x1a\n" + + "\bexcluded\x18\t \x01(\bR\bexcludedB\x0f\n" + "\r_portfolio_id\"\xbd\x03\n" + "\aAccount\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x17\n" + @@ -2225,7 +2240,7 @@ const file_v1_portfolio_proto_rawDesc = "" + "\x16PortfolioValueResponse\x12!\n" + "\fportfolio_id\x18\x01 \x01(\tR\vportfolioId\x12$\n" + "\x0equote_asset_id\x18\x02 \x01(\tR\fquoteAssetId\x12,\n" + - "\x12total_value_amount\x18\x03 \x01(\x03R\x10totalValueAmount\x12\x1a\n" + + "\x12total_value_amount\x18\x03 \x01(\tR\x10totalValueAmount\x12\x1a\n" + "\bdecimals\x18\x04 \x01(\rR\bdecimals\x12E\n" + "\x10calculation_time\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\x0fcalculationTime\"\xcd\x01\n" + "\x1eGetPortfolioPerformanceRequest\x12!\n" + @@ -2371,9 +2386,9 @@ const file_v1_portfolio_proto_rawDesc = "" + "\x0eGetTransaction\x12\x1d.eye.v1.GetTransactionRequest\x1a\x13.eye.v1.Transaction\"!\x82\xd3\xe4\x93\x02\x1b\x12\x19/api/v1/transactions/{id}\x12\x86\x01\n" + "\x11UpdateTransaction\x12 .eye.v1.UpdateTransactionRequest\x1a\x13.eye.v1.Transaction\":\x82\xd3\xe4\x93\x024:\vtransaction\x1a%/api/v1/transactions/{transaction.id}\x12s\n" + "\x10ListTransactions\x12\x1f.eye.v1.ListTransactionsRequest\x1a .eye.v1.ListTransactionsResponse\"\x1c\x82\xd3\xe4\x93\x02\x16\x12\x14/api/v1/transactions\x12r\n" + - "\vSyncAccount\x12\x1a.eye.v1.SyncAccountRequest\x1a\x1b.eye.v1.SyncAccountResponse\"*\x82\xd3\xe4\x93\x02$\"\"/api/v1/accounts/{account_id}/syncB\x8a\x01\n" + + "\vSyncAccount\x12\x1a.eye.v1.SyncAccountRequest\x1a\x1b.eye.v1.SyncAccountResponse\"*\x82\xd3\xe4\x93\x02$\"\"/api/v1/accounts/{account_id}/syncB\x81\x01\n" + "\n" + - "com.eye.v1B\x0ePortfolioProtoP\x01Z3github.com/foxcool/greedy-eye/internal/api/v1;apiv1\xa2\x02\x03EXX\xaa\x02\x06Eye.V1\xca\x02\x06Eye\\V1\xe2\x02\x12Eye\\V1\\GPBMetadata\xea\x02\aEye::V1b\x06proto3" + "com.eye.v1B\x0ePortfolioProtoP\x01Z*github.com/foxcool/greedy-eye/api/v1;apiv1\xa2\x02\x03EXX\xaa\x02\x06Eye.V1\xca\x02\x06Eye\\V1\xe2\x02\x12Eye\\V1\\GPBMetadata\xea\x02\aEye::V1b\x06proto3" var ( file_v1_portfolio_proto_rawDescOnce sync.Once diff --git a/api/v1/portfolio.proto b/api/v1/portfolio.proto index 3d210f2..531ba28 100644 --- a/api/v1/portfolio.proto +++ b/api/v1/portfolio.proto @@ -8,7 +8,7 @@ import "google/protobuf/timestamp.proto"; import "google/protobuf/field_mask.proto"; import "google/api/annotations.proto"; -option go_package = "github.com/foxcool/greedy-eye/internal/api/v1;apiv1"; +option go_package = "github.com/foxcool/greedy-eye/api/v1;apiv1"; // ============================================================================= // TYPES @@ -54,13 +54,17 @@ message Portfolio { // Holding represents a specific quantity of an Asset held within an Account. message Holding { string id = 1; - int64 amount = 2; + // Raw integer balance as a decimal string (scaled by `decimals`). + // String because uint256 on-chain balances overflow int64. + string amount = 2; uint32 decimals = 3; string asset_id = 4; string account_id = 5; optional string portfolio_id = 6; google.protobuf.Timestamp created_at = 7; google.protobuf.Timestamp updated_at = 8; + // If true, explicitly excluded from all portfolio calculations (e.g. spam/scam tokens). + bool excluded = 9; } // Account represents a user's connection to an external financial entity. @@ -271,14 +275,17 @@ message ListPortfoliosResponse { message CalculatePortfolioValueRequest { string portfolio_id = 1; + // Quote currency: either an asset UUID or a ticker symbol (e.g. "USD"). Defaults to USD. string quote_asset_id = 2; google.protobuf.Timestamp at_time = 3; } message PortfolioValueResponse { string portfolio_id = 1; + // Echoes the requested quote (UUID or symbol) used to value the portfolio. string quote_asset_id = 2; - int64 total_value_amount = 3; + // Total value as a raw integer decimal string (scaled by `decimals`). + string total_value_amount = 3; uint32 decimals = 4; google.protobuf.Timestamp calculation_time = 5; } diff --git a/buf.gen.yaml b/buf.gen.yaml index 8bb513d..eddbbeb 100644 --- a/buf.gen.yaml +++ b/buf.gen.yaml @@ -3,8 +3,8 @@ managed: enabled: true plugins: - local: protoc-gen-go - out: internal/api + out: api opt: paths=source_relative - local: protoc-gen-connect-go - out: internal/api + out: api opt: paths=source_relative diff --git a/cmd/eye/main.go b/cmd/eye/main.go index e9ea687..073c54d 100644 --- a/cmd/eye/main.go +++ b/cmd/eye/main.go @@ -12,10 +12,10 @@ import ( "time" "connectrpc.com/connect" + "github.com/foxcool/greedy-eye/api/v1/apiv1connect" binanceadapter "github.com/foxcool/greedy-eye/internal/adapter/binance" "github.com/foxcool/greedy-eye/internal/adapter/coingecko" moralisadapter "github.com/foxcool/greedy-eye/internal/adapter/moralis" - "github.com/foxcool/greedy-eye/internal/api/v1/apiv1connect" "github.com/foxcool/greedy-eye/internal/entity" "github.com/foxcool/greedy-eye/internal/middleware" "github.com/foxcool/greedy-eye/internal/service/automation" @@ -23,7 +23,6 @@ import ( "github.com/foxcool/greedy-eye/internal/service/portfolio" "github.com/foxcool/greedy-eye/internal/store/postgres" "github.com/getsentry/sentry-go" - "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" ) @@ -61,7 +60,7 @@ func run() error { return fmt.Errorf("database URL cannot be empty") } - pool, err := pgxpool.New(context.Background(), config.DB.URL) + pool, err := postgres.NewPool(context.Background(), config.DB.URL) if err != nil { return fmt.Errorf("connect to database: %w", err) } @@ -85,9 +84,10 @@ func run() error { } }) - // Initialize optional CoinGecko provider + mdStore := postgres.NewMarketDataStore(pool) + + // Initialize price providers. cgClient := coingecko.NewClient(coingecko.Config{APIKey: config.CoinGecko.APIKey, Pro: config.CoinGecko.Pro}) - cgProvider := coingecko.NewProvider(cgClient) // Initialize optional Moralis wallet syncer var walletSyncer entity.WalletSyncer @@ -102,8 +102,8 @@ func run() error { middleware.UserProvisioningInterceptor(userStore, log), loggingInterceptor(log), ) - mdHandler := marketdata.NewHandler(postgres.NewMarketDataStore(pool), log). - WithProvider(coingecko.ProviderName, cgProvider). + mdHandler := marketdata.NewHandler(mdStore, log). + WithProvider(coingecko.ProviderName, coingecko.NewProvider(cgClient)). WithProvider(binanceadapter.ProviderName, binanceadapter.NewProvider( binanceadapter.NewClient(binanceadapter.Config{ APIKey: config.Binance.APIKey, @@ -188,17 +188,20 @@ func createLogger(level string) *slog.Logger { return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel})) } - func loggingInterceptor(log *slog.Logger) connect.UnaryInterceptorFunc { return func(next connect.UnaryFunc) connect.UnaryFunc { return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { start := time.Now() resp, err := next(ctx, req) - log.Info("request", + attrs := []any{ slog.String("procedure", req.Spec().Procedure), slog.Duration("duration", time.Since(start)), slog.Bool("error", err != nil), - ) + } + if err != nil { + attrs = append(attrs, slog.String("error_msg", err.Error())) + } + log.Info("request", attrs...) return resp, err } } diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 610993b..e269d7d 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -31,6 +31,12 @@ services: EYE_LOGGING_LEVEL: INFO EYE_SENTRY_ENVIRONMENT: "development" EYE_DB_URL: "postgres://greedy_eye:password@postgres:5432/greedy_eye?sslmode=disable" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8080/eye/health || exit 1"] + interval: 5s + timeout: 3s + retries: 12 + start_period: 15s labels: - "traefik.enable=true" # Connect-RPC / gRPC routes @@ -158,13 +164,8 @@ services: working_dir: /go/src/github.com/foxcool/greedy-eye env_file: secrets.env environment: - EYE_LOGGING_LEVEL: INFO - EYE_SENTRY_ENVIRONMENT: "test" EYE_DB_URL: "postgres://greedy_eye:password@postgres:5432/greedy_eye?sslmode=disable" - # Flag to indicate running in test environment - DOCKER_COMPOSE_TEST: "true" + SMOKE_BACKEND_URL: "http://eye-dev:8080" depends_on: - postgres: + eye-dev: condition: service_healthy - migrate: - condition: service_completed_successfully diff --git a/go.mod b/go.mod index 042e490..e8facfc 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( connectrpc.com/connect v1.19.1 github.com/getsentry/sentry-go v0.29.0 github.com/google/uuid v1.6.0 + github.com/jackc/pgx-shopspring-decimal v0.0.0-20220624020537-1d36b5a1853e github.com/jackc/pgx/v5 v5.8.0 github.com/knadh/koanf v1.5.0 github.com/shopspring/decimal v1.4.0 diff --git a/go.sum b/go.sum index f53da8f..da7024d 100644 --- a/go.sum +++ b/go.sum @@ -188,6 +188,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx-shopspring-decimal v0.0.0-20220624020537-1d36b5a1853e h1:i3gQ/Zo7sk4LUVbsAjTNeC4gIjoPNIZVzs4EXstssV4= +github.com/jackc/pgx-shopspring-decimal v0.0.0-20220624020537-1d36b5a1853e/go.mod h1:zUHglCZ4mpDUPgIwqEKoba6+tcUQzRdb1+DPTuYe9pI= github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= diff --git a/internal/adapter/binance/client.go b/internal/adapter/binance/client.go index 97a696d..be1e66a 100644 --- a/internal/adapter/binance/client.go +++ b/internal/adapter/binance/client.go @@ -43,17 +43,17 @@ type Balance struct { // Order represents a trading order type Order struct { - OrderID string - Symbol string - Side string // BUY, SELL - Type string // MARKET, LIMIT - Price float64 - Quantity float64 - ExecutedQty float64 - Status string - TimeInForce string - CreatedAt time.Time - UpdatedAt time.Time + OrderID string + Symbol string + Side string // BUY, SELL + Type string // MARKET, LIMIT + Price float64 + Quantity float64 + ExecutedQty float64 + Status string + TimeInForce string + CreatedAt time.Time + UpdatedAt time.Time } // Trade represents a completed trade diff --git a/internal/adapter/binance/price_provider.go b/internal/adapter/binance/price_provider.go index 61bd500..8ecdd5a 100644 --- a/internal/adapter/binance/price_provider.go +++ b/internal/adapter/binance/price_provider.go @@ -3,12 +3,11 @@ package binance import ( "context" "fmt" - "math" - "strconv" "strings" "time" "github.com/foxcool/greedy-eye/internal/entity" + "github.com/shopspring/decimal" ) const ( @@ -16,9 +15,7 @@ const ( ProviderName = "binance" sourceID = ProviderName - baseAssetID = "usdt" priceDecimals = uint32(8) - divisor = 1e8 interval = "latest" ) @@ -32,6 +29,12 @@ func NewProvider(client *Client) *Provider { return &Provider{client: client} } +// BaseAssetSymbol returns the ticker of the quote currency used by Binance ("USDT"). +func (p *Provider) BaseAssetSymbol() string { return "USDT" } + +// BaseAssetType reports that Binance's quote currency (USDT) is a cryptocurrency stablecoin. +func (p *Provider) BaseAssetType() entity.AssetType { return entity.AssetTypeCryptocurrency } + // FetchPrices fetches current prices from Binance for the given assets. // Binance symbols are derived from asset symbols as UPPER(symbol)+"USDT" // (e.g., asset.Symbol="BTC" → "BTCUSDT"). @@ -62,19 +65,20 @@ func (p *Provider) FetchPrices(ctx context.Context, assets []*entity.Asset) ([]e if !ok { continue } - price, err := strconv.ParseFloat(t.Price, 64) + price, err := decimal.NewFromString(t.Price) if err != nil { continue } - last := int64(math.Round(price * divisor)) + // Store as a raw integer scaled by priceDecimals (value = last / 10^priceDecimals). + last := price.Shift(int32(priceDecimals)).Round(0) result = append(result, entity.StoredPrice{ - SourceID: sourceID, - AssetID: assetID, - BaseAssetID: baseAssetID, - Interval: interval, - Decimals: priceDecimals, - Last: last, - Timestamp: now, + SourceID: sourceID, + AssetID: assetID, + // BaseAssetID is intentionally empty — resolved by FetchExternalPrices handler. + Interval: interval, + Decimals: priceDecimals, + Last: last, + Timestamp: now, }) } return result, nil diff --git a/internal/adapter/binance/price_provider_test.go b/internal/adapter/binance/price_provider_test.go index 5eeb1cf..130a5e5 100644 --- a/internal/adapter/binance/price_provider_test.go +++ b/internal/adapter/binance/price_provider_test.go @@ -45,16 +45,16 @@ func TestFetchPrices_OK(t *testing.T) { require.NoError(t, err) require.Len(t, prices, 2) - byAsset := make(map[string]int64, 2) + byAsset := make(map[string]string, 2) for _, sp := range prices { assert.Equal(t, "binance", sp.SourceID) - assert.Equal(t, "usdt", sp.BaseAssetID) + assert.Empty(t, sp.BaseAssetID) // resolved by handler, not provider assert.Equal(t, "latest", sp.Interval) assert.Equal(t, uint32(8), sp.Decimals) - byAsset[sp.AssetID] = sp.Last + byAsset[sp.AssetID] = sp.Last.String() } - assert.Equal(t, int64(6700012345678), byAsset["uuid-btc"]) - assert.Equal(t, int64(350000000000), byAsset["uuid-eth"]) + assert.Equal(t, "6700012345678", byAsset["uuid-btc"]) + assert.Equal(t, "350000000000", byAsset["uuid-eth"]) } func TestFetchPrices_EmptyAssets(t *testing.T) { diff --git a/internal/adapter/coingecko/client.go b/internal/adapter/coingecko/client.go index 4781fbf..d6b7eeb 100644 --- a/internal/adapter/coingecko/client.go +++ b/internal/adapter/coingecko/client.go @@ -11,9 +11,9 @@ import ( // Client implements PriceProvider interface for CoinGecko type Client struct { - apiKey string - baseURL string - rateLimit time.Duration + apiKey string + baseURL string + rateLimit time.Duration httpClient *http.Client } @@ -55,24 +55,24 @@ func NewClient(cfg Config) *Client { } return &Client{ - apiKey: cfg.APIKey, - baseURL: baseURL, - rateLimit: rateLimit, + apiKey: cfg.APIKey, + baseURL: baseURL, + rateLimit: rateLimit, httpClient: &http.Client{Timeout: 30 * time.Second}, } } // coingeckoMarketItem is the JSON shape from /coins/markets endpoint. type coingeckoMarketItem struct { - ID string `json:"id"` - Symbol string `json:"symbol"` - CurrentPrice float64 `json:"current_price"` - MarketCap float64 `json:"market_cap"` - TotalVolume float64 `json:"total_volume"` - PriceChange24h float64 `json:"price_change_24h"` - PriceChangePct24h float64 `json:"price_change_percentage_24h"` - High24h float64 `json:"high_24h"` - Low24h float64 `json:"low_24h"` + ID string `json:"id"` + Symbol string `json:"symbol"` + CurrentPrice float64 `json:"current_price"` + MarketCap float64 `json:"market_cap"` + TotalVolume float64 `json:"total_volume"` + PriceChange24h float64 `json:"price_change_24h"` + PriceChangePct24h float64 `json:"price_change_percentage_24h"` + High24h float64 `json:"high_24h"` + Low24h float64 `json:"low_24h"` } // GetMultiplePrices retrieves current prices for multiple assets. @@ -131,6 +131,76 @@ func (c *Client) GetMultiplePrices(ctx context.Context, assetIDs []string, curre return result, nil } +// GetTokenPricesByContract retrieves prices for ERC-20 tokens by their contract addresses. +// platform is the CoinGecko platform ID, e.g. "ethereum", "base", "polygon-pos". +func (c *Client) GetTokenPricesByContract(ctx context.Context, platform string, addresses []string, currency string) (map[string]*PriceData, error) { + if len(addresses) == 0 { + return map[string]*PriceData{}, nil + } + + // CoinGecko supports batching up to 30 addresses per request on free tier. + const batchSize = 30 + result := make(map[string]*PriceData, len(addresses)) + + for i := 0; i < len(addresses); i += batchSize { + end := i + batchSize + if end > len(addresses) { + end = len(addresses) + } + batch := addresses[i:end] + + url := fmt.Sprintf( + "%s/simple/token_price/%s?contract_addresses=%s&vs_currencies=%s&include_24hr_high=true&include_24hr_low=true", + c.baseURL, + platform, + strings.Join(batch, ","), + currency, + ) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + if c.apiKey != "" { + req.Header.Set("x-cg-demo-api-key", c.apiKey) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("do request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + return nil, fmt.Errorf("unexpected status %d from CoinGecko token_price", resp.StatusCode) + } + + // Response: { "0x...": { "usd": 1.23, "usd_24h_high": 1.30, "usd_24h_low": 1.10 }, ... } + var raw map[string]map[string]float64 + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + _ = resp.Body.Close() + return nil, fmt.Errorf("decode response: %w", err) + } + _ = resp.Body.Close() + + now := time.Now() + for addr, prices := range raw { + price := prices[currency] + if price == 0 { + continue + } + result[addr] = &PriceData{ + Price: price, + High24h: prices[currency+"_24h_high"], + Low24h: prices[currency+"_24h_low"], + Timestamp: now, + } + } + } + + return result, nil +} + // GetCurrentPrice retrieves current price for an asset. func (c *Client) GetCurrentPrice(ctx context.Context, assetID string, currency string) (*PriceData, error) { prices, err := c.GetMultiplePrices(ctx, []string{assetID}, currency) diff --git a/internal/adapter/coingecko/provider.go b/internal/adapter/coingecko/provider.go index 3b9779e..0f5f855 100644 --- a/internal/adapter/coingecko/provider.go +++ b/internal/adapter/coingecko/provider.go @@ -3,81 +3,226 @@ package coingecko import ( "context" "fmt" - "math" + "log/slog" "strings" "time" "github.com/foxcool/greedy-eye/internal/entity" + "github.com/shopspring/decimal" ) const ( // ProviderName is the canonical source identifier for CoinGecko prices. ProviderName = "coingecko" - sourceID = ProviderName - baseAssetID = "usd" + sourceID = ProviderName priceDecimals = uint32(8) - divisor = 1e8 - interval = "latest" + interval = "latest" + + // cgQuoteCurrency is the currency code used in CoinGecko API requests. + // Prices are returned in USD; the internal base_asset_id UUID is stored separately. + cgQuoteCurrency = "usd" + + // cgPlatformEVM is the CoinGecko platform ID used for EVM token contract lookups. + // Most ERC-20 tokens on Ethereum, Arbitrum, Base share the same contract addresses + // and are listed under the Ethereum platform on CoinGecko. + cgPlatformEVM = "ethereum" ) +// nativeCoinID maps lowercase asset symbols → CoinGecko coin ID for native/major coins. +// Symbols are not unique on CoinGecko; this covers well-known cases. +var nativeCoinID = map[string]string{ + "eth": "ethereum", + "weth": "weth", + "steth": "staked-ether", + "wsteth": "wrapped-steth", + "reth": "rocket-pool-eth", + "cbeth": "coinbase-wrapped-staked-eth", + "btc": "bitcoin", + "wbtc": "wrapped-bitcoin", + "bnb": "binancecoin", + "matic": "matic-network", + "pol": "polygon-ecosystem-token", + "avax": "avalanche-2", + "sol": "solana", + "ftm": "fantom", + "op": "optimism", + "arb": "arbitrum", + "usdt": "tether", + "usdc": "usd-coin", + "dai": "dai", + "busd": "binance-usd", + "frax": "frax", + "lusd": "liquity-usd", + "tusd": "true-usd", + "usdd": "usdd", + "link": "chainlink", + "uni": "uniswap", + "aave": "aave", + "mkr": "maker", + "comp": "compound-governance-token", + "crv": "curve-dao-token", + "cvx": "convex-finance", + "bal": "balancer", + "sushi": "sushi", + "xsushi": "xsushi", + "1inch": "1inch", + "yfi": "yearn-finance", + "snx": "havven", + "ldo": "lido-dao", + "rpl": "rocket-pool", + "dydx": "dydx", + "imx": "immutable-x", + "grt": "the-graph", + "enj": "enjincoin", + "sand": "the-sandbox", + "mana": "decentraland", + "axs": "axie-infinity", + "chz": "chiliz", + "bat": "basic-attention-token", + "lrc": "loopring", + "zrx": "0x", + "shib": "shiba-inu", + "pepe": "pepe", + "doge": "dogecoin", + "atom": "cosmos", + "dot": "polkadot", + "ada": "cardano", + "trx": "tron", + "xlm": "stellar", + "amb": "amber", +} + // Provider adapts *Client to marketdata.PriceProvider. type Provider struct { client *Client + log *slog.Logger } -// NewProvider wraps a *Client as a price provider. +// NewProvider wraps a *Client as a CoinGecko price provider. func NewProvider(c *Client) *Provider { - return &Provider{client: c} + return &Provider{client: c, log: slog.Default()} } -// FetchPrices fetches prices from CoinGecko and returns them as StoredPrice records. -// CoinGecko coin IDs are derived from asset symbols in lowercase (e.g., "BTC" → "btc"). -// This requires asset symbols to match CoinGecko coin IDs. +// BaseAssetSymbol returns the ticker of the quote currency used by CoinGecko ("USD"). +func (p *Provider) BaseAssetSymbol() string { return "USD" } + +// BaseAssetType reports that CoinGecko's quote currency (USD) is fiat (forex). +func (p *Provider) BaseAssetType() entity.AssetType { return entity.AssetTypeForex } + +// FetchPrices fetches prices from CoinGecko for the given assets. +// +// Strategy: +// 1. Assets with a "contract:0x..." tag → CoinGecko token_price by contract address +// (accurate for ERC-20 tokens whose contract address is on the Ethereum platform) +// 2. Native/well-known coins → CoinGecko coin ID from the hardcoded symbol map +// 3. Unknown symbols without contract addresses → skipped func (p *Provider) FetchPrices(ctx context.Context, assets []*entity.Asset) ([]entity.StoredPrice, error) { if len(assets) == 0 { return nil, nil } - // Build coinID → assetID map and collect CoinGecko IDs. - coinToAsset := make(map[string]string, len(assets)) - coinIDs := make([]string, 0, len(assets)) - for _, a := range assets { - coinID := strings.ToLower(a.Symbol) - coinToAsset[coinID] = a.ID - coinIDs = append(coinIDs, coinID) + // Split assets into two groups. + type contractAsset struct { + id string + address string } + var contractAssets []contractAsset // ERC-20 with known contract address + var nativeAssets []*entity.Asset // native coins looked up by symbol - raw, err := p.client.GetMultiplePrices(ctx, coinIDs, baseAssetID) - if err != nil { - return nil, fmt.Errorf("coingecko: %w", err) + for _, a := range assets { + if addr := contractTag(a.Tags); addr != "" { + contractAssets = append(contractAssets, contractAsset{id: a.ID, address: addr}) + } else if _, ok := nativeCoinID[strings.ToLower(a.Symbol)]; ok { + nativeAssets = append(nativeAssets, a) + } + // else: unknown, skip — no reliable CoinGecko mapping } now := time.Now() - result := make([]entity.StoredPrice, 0, len(raw)) - for coinID, pd := range raw { - assetID, ok := coinToAsset[coinID] - if !ok { - continue + var result []entity.StoredPrice + + // --- Path 1: ERC-20 by contract address --- + if len(contractAssets) > 0 { + addrs := make([]string, len(contractAssets)) + addrToID := make(map[string]string, len(contractAssets)) + for i, ca := range contractAssets { + addrs[i] = ca.address + addrToID[strings.ToLower(ca.address)] = ca.id + } + + pricesByAddr, err := p.client.GetTokenPricesByContract(ctx, cgPlatformEVM, addrs, cgQuoteCurrency) + if err != nil { + // Non-fatal: native coin lookup may still succeed. + p.log.Warn("coingecko contract lookup failed", "error", err) + } else { + for addr, pd := range pricesByAddr { + assetID, ok := addrToID[strings.ToLower(addr)] + if !ok { + continue + } + result = append(result, storedPrice(assetID, pd, now)) + } + } + } + + // --- Path 2: native / well-known coins by CoinGecko ID --- + if len(nativeAssets) > 0 { + coinToAsset := make(map[string]string, len(nativeAssets)) + coinIDs := make([]string, 0, len(nativeAssets)) + for _, a := range nativeAssets { + cgID := nativeCoinID[strings.ToLower(a.Symbol)] + if _, dup := coinToAsset[cgID]; !dup { + coinToAsset[cgID] = a.ID + coinIDs = append(coinIDs, cgID) + } + } + + raw, err := p.client.GetMultiplePrices(ctx, coinIDs, cgQuoteCurrency) + if err != nil { + return result, fmt.Errorf("coingecko native lookup: %w", err) } - last := int64(math.Round(pd.Price * divisor)) - high := int64(math.Round(pd.High24h * divisor)) - low := int64(math.Round(pd.Low24h * divisor)) - ts := pd.Timestamp - if ts.IsZero() { - ts = now + for cgID, pd := range raw { + assetID, ok := coinToAsset[cgID] + if !ok { + continue + } + result = append(result, storedPrice(assetID, pd, now)) } - result = append(result, entity.StoredPrice{ - SourceID: sourceID, - AssetID: assetID, - BaseAssetID: baseAssetID, - Interval: interval, - Decimals: priceDecimals, - Last: last, - High: &high, - Low: &low, - Timestamp: ts, - }) } + return result, nil } + +// contractTag extracts the contract address from a "contract:0x..." tag, or returns "". +func contractTag(tags []string) string { + for _, t := range tags { + if after, ok := strings.CutPrefix(t, "contract:"); ok { + return after + } + } + return "" +} + +// scaled converts a float price to a raw integer scaled by priceDecimals as a decimal. +func scaled(v float64) decimal.Decimal { + return decimal.NewFromFloat(v).Shift(int32(priceDecimals)).Round(0) +} + +func storedPrice(assetID string, pd *PriceData, now time.Time) entity.StoredPrice { + ts := pd.Timestamp + if ts.IsZero() { + ts = now + } + return entity.StoredPrice{ + SourceID: sourceID, + AssetID: assetID, + // BaseAssetID is intentionally empty — resolved by FetchExternalPrices handler. + Interval: interval, + Decimals: priceDecimals, + Last: scaled(pd.Price), + High: decimal.NullDecimal{Decimal: scaled(pd.High24h), Valid: true}, + Low: decimal.NullDecimal{Decimal: scaled(pd.Low24h), Valid: true}, + Timestamp: ts, + } +} diff --git a/internal/adapter/moralis/client.go b/internal/adapter/moralis/client.go index bfac7f2..0281a08 100644 --- a/internal/adapter/moralis/client.go +++ b/internal/adapter/moralis/client.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "time" ) @@ -27,7 +28,9 @@ type Balance struct { Name string Decimals int Balance string // Raw balance as string to avoid precision loss - Thumbnail string + Thumbnail string + PossibleSpam bool // Moralis spam classification; scam tokens often clone real symbols + VerifiedContract bool // Moralis contract verification; fake clones are unverified } // Transaction represents a blockchain transaction @@ -64,14 +67,85 @@ func NewClient(cfg Config) *Client { } } +// doGetURL is like doGet but takes a full URL instead of a path suffix. +func (c *Client) doGetURL(ctx context.Context, url string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("X-API-Key", c.apiKey) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("do request: %w", err) + } + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + return nil, fmt.Errorf("moralis API status %d for %s", resp.StatusCode, url) + } + return resp, nil +} + +// moralisActiveChain is one entry from the /api/v2.2/wallets/{address}/chains response. +type moralisActiveChain struct { + Chain string `json:"chain"` + ChainID string `json:"chain_id"` + // Non-null only for chains where the wallet actually transacted. + FirstTransaction json.RawMessage `json:"first_transaction"` +} + +// candidateChains is the set Moralis is asked to probe for activity. Without an +// explicit chains parameter the endpoint only checks eth, hiding L2/sidechain +// balances. The chains endpoint rejects scroll/zksync/fantom even though other +// Moralis endpoints support them, so those stay out of the list. +var candidateChains = []string{ + "eth", "base", "arbitrum", "optimism", "linea", + "polygon", "bsc", "avalanche", +} + +// GetActiveChains returns the list of EVM chains where the address has had activity. +// Uses the Moralis v2.2 wallet chains endpoint. +func (c *Client) GetActiveChains(ctx context.Context, address string) ([]string, error) { + params := make([]string, 0, len(candidateChains)) + for _, ch := range candidateChains { + params = append(params, "chains%5B%5D="+ch) // chains[]= + } + url := fmt.Sprintf("https://deep-index.moralis.io/api/v2.2/wallets/%s/chains?%s", + address, strings.Join(params, "&")) + resp, err := c.doGetURL(ctx, url) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + var result struct { + ActiveChains []moralisActiveChain `json:"active_chains"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode active chains: %w", err) + } + + chains := make([]string, 0, len(result.ActiveChains)) + for _, ac := range result.ActiveChains { + active := len(ac.FirstTransaction) > 0 && string(ac.FirstTransaction) != "null" + if ac.Chain != "" && active { + chains = append(chains, ac.Chain) + } + } + return chains, nil +} + // moralisERC20Token is the JSON shape from /v2/{address}/erc20 endpoint. type moralisERC20Token struct { TokenAddress string `json:"token_address"` Symbol string `json:"symbol"` Name string `json:"name"` - Decimals string `json:"decimals"` // returned as string by Moralis + Decimals int `json:"decimals"` Balance string `json:"balance"` - Thumbnail string `json:"thumbnail"` + Thumbnail string `json:"thumbnail"` + PossibleSpam bool `json:"possible_spam"` + VerifiedContract bool `json:"verified_contract"` } func (c *Client) doGet(ctx context.Context, path string) (*http.Response, error) { @@ -108,16 +182,7 @@ func (c *Client) GetWalletTokenBalances(ctx context.Context, chain string, addre result := make([]Balance, 0, len(tokens)) for _, t := range tokens { - dec := 18 - _, _ = fmt.Sscanf(t.Decimals, "%d", &dec) // best-effort; default 18 - result = append(result, Balance{ - TokenAddress: t.TokenAddress, - Symbol: t.Symbol, - Name: t.Name, - Decimals: dec, - Balance: t.Balance, - Thumbnail: t.Thumbnail, - }) + result = append(result, Balance(t)) } return result, nil } diff --git a/internal/adapter/moralis/syncer.go b/internal/adapter/moralis/syncer.go index 0cc8deb..7d5a1cf 100644 --- a/internal/adapter/moralis/syncer.go +++ b/internal/adapter/moralis/syncer.go @@ -2,10 +2,29 @@ package moralis import ( "context" + "errors" + "fmt" "github.com/foxcool/greedy-eye/internal/entity" ) +// nativeToken maps a Moralis chain identifier to its native coin metadata. +var nativeToken = map[string]struct{ symbol, name string }{ + "eth": {"ETH", "Ethereum"}, + "base": {"ETH", "Ethereum"}, + "arbitrum": {"ETH", "Ethereum"}, + "optimism": {"ETH", "Ethereum"}, + "linea": {"ETH", "Ethereum"}, + "zksync": {"ETH", "Ethereum"}, + "scroll": {"ETH", "Ethereum"}, + "polygon": {"POL", "Polygon"}, + "bsc": {"BNB", "BNB Chain"}, + "avalanche": {"AVAX", "Avalanche"}, + "fantom": {"FTM", "Fantom"}, +} + +const nativeDecimals = 18 + // WalletSyncerAdapter adapts *Client to entity.WalletSyncer. type WalletSyncerAdapter struct { client *Client @@ -16,19 +35,93 @@ func NewWalletSyncer(c *Client) *WalletSyncerAdapter { return &WalletSyncerAdapter{client: c} } -func (a *WalletSyncerAdapter) GetWalletTokenBalances(ctx context.Context, chain, address string) ([]entity.WalletBalance, error) { +// SyncWallet fetches native and token balances across the given chains, returning a flat +// normalized list. When chains is empty it auto-discovers chains with activity, falling +// back to "eth" if discovery fails or yields nothing. Per-chain failures are joined into +// the returned error while the balances gathered so far are still returned. +func (a *WalletSyncerAdapter) SyncWallet(ctx context.Context, address string, chains []string) ([]entity.WalletBalance, error) { + if len(chains) == 0 { + chains = a.resolveChains(ctx, address) + } + + var ( + result []entity.WalletBalance + errs []error + ) + for _, chain := range chains { + tokens, err := a.tokenBalances(ctx, chain, address) + if err != nil { + errs = append(errs, fmt.Errorf("chain %s tokens: %w", chain, err)) + } else { + result = append(result, tokens...) + } + + native, err := a.nativeBalance(ctx, chain, address) + if err != nil { + errs = append(errs, fmt.Errorf("chain %s native: %w", chain, err)) + } else if native != nil { + result = append(result, *native) + } + } + + return result, errors.Join(errs...) +} + +// resolveChains discovers chains with activity, falling back to "eth". +func (a *WalletSyncerAdapter) resolveChains(ctx context.Context, address string) []string { + discovered, err := a.client.GetActiveChains(ctx, address) + if err != nil || len(discovered) == 0 { + return []string{"eth"} + } + return discovered +} + +// tokenBalances returns ERC-20 token balances for a single chain. +func (a *WalletSyncerAdapter) tokenBalances(ctx context.Context, chain, address string) ([]entity.WalletBalance, error) { balances, err := a.client.GetWalletTokenBalances(ctx, chain, address) if err != nil { return nil, err } result := make([]entity.WalletBalance, 0, len(balances)) for _, b := range balances { + // Scam tokens clone real symbols (fake USDT airdrops etc.) and would + // merge into legitimate holdings downstream — drop at the source. + // Moralis misses some scams in possible_spam (a fake USDT passed), so + // unverified contracts are dropped too; majors are all verified. + if b.PossibleSpam || !b.VerifiedContract { + continue + } result = append(result, entity.WalletBalance{ - Symbol: b.Symbol, - Name: b.Name, - Amount: b.Balance, - Decimals: b.Decimals, + Symbol: b.Symbol, + Name: b.Name, + Amount: b.Balance, + Decimals: b.Decimals, + ContractAddress: b.TokenAddress, }) } return result, nil } + +// nativeBalance returns the native coin balance for a single chain, or nil when the +// balance is zero. Unknown chains are skipped (nil, nil) rather than erroring, so a +// chain set that mixes supported and unsupported networks still syncs the rest. +func (a *WalletSyncerAdapter) nativeBalance(ctx context.Context, chain, address string) (*entity.WalletBalance, error) { + native, ok := nativeToken[chain] + if !ok { + return nil, nil + } + + raw, err := a.client.GetWalletBalance(ctx, chain, address) + if err != nil { + return nil, err + } + if raw == "" || raw == "0" { + return nil, nil + } + return &entity.WalletBalance{ + Symbol: native.symbol, + Name: native.name, + Amount: raw, + Decimals: nativeDecimals, + }, nil +} diff --git a/internal/adapter/moralis/syncer_test.go b/internal/adapter/moralis/syncer_test.go new file mode 100644 index 0000000..6a51d4b --- /dev/null +++ b/internal/adapter/moralis/syncer_test.go @@ -0,0 +1,33 @@ +package moralis + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWalletSyncer_TokenBalances_FiltersSpam(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"token_address":"0xc944e90c64b2c07662a292be6244bdf05cda44a7","symbol":"GRT","name":"Graph Token","decimals":18,"balance":"1000","possible_spam":false,"verified_contract":true}, + {"token_address":"0xdeadbeef00000000000000000000000000000000","symbol":"SCAM","name":"Spam airdrop","decimals":6,"balance":"1","possible_spam":true,"verified_contract":false}, + {"token_address":"0x7f1ffe630000000000000000000000000000000","symbol":"USDT","name":"Fake USDT (unverified, not flagged as spam)","decimals":6,"balance":"4129545600000","possible_spam":false,"verified_contract":false} + ]`)) + })) + defer srv.Close() + + client := NewClient(Config{APIKey: "test-api-key"}) + client.baseURL = srv.URL + syncer := NewWalletSyncer(client) + + balances, err := syncer.tokenBalances(context.Background(), "eth", "0xabc") + require.NoError(t, err) + require.Len(t, balances, 1) + assert.Equal(t, "GRT", balances[0].Symbol) + assert.Equal(t, "1000", balances[0].Amount) +} diff --git a/internal/entity/asset.go b/internal/entity/asset.go index f853259..2af89f5 100644 --- a/internal/entity/asset.go +++ b/internal/entity/asset.go @@ -1,6 +1,16 @@ package entity -import "time" +import ( + "strings" + "time" +) + +// NormalizeSymbol canonicalizes an asset symbol (trim + uppercase) so symbol lookups +// and the unique constraint are case-insensitive. Tickers are conventionally uppercase +// (BTC, USDC); applying this on every write keeps "usdc" and "USDC" the same asset. +func NormalizeSymbol(symbol string) string { + return strings.ToUpper(strings.TrimSpace(symbol)) +} // AssetSymbol is a simple string representation of an asset symbol. // Kept for backward compatibility with existing code. diff --git a/internal/entity/automation.go b/internal/entity/automation.go index f6f023f..0de63dd 100644 --- a/internal/entity/automation.go +++ b/internal/entity/automation.go @@ -41,7 +41,7 @@ type Rule struct { ID string Name string Description string - RuleType string // e.g. "dca", "rebalancing", "stop_loss", "withdrawal" + RuleType string // e.g. "dca", "rebalancing", "stop_loss", "withdrawal" PortfolioID string UserID string Status RuleStatus diff --git a/internal/entity/portfolio.go b/internal/entity/portfolio.go index 9f70663..cdb6a88 100644 --- a/internal/entity/portfolio.go +++ b/internal/entity/portfolio.go @@ -3,6 +3,8 @@ package entity import ( "encoding/json" "time" + + "github.com/shopspring/decimal" ) // Portfolio represents a collection of holdings managed by a user. @@ -43,11 +45,12 @@ type Account struct { // Holding represents a specific quantity of an Asset held within an Account. type Holding struct { ID string - Amount int64 + Amount decimal.Decimal // Raw integer balance scaled by Decimals (value = Amount / 10^Decimals); holds uint256. Decimals uint32 AssetID string AccountID string - PortfolioID string // Optional + PortfolioID string // Optional; empty = inherit from account's portfolio_id + Excluded bool // If true, holding is explicitly excluded from all portfolio calculations CreatedAt time.Time UpdatedAt time.Time } diff --git a/internal/entity/price.go b/internal/entity/price.go index 78585f5..a4a7677 100644 --- a/internal/entity/price.go +++ b/internal/entity/price.go @@ -23,7 +23,8 @@ type Price struct { } // StoredPrice represents price data from database. -// Uses int64 with decimals field for efficient storage. +// Amounts are raw integers scaled by Decimals (value = amount / 10^Decimals), carried as +// decimal.Decimal and stored in NUMERIC columns. Optional OHLCV fields use NullDecimal. type StoredPrice struct { ID string SourceID string @@ -31,11 +32,11 @@ type StoredPrice struct { BaseAssetID string Interval string Decimals uint32 - Last int64 - Open *int64 - High *int64 - Low *int64 - Close *int64 - Volume *int64 + Last decimal.Decimal + Open decimal.NullDecimal + High decimal.NullDecimal + Low decimal.NullDecimal + Close decimal.NullDecimal + Volume decimal.NullDecimal Timestamp time.Time } diff --git a/internal/entity/wallet.go b/internal/entity/wallet.go index e4a1811..54e76a0 100644 --- a/internal/entity/wallet.go +++ b/internal/entity/wallet.go @@ -4,13 +4,22 @@ import "context" // WalletBalance represents a single token balance returned by a wallet syncer. type WalletBalance struct { - Symbol string - Name string - Amount string // raw integer string (no decimals applied) - Decimals int + Symbol string + Name string + Amount string // raw integer string (no decimals applied) + Decimals int + ContractAddress string // EVM token contract address; empty for native coins } -// WalletSyncer fetches token balances for a blockchain wallet. +// WalletSyncer fetches token balances for a blockchain wallet across one or more chains. +// +// The implementation owns all provider-specific mechanics: chain discovery, per-chain +// fan-out, and native-vs-token balance retrieval. Callers receive a flat, normalized list +// of balances and remain unaware of the underlying provider's API shape. type WalletSyncer interface { - GetWalletTokenBalances(ctx context.Context, chain, address string) ([]WalletBalance, error) + // SyncWallet returns native and token balances for the address across the given chains. + // When chains is empty, the implementation auto-discovers the chains with activity. + // A non-nil error may accompany a partial result: balances gathered before the failure + // are still returned so the caller can surface per-chain errors without losing data. + SyncWallet(ctx context.Context, address string, chains []string) ([]WalletBalance, error) } diff --git a/internal/middleware/user_test.go b/internal/middleware/user_test.go index 33244a8..e59c84a 100644 --- a/internal/middleware/user_test.go +++ b/internal/middleware/user_test.go @@ -2,10 +2,10 @@ package middleware import ( "context" - "net/http" - "testing" "log/slog" + "net/http" "os" + "testing" "connectrpc.com/connect" "github.com/foxcool/greedy-eye/internal/entity" diff --git a/internal/service/automation/handler.go b/internal/service/automation/handler.go index 68b3ebf..094185b 100644 --- a/internal/service/automation/handler.go +++ b/internal/service/automation/handler.go @@ -8,9 +8,10 @@ import ( "time" "connectrpc.com/connect" - apiv1 "github.com/foxcool/greedy-eye/internal/api/v1" - "github.com/foxcool/greedy-eye/internal/api/v1/apiv1connect" + apiv1 "github.com/foxcool/greedy-eye/api/v1" + "github.com/foxcool/greedy-eye/api/v1/apiv1connect" "github.com/foxcool/greedy-eye/internal/entity" + "github.com/foxcool/greedy-eye/internal/middleware" "github.com/foxcool/greedy-eye/internal/store" "google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/structpb" @@ -35,10 +36,16 @@ func (h *Handler) CreateRule(ctx context.Context, req *connect.Request[apiv1.Cre return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("rule is required")) } + user, ok := middleware.UserFromContext(ctx) + if !ok { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("user not found in context")) + } + r, err := ruleFromProto(req.Msg.Rule) if err != nil { return nil, connect.NewError(connect.CodeInvalidArgument, err) } + r.UserID = user.ID created, err := h.store.CreateRule(ctx, r) if err != nil { @@ -109,8 +116,14 @@ func (h *Handler) DeleteRule(ctx context.Context, req *connect.Request[apiv1.Del } func (h *Handler) ListRules(ctx context.Context, req *connect.Request[apiv1.ListRulesRequest]) (*connect.Response[apiv1.ListRulesResponse], error) { - opts := ListRulesOpts{} - if req.Msg.UserId != nil { + user, ok := middleware.UserFromContext(ctx) + if !ok { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("user not found in context")) + } + + // Scope to the caller; allow an explicit override (admin) like PortfolioService. + opts := ListRulesOpts{UserID: user.ID} + if req.Msg.UserId != nil && *req.Msg.UserId != "" { opts.UserID = *req.Msg.UserId } if req.Msg.PortfolioId != nil { diff --git a/internal/service/automation/handler_test.go b/internal/service/automation/handler_test.go index bbc0759..c27fe73 100644 --- a/internal/service/automation/handler_test.go +++ b/internal/service/automation/handler_test.go @@ -8,8 +8,9 @@ import ( "time" "connectrpc.com/connect" - apiv1 "github.com/foxcool/greedy-eye/internal/api/v1" + apiv1 "github.com/foxcool/greedy-eye/api/v1" "github.com/foxcool/greedy-eye/internal/entity" + "github.com/foxcool/greedy-eye/internal/middleware" "github.com/foxcool/greedy-eye/internal/store" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -132,7 +133,8 @@ func TestCreateRule_OK(t *testing.T) { s.On("CreateRule", mock.Anything, mock.Anything).Return(testRule("r-1"), nil) h := newHandler(s) - resp, err := h.CreateRule(context.Background(), connect.NewRequest(&apiv1.CreateRuleRequest{ + ctx := middleware.ContextWithUser(context.Background(), &entity.User{ID: "user-1"}) + resp, err := h.CreateRule(ctx, connect.NewRequest(&apiv1.CreateRuleRequest{ Rule: &apiv1.Rule{Name: "DCA Rule", RuleType: "dca", PortfolioId: "p-1"}, })) require.NoError(t, err) @@ -186,11 +188,12 @@ func TestDeleteRule_OK(t *testing.T) { func TestListRules_WithFilters(t *testing.T) { s := &mockStore{} portfolioID := "p-1" - s.On("ListRules", mock.Anything, ListRulesOpts{PortfolioID: "p-1"}). + s.On("ListRules", mock.Anything, ListRulesOpts{UserID: "user-1", PortfolioID: "p-1"}). Return([]*entity.Rule{testRule("r-1")}, "", nil) h := newHandler(s) - resp, err := h.ListRules(context.Background(), connect.NewRequest(&apiv1.ListRulesRequest{ + ctx := middleware.ContextWithUser(context.Background(), &entity.User{ID: "user-1"}) + resp, err := h.ListRules(ctx, connect.NewRequest(&apiv1.ListRulesRequest{ PortfolioId: &portfolioID, })) require.NoError(t, err) diff --git a/internal/service/marketdata/handler.go b/internal/service/marketdata/handler.go index 2cef9e1..99039e7 100644 --- a/internal/service/marketdata/handler.go +++ b/internal/service/marketdata/handler.go @@ -6,10 +6,14 @@ import ( "fmt" "log/slog" "slices" + "strings" + + "github.com/google/uuid" + "github.com/shopspring/decimal" "connectrpc.com/connect" - apiv1 "github.com/foxcool/greedy-eye/internal/api/v1" - "github.com/foxcool/greedy-eye/internal/api/v1/apiv1connect" + apiv1 "github.com/foxcool/greedy-eye/api/v1" + "github.com/foxcool/greedy-eye/api/v1/apiv1connect" "github.com/foxcool/greedy-eye/internal/entity" "github.com/foxcool/greedy-eye/internal/store" "google.golang.org/protobuf/types/known/emptypb" @@ -17,9 +21,17 @@ import ( ) // PriceProvider fetches prices from an external source. -// Each implementation encapsulates its own SourceID, BaseAssetID, Decimals, and Interval. type PriceProvider interface { + // FetchPrices fetches current prices for the given assets. + // Returned StoredPrice.BaseAssetID is intentionally empty — the handler resolves + // the base asset UUID from BaseAssetSymbol() before persisting. FetchPrices(ctx context.Context, assets []*entity.Asset) ([]entity.StoredPrice, error) + // BaseAssetSymbol returns the ticker of the quote currency (e.g. "USD", "USDT"). + // Used by FetchExternalPrices to resolve or create the base asset on demand. + BaseAssetSymbol() string + // BaseAssetType is the asset type to use when the base asset must be created. + // Fiat quotes (USD) are forex; stablecoin quotes (USDT) are cryptocurrency. + BaseAssetType() entity.AssetType } // Handler implements apiv1connect.MarketDataServiceHandler. @@ -43,9 +55,9 @@ func (h *Handler) WithProvider(name string, p PriceProvider) *Handler { providers[name] = p return &Handler{ UnimplementedMarketDataServiceHandler: h.UnimplementedMarketDataServiceHandler, - store: h.store, - providers: providers, - log: h.log, + store: h.store, + providers: providers, + log: h.log, } } @@ -145,7 +157,10 @@ func (h *Handler) CreatePrice(ctx context.Context, req *connect.Request[apiv1.Cr return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("price is required")) } - price := priceFromProto(req.Msg.Price) + price, err := priceFromProto(req.Msg.Price) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } created, err := h.store.CreatePrice(ctx, price) if err != nil { return nil, toConnectError(err) @@ -158,7 +173,11 @@ func (h *Handler) CreatePrice(ctx context.Context, req *connect.Request[apiv1.Cr func (h *Handler) CreatePrices(ctx context.Context, req *connect.Request[apiv1.CreatePricesRequest]) (*connect.Response[apiv1.CreatePricesResponse], error) { prices := make([]*entity.StoredPrice, 0, len(req.Msg.Prices)) for _, p := range req.Msg.Prices { - prices = append(prices, priceFromProto(p)) + price, err := priceFromProto(p) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + prices = append(prices, price) } count, err := h.store.CreatePrices(ctx, prices) @@ -171,10 +190,19 @@ func (h *Handler) CreatePrices(ctx context.Context, req *connect.Request[apiv1.C }), nil } -// GetLatestPrice returns the most recent price for an asset pair. +// GetLatestPrice returns the most recent price for an asset. When base_asset_id is +// provided it returns the price in that specific pair; when omitted it returns the +// latest price in whatever base the asset actually trades against (the response's +// base_asset_id tells the caller which). This lets callers value an asset without +// knowing its quote currency in advance. func (h *Handler) GetLatestPrice(ctx context.Context, req *connect.Request[apiv1.GetLatestPriceRequest]) (*connect.Response[apiv1.Price], error) { - if req.Msg.AssetId == "" || req.Msg.BaseAssetId == "" { - return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("asset_id and base_asset_id are required")) + if req.Msg.AssetId == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("asset_id is required")) + } + + assetID, err := h.resolveAssetID(ctx, req.Msg.AssetId) + if err != nil { + return nil, toConnectError(err) } var sourceID string @@ -182,7 +210,17 @@ func (h *Handler) GetLatestPrice(ctx context.Context, req *connect.Request[apiv1 sourceID = *req.Msg.SourceId } - price, err := h.store.GetLatestPrice(ctx, req.Msg.AssetId, req.Msg.BaseAssetId, sourceID) + // base_asset_id is optional: empty means "any base", letting the store return the + // asset's latest price in whatever pair it trades against. + var baseAssetID string + if req.Msg.BaseAssetId != "" { + baseAssetID, err = h.resolveAssetID(ctx, req.Msg.BaseAssetId) + if err != nil { + return nil, toConnectError(err) + } + } + + price, err := h.store.GetLatestPrice(ctx, assetID, baseAssetID, sourceID) if err != nil { return nil, toConnectError(err) } @@ -190,15 +228,34 @@ func (h *Handler) GetLatestPrice(ctx context.Context, req *connect.Request[apiv1 return connect.NewResponse(priceToProto(price)), nil } +// resolveAssetID returns id unchanged if it is a valid UUID, otherwise treats it +// as a symbol and looks up the asset by symbol. This allows callers to pass either +// a UUID or a well-known ticker (e.g. "USD", "usd") interchangeably. +func (h *Handler) resolveAssetID(ctx context.Context, id string) (string, error) { + if _, err := uuid.Parse(id); err == nil { + return id, nil + } + asset, err := h.store.GetAssetBySymbol(ctx, id) // store normalizes symbol case + if err != nil { + return "", fmt.Errorf("resolve asset %q: %w", id, err) + } + return asset.ID, nil +} + // ListPriceHistory returns price history for an asset pair. func (h *Handler) ListPriceHistory(ctx context.Context, req *connect.Request[apiv1.ListPriceHistoryRequest]) (*connect.Response[apiv1.ListPriceHistoryResponse], error) { if req.Msg.AssetId == "" || req.Msg.BaseAssetId == "" { return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("asset_id and base_asset_id are required")) } + baseAssetID, err := h.resolveAssetID(ctx, req.Msg.BaseAssetId) + if err != nil { + return nil, toConnectError(err) + } + opts := ListPriceHistoryOpts{ AssetID: req.Msg.AssetId, - BaseAssetID: req.Msg.BaseAssetId, + BaseAssetID: baseAssetID, } if req.Msg.SourceId != nil { opts.SourceID = *req.Msg.SourceId @@ -337,6 +394,9 @@ func (h *Handler) FetchExternalPrices(ctx context.Context, req *connect.Request[ var fetchErrs []string var totalFetched int + // Cache base asset UUIDs per symbol to avoid repeated lookups across providers. + baseAssetCache := map[string]string{} // symbol → UUID + for name, provider := range h.providers { if len(req.Msg.SourceIds) > 0 && !slices.Contains(req.Msg.SourceIds, name) { continue @@ -346,15 +406,32 @@ func (h *Handler) FetchExternalPrices(ctx context.Context, req *connect.Request[ fetchErrs = append(fetchErrs, fmt.Sprintf("%s: %v", name, err)) continue } + + // Resolve base asset UUID for this provider (create the asset if it doesn't exist yet). + sym := strings.ToUpper(provider.BaseAssetSymbol()) + baseID, ok := baseAssetCache[sym] + if !ok { + baseAsset, err := h.store.GetOrCreateAssetBySymbol(ctx, sym, sym, provider.BaseAssetType()) + if err != nil { + fetchErrs = append(fetchErrs, fmt.Sprintf("%s: resolve base asset %s: %v", name, sym, err)) + continue + } + baseID = baseAsset.ID + baseAssetCache[sym] = baseID + } + totalFetched += len(results) for i := range results { + results[i].BaseAssetID = baseID allPrices = append(allPrices, &results[i]) } } stored, err := h.store.CreatePrices(ctx, allPrices) if err != nil { - return nil, toConnectError(err) + // Partial failures are non-fatal: surface them in the response errors field. + h.log.Warn("some prices failed to store", "error", err) + fetchErrs = append(fetchErrs, err.Error()) } return connect.NewResponse(&apiv1.FetchExternalPricesResponse{ @@ -410,7 +487,63 @@ func assetToProto(e *entity.Asset) *apiv1.Asset { } } -func priceFromProto(p *apiv1.Price) *entity.StoredPrice { +// parseDecimal parses a raw integer decimal string. Empty is treated as unset (zero); +// a non-empty but malformed value is an error rather than a silent zero. +func parseDecimal(s string) (decimal.Decimal, error) { + if s == "" { + return decimal.Zero, nil + } + return decimal.NewFromString(s) +} + +// parseNullDecimal converts an optional proto string into a NullDecimal. A nil pointer +// is a valid absent value; a non-nil but malformed value is an error. +func parseNullDecimal(s *string) (decimal.NullDecimal, error) { + if s == nil { + return decimal.NullDecimal{}, nil + } + d, err := decimal.NewFromString(*s) + if err != nil { + return decimal.NullDecimal{}, err + } + return decimal.NullDecimal{Decimal: d, Valid: true}, nil +} + +// nullDecimalToProto converts a NullDecimal into an optional proto string. +func nullDecimalToProto(d decimal.NullDecimal) *string { + if !d.Valid { + return nil + } + s := d.Decimal.String() + return &s +} + +func priceFromProto(p *apiv1.Price) (*entity.StoredPrice, error) { + last, err := parseDecimal(p.Last) + if err != nil { + return nil, fmt.Errorf("invalid last %q: %w", p.Last, err) + } + open, err := parseNullDecimal(p.Open) + if err != nil { + return nil, fmt.Errorf("invalid open: %w", err) + } + high, err := parseNullDecimal(p.High) + if err != nil { + return nil, fmt.Errorf("invalid high: %w", err) + } + low, err := parseNullDecimal(p.Low) + if err != nil { + return nil, fmt.Errorf("invalid low: %w", err) + } + closeVal, err := parseNullDecimal(p.Close) + if err != nil { + return nil, fmt.Errorf("invalid close: %w", err) + } + volume, err := parseNullDecimal(p.Volume) + if err != nil { + return nil, fmt.Errorf("invalid volume: %w", err) + } + price := &entity.StoredPrice{ ID: p.Id, SourceID: p.SourceId, @@ -418,17 +551,17 @@ func priceFromProto(p *apiv1.Price) *entity.StoredPrice { BaseAssetID: p.BaseAssetId, Interval: p.Interval, Decimals: p.Decimals, - Last: p.Last, - Open: p.Open, - High: p.High, - Low: p.Low, - Close: p.Close, - Volume: p.Volume, + Last: last, + Open: open, + High: high, + Low: low, + Close: closeVal, + Volume: volume, } if p.Timestamp != nil { price.Timestamp = p.Timestamp.AsTime() } - return price + return price, nil } func priceToProto(e *entity.StoredPrice) *apiv1.Price { @@ -439,12 +572,12 @@ func priceToProto(e *entity.StoredPrice) *apiv1.Price { BaseAssetId: e.BaseAssetID, Interval: e.Interval, Decimals: e.Decimals, - Last: e.Last, - Open: e.Open, - High: e.High, - Low: e.Low, - Close: e.Close, - Volume: e.Volume, + Last: e.Last.String(), + Open: nullDecimalToProto(e.Open), + High: nullDecimalToProto(e.High), + Low: nullDecimalToProto(e.Low), + Close: nullDecimalToProto(e.Close), + Volume: nullDecimalToProto(e.Volume), Timestamp: timestamppb.New(e.Timestamp), } } diff --git a/internal/service/marketdata/handler_test.go b/internal/service/marketdata/handler_test.go index a5044cb..9d8a00d 100644 --- a/internal/service/marketdata/handler_test.go +++ b/internal/service/marketdata/handler_test.go @@ -7,9 +7,10 @@ import ( "time" "connectrpc.com/connect" - apiv1 "github.com/foxcool/greedy-eye/internal/api/v1" + apiv1 "github.com/foxcool/greedy-eye/api/v1" "github.com/foxcool/greedy-eye/internal/entity" "github.com/foxcool/greedy-eye/internal/store" + "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -39,6 +40,22 @@ func (m *mockStore) GetAsset(ctx context.Context, id string) (*entity.Asset, err return nil, args.Error(1) } +func (m *mockStore) GetAssetBySymbol(ctx context.Context, symbol string) (*entity.Asset, error) { + args := m.Called(ctx, symbol) + if v := args.Get(0); v != nil { + return v.(*entity.Asset), args.Error(1) + } + return nil, args.Error(1) +} + +func (m *mockStore) GetOrCreateAssetBySymbol(ctx context.Context, symbol, nameIfNew string, typeIfNew entity.AssetType) (*entity.Asset, error) { + args := m.Called(ctx, symbol, nameIfNew, typeIfNew) + if v := args.Get(0); v != nil { + return v.(*entity.Asset), args.Error(1) + } + return nil, args.Error(1) +} + func (m *mockStore) UpdateAsset(ctx context.Context, asset *entity.Asset, fields []string) (*entity.Asset, error) { args := m.Called(ctx, asset, fields) if v := args.Get(0); v != nil { @@ -270,7 +287,7 @@ func TestCreatePrice_OK(t *testing.T) { AssetID: "a-1", BaseAssetID: "usdt", SourceID: "binance", - Last: 50000, + Last: decimal.NewFromInt(50000), Timestamp: now, } s.On("CreatePrice", mock.Anything, mock.Anything).Return(stored, nil) @@ -292,7 +309,6 @@ func TestGetLatestPrice_MissingFields(t *testing.T) { req *apiv1.GetLatestPriceRequest }{ {"missing both", &apiv1.GetLatestPriceRequest{}}, - {"missing base_asset_id", &apiv1.GetLatestPriceRequest{AssetId: "a-1"}}, {"missing asset_id", &apiv1.GetLatestPriceRequest{BaseAssetId: "usdt"}}, } for _, tt := range tests { @@ -305,19 +321,72 @@ func TestGetLatestPrice_MissingFields(t *testing.T) { } func TestGetLatestPrice_OK(t *testing.T) { + const baseUUID = "00000000-0000-0000-0000-000000000001" + const assetUUID = "00000000-0000-0000-0000-0000000000a1" s := &mockStore{} - s.On("GetLatestPrice", mock.Anything, "a-1", "usdt", "").Return(&entity.StoredPrice{ - ID: "p-1", AssetID: "a-1", BaseAssetID: "usdt", + s.On("GetLatestPrice", mock.Anything, assetUUID, baseUUID, "").Return(&entity.StoredPrice{ + ID: "p-1", AssetID: assetUUID, BaseAssetID: baseUUID, }, nil) h := newHandler(s) resp, err := h.GetLatestPrice(context.Background(), connect.NewRequest(&apiv1.GetLatestPriceRequest{ - AssetId: "a-1", BaseAssetId: "usdt", + AssetId: assetUUID, BaseAssetId: baseUUID, })) require.NoError(t, err) assert.Equal(t, "p-1", resp.Msg.Id) } +// TestGetLatestPrice_AnyBase verifies that omitting base_asset_id returns the asset's +// latest price in whatever base it trades against (used for portfolio cross-rate valuation). +func TestGetLatestPrice_AnyBase(t *testing.T) { + const tradedBase = "00000000-0000-0000-0000-0000000000aa" + const assetUUID = "00000000-0000-0000-0000-0000000000a1" + s := &mockStore{} + // Empty base_asset_id → store called with empty base ("any pair"). + s.On("GetLatestPrice", mock.Anything, assetUUID, "", "").Return(&entity.StoredPrice{ + ID: "p-9", AssetID: assetUUID, BaseAssetID: tradedBase, + }, nil) + h := newHandler(s) + + resp, err := h.GetLatestPrice(context.Background(), connect.NewRequest(&apiv1.GetLatestPriceRequest{ + AssetId: assetUUID, // no BaseAssetId + })) + require.NoError(t, err) + assert.Equal(t, "p-9", resp.Msg.Id) + assert.Equal(t, tradedBase, resp.Msg.GetBaseAssetId()) +} + +func TestGetLatestPrice_SymbolResolved(t *testing.T) { + const resolvedUUID = "00000000-0000-0000-0000-000000000002" + const assetUUID = "00000000-0000-0000-0000-0000000000a1" + s := &mockStore{} + // Handler passes the symbol through verbatim; the real store normalizes case. + s.On("GetAssetBySymbol", mock.Anything, "usd").Return(&entity.Asset{ID: resolvedUUID}, nil) + s.On("GetLatestPrice", mock.Anything, assetUUID, resolvedUUID, "").Return(&entity.StoredPrice{ + ID: "p-2", AssetID: assetUUID, BaseAssetID: resolvedUUID, + }, nil) + h := newHandler(s) + + resp, err := h.GetLatestPrice(context.Background(), connect.NewRequest(&apiv1.GetLatestPriceRequest{ + AssetId: assetUUID, BaseAssetId: "usd", // lowercase symbol, not UUID + })) + require.NoError(t, err) + assert.Equal(t, "p-2", resp.Msg.Id) +} + +func TestGetLatestPrice_SymbolNotFound(t *testing.T) { + const assetUUID = "00000000-0000-0000-0000-0000000000a1" + s := &mockStore{} + s.On("GetAssetBySymbol", mock.Anything, "unknown").Return(nil, store.ErrNotFound) + h := newHandler(s) + + _, err := h.GetLatestPrice(context.Background(), connect.NewRequest(&apiv1.GetLatestPriceRequest{ + AssetId: assetUUID, BaseAssetId: "unknown", + })) + require.Error(t, err) + assert.Equal(t, connect.CodeNotFound, connect.CodeOf(err)) +} + // --- Tests: Stubs return Unimplemented --- func TestStubs_ReturnUnimplemented(t *testing.T) { diff --git a/internal/service/marketdata/store.go b/internal/service/marketdata/store.go index 229193f..8ae169b 100644 --- a/internal/service/marketdata/store.go +++ b/internal/service/marketdata/store.go @@ -13,6 +13,8 @@ type Store interface { // Assets CreateAsset(ctx context.Context, asset *entity.Asset) (*entity.Asset, error) GetAsset(ctx context.Context, id string) (*entity.Asset, error) + GetAssetBySymbol(ctx context.Context, symbol string) (*entity.Asset, error) + GetOrCreateAssetBySymbol(ctx context.Context, symbol, nameIfNew string, typeIfNew entity.AssetType) (*entity.Asset, error) UpdateAsset(ctx context.Context, asset *entity.Asset, fields []string) (*entity.Asset, error) DeleteAsset(ctx context.Context, id string) error ListAssets(ctx context.Context, opts ListAssetsOpts) ([]*entity.Asset, string, error) @@ -20,6 +22,9 @@ type Store interface { // Prices CreatePrice(ctx context.Context, price *entity.StoredPrice) (*entity.StoredPrice, error) CreatePrices(ctx context.Context, prices []*entity.StoredPrice) (int, error) + // GetLatestPrice returns the asset's most recent price. An empty baseAssetID or + // sourceID means "any" for that filter; omitting baseAssetID yields the price in + // whatever pair the asset trades against (used for cross-rate valuation). GetLatestPrice(ctx context.Context, assetID, baseAssetID, sourceID string) (*entity.StoredPrice, error) ListPriceHistory(ctx context.Context, opts ListPriceHistoryOpts) ([]*entity.StoredPrice, string, error) DeletePrice(ctx context.Context, id string) error diff --git a/internal/service/portfolio/handler.go b/internal/service/portfolio/handler.go index f2b7e6d..20b166a 100644 --- a/internal/service/portfolio/handler.go +++ b/internal/service/portfolio/handler.go @@ -6,26 +6,31 @@ import ( "errors" "fmt" "log/slog" - "math" + "math/big" + "strings" "time" "connectrpc.com/connect" - "github.com/shopspring/decimal" - apiv1 "github.com/foxcool/greedy-eye/internal/api/v1" - "github.com/foxcool/greedy-eye/internal/api/v1/apiv1connect" + apiv1 "github.com/foxcool/greedy-eye/api/v1" + "github.com/foxcool/greedy-eye/api/v1/apiv1connect" "github.com/foxcool/greedy-eye/internal/entity" "github.com/foxcool/greedy-eye/internal/middleware" "github.com/foxcool/greedy-eye/internal/store" + "github.com/shopspring/decimal" "google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/timestamppb" ) +// defaultQuoteAsset is the symbol used when the caller omits quote_asset_id. +// The marketdata handler resolves it to a UUID via GetAssetBySymbol. +const defaultQuoteAsset = "USD" + // Handler implements apiv1connect.PortfolioServiceHandler. type Handler struct { apiv1connect.UnimplementedPortfolioServiceHandler store Store - mdClient MarketDataClient // optional; nil if not configured + mdClient MarketDataClient // optional; nil if not configured walletSyncer entity.WalletSyncer // optional; nil if not configured log *slog.Logger } @@ -38,10 +43,10 @@ func NewHandler(store Store, log *slog.Logger) *Handler { func (h *Handler) WithMarketDataClient(mc MarketDataClient) *Handler { return &Handler{ UnimplementedPortfolioServiceHandler: h.UnimplementedPortfolioServiceHandler, - store: h.store, - mdClient: mc, - walletSyncer: h.walletSyncer, - log: h.log, + store: h.store, + mdClient: mc, + walletSyncer: h.walletSyncer, + log: h.log, } } @@ -49,10 +54,10 @@ func (h *Handler) WithMarketDataClient(mc MarketDataClient) *Handler { func (h *Handler) WithWalletSyncer(ws entity.WalletSyncer) *Handler { return &Handler{ UnimplementedPortfolioServiceHandler: h.UnimplementedPortfolioServiceHandler, - store: h.store, - mdClient: h.mdClient, - walletSyncer: ws, - log: h.log, + store: h.store, + mdClient: h.mdClient, + walletSyncer: ws, + log: h.log, } } @@ -167,10 +172,14 @@ func (h *Handler) CalculatePortfolioValue(ctx context.Context, req *connect.Requ quoteAssetID := req.Msg.QuoteAssetId if quoteAssetID == "" { - quoteAssetID = "usd" + quoteAssetID = defaultQuoteAsset } - holdings, _, err := h.store.ListHoldings(ctx, ListHoldingsOpts{PortfolioID: req.Msg.PortfolioId, PageSize: 1000}) + holdings, _, err := h.store.ListHoldings(ctx, ListHoldingsOpts{ + PortfolioID: req.Msg.PortfolioId, + PageSize: 1000, + HideExcluded: true, + }) if err != nil { return nil, toConnectError(err) } @@ -179,28 +188,21 @@ func (h *Handler) CalculatePortfolioValue(ctx context.Context, req *connect.Requ total := decimal.Zero for _, hld := range holdings { - priceResp, err := h.mdClient.GetLatestPrice(ctx, connect.NewRequest(&apiv1.GetLatestPriceRequest{ - AssetId: hld.AssetID, BaseAssetId: quoteAssetID, - })) + unit, ok, err := h.unitPrice(ctx, hld.AssetID, quoteAssetID) if err != nil { - if connect.CodeOf(err) == connect.CodeNotFound { - continue // skip assets without price data - } return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("get price for %s: %w", hld.AssetID, err)) } - price := priceResp.Msg - - // value = amount * price.Last / 10^(holding.Decimals + price.Decimals) - divisor := decimal.New(1, int32(hld.Decimals)+int32(price.Decimals)) - holdingValue := decimal.New(hld.Amount, 0). - Mul(decimal.New(price.Last, 0)). - Div(divisor) + if !ok { + continue // no price path for this asset → skip + } + // value = (amount / 10^holding.Decimals) * unitPrice + holdingValue := hld.Amount.Shift(-int32(hld.Decimals)).Mul(unit) total = total.Add(holdingValue) } - // Convert to result decimals (e.g., 2 for USD cents) - resultAmount := total.Mul(decimal.New(1, int32(resultDecimals))).IntPart() + // Convert to result decimals (e.g., 2 for USD cents) as a raw integer decimal string. + resultAmount := total.Mul(decimal.New(1, int32(resultDecimals))).Round(0).String() return connect.NewResponse(&apiv1.PortfolioValueResponse{ PortfolioId: req.Msg.PortfolioId, @@ -211,8 +213,88 @@ func (h *Handler) CalculatePortfolioValue(ctx context.Context, req *connect.Requ }), nil } -// Ensure math is referenced (used for potential rounding in future). -var _ = math.Round +// unitPrice returns the per-token price of assetID expressed in quoteAssetID as a +// real-unit decimal (i.e. already divided by the price's decimals). +// +// A position is priced in whatever pair it actually trades in (USDT, RUB, BTC, …); the +// quote currency is not assumed. Resolution: +// 1. direct — a price quoted straight in quoteAssetID +// 2. cross — the asset's latest price in its own base B, converted B→quote +// +// ok is false when no price path exists; a non-nil error signals an unexpected failure. +func (h *Handler) unitPrice(ctx context.Context, assetID, quoteAssetID string) (decimal.Decimal, bool, error) { + if p, ok, err := h.realPrice(ctx, assetID, quoteAssetID); err != nil || ok { + return p, ok, err + } + + // The asset's actual traded pair: latest price in whatever base it has. + baseID, value, ok, err := h.latestAnyBase(ctx, assetID) + if err != nil || !ok { + return decimal.Zero, false, err + } + + rate, ok, err := h.crossRate(ctx, baseID, quoteAssetID) + if err != nil || !ok { + return decimal.Zero, false, err + } + return value.Mul(rate), true, nil +} + +// crossRate returns how many units of quoteID one unit of baseID is worth, using a +// direct baseID/quoteID price or, failing that, the inverse quoteID/baseID price. +func (h *Handler) crossRate(ctx context.Context, baseID, quoteID string) (decimal.Decimal, bool, error) { + if r, ok, err := h.realPrice(ctx, baseID, quoteID); err != nil || ok { + return r, ok, err + } + inv, ok, err := h.realPrice(ctx, quoteID, baseID) + if err != nil || !ok || inv.IsZero() { + return decimal.Zero, false, err + } + return decimal.NewFromInt(1).Div(inv), true, nil +} + +// latestAnyBase returns the asset's most recent price regardless of base, as the base +// asset ID and the real-unit value. ok is false when no price exists. +func (h *Handler) latestAnyBase(ctx context.Context, assetID string) (string, decimal.Decimal, bool, error) { + resp, err := h.mdClient.GetLatestPrice(ctx, connect.NewRequest(&apiv1.GetLatestPriceRequest{ + AssetId: assetID, // BaseAssetId omitted → latest in any base + })) + if err != nil { + if connect.CodeOf(err) == connect.CodeNotFound { + return "", decimal.Zero, false, nil + } + return "", decimal.Zero, false, err + } + last, err := decimal.NewFromString(resp.Msg.Last) + if err != nil { + h.log.Warn("skip price with unparseable last", + "asset_id", assetID, "base_asset_id", resp.Msg.BaseAssetId, "last", resp.Msg.Last, "error", err) + return "", decimal.Zero, false, nil + } + return resp.Msg.BaseAssetId, last.Shift(-int32(resp.Msg.Decimals)), true, nil +} + +// realPrice returns the latest price of assetID in baseID as a real-unit decimal +// (value = last / 10^decimals). ok is false when no price exists (NotFound) or the +// stored value is unparseable; a non-nil error is returned only for unexpected failures. +func (h *Handler) realPrice(ctx context.Context, assetID, baseID string) (decimal.Decimal, bool, error) { + resp, err := h.mdClient.GetLatestPrice(ctx, connect.NewRequest(&apiv1.GetLatestPriceRequest{ + AssetId: assetID, BaseAssetId: baseID, + })) + if err != nil { + if connect.CodeOf(err) == connect.CodeNotFound { + return decimal.Zero, false, nil + } + return decimal.Zero, false, err + } + last, err := decimal.NewFromString(resp.Msg.Last) + if err != nil { + h.log.Warn("skip price with unparseable last", + "asset_id", assetID, "base_asset_id", baseID, "last", resp.Msg.Last, "error", err) + return decimal.Zero, false, nil + } + return last.Shift(-int32(resp.Msg.Decimals)), true, nil +} // GetPortfolioPerformance calculates return over a time range using stored price history. // If no `from` is set, defaults to 30 days ago. Requires marketStore. @@ -229,7 +311,7 @@ func (h *Handler) GetPortfolioPerformance(ctx context.Context, req *connect.Requ from = req.Msg.From.AsTime() } - quoteAssetID := "usd" + quoteAssetID := defaultQuoteAsset if req.Msg.BenchmarkAssetId != "" { quoteAssetID = req.Msg.BenchmarkAssetId } @@ -254,9 +336,15 @@ func (h *Handler) GetPortfolioPerformance(ctx context.Context, req *connect.Requ } latestPrice := latestResp.Msg + latestLast, err := decimal.NewFromString(latestPrice.Last) + if err != nil { + h.log.Warn("skip price with unparseable last", "asset_id", hld.AssetID, "last", latestPrice.Last, "error", err) + continue + } + divisorCurrent := decimal.New(1, int32(hld.Decimals)+int32(latestPrice.Decimals)) - holdingCurrent := decimal.New(hld.Amount, 0). - Mul(decimal.New(latestPrice.Last, 0)). + holdingCurrent := hld.Amount. + Mul(latestLast). Div(divisorCurrent) currentValue = currentValue.Add(holdingCurrent) @@ -277,9 +365,16 @@ func (h *Handler) GetPortfolioPerformance(ctx context.Context, req *connect.Requ } fromPrice := histResp.Msg.Prices[0] + fromLast, err := decimal.NewFromString(fromPrice.Last) + if err != nil { + h.log.Warn("skip historical price with unparseable last", "asset_id", hld.AssetID, "last", fromPrice.Last, "error", err) + fromValue = fromValue.Add(holdingCurrent) + continue + } + divisorFrom := decimal.New(1, int32(hld.Decimals)+int32(fromPrice.Decimals)) - holdingFrom := decimal.New(hld.Amount, 0). - Mul(decimal.New(fromPrice.Last, 0)). + holdingFrom := hld.Amount. + Mul(fromLast). Div(divisorFrom) fromValue = fromValue.Add(holdingFrom) } @@ -303,7 +398,10 @@ func (h *Handler) CreateHolding(ctx context.Context, req *connect.Request[apiv1. return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("holding is required")) } - holding := holdingFromProto(req.Msg.Holding) + holding, err := holdingFromProto(req.Msg.Holding) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } created, err := h.store.CreateHolding(ctx, holding) if err != nil { return nil, toConnectError(err) @@ -330,12 +428,15 @@ func (h *Handler) UpdateHolding(ctx context.Context, req *connect.Request[apiv1. return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("holding with ID is required")) } - fields := []string{"amount", "decimals", "portfolio_id"} + fields := []string{"amount", "decimals", "portfolio_id", "excluded"} if req.Msg.UpdateMask != nil && len(req.Msg.UpdateMask.Paths) > 0 { fields = req.Msg.UpdateMask.Paths } - holding := holdingFromProto(req.Msg.Holding) + holding, err := holdingFromProto(req.Msg.Holding) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } updated, err := h.store.UpdateHolding(ctx, holding, fields) if err != nil { return nil, toConnectError(err) @@ -345,7 +446,12 @@ func (h *Handler) UpdateHolding(ctx context.Context, req *connect.Request[apiv1. } func (h *Handler) ListHoldings(ctx context.Context, req *connect.Request[apiv1.ListHoldingsRequest]) (*connect.Response[apiv1.ListHoldingsResponse], error) { - opts := ListHoldingsOpts{} + user, ok := middleware.UserFromContext(ctx) + if !ok { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("user not found in context")) + } + + opts := ListHoldingsOpts{UserID: user.ID} if req.Msg.PortfolioId != nil { opts.PortfolioID = *req.Msg.PortfolioId } @@ -418,7 +524,7 @@ func (h *Handler) UpdateAccount(ctx context.Context, req *connect.Request[apiv1. return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("account with ID is required")) } - fields := []string{"name", "description", "type", "data"} + fields := []string{"name", "description", "type", "data", "portfolio_id"} if req.Msg.UpdateMask != nil && len(req.Msg.UpdateMask.Paths) > 0 { fields = req.Msg.UpdateMask.Paths } @@ -503,15 +609,63 @@ func (h *Handler) SyncAccount(ctx context.Context, req *connect.Request[apiv1.Sy if !ok || address == "" { return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("account.data.address is required for wallet sync")) } - chain := account.Data["chain"] - if chain == "" { - chain = "eth" + + // Resolve which chains to sync from the account config. + // Empty or "auto" → let the syncer auto-discover (pass nil). + // Otherwise a comma-separated list: "eth,base,arbitrum". + var syncErrors []string + var chains []string + if chainRaw := strings.TrimSpace(account.Data["chain"]); chainRaw != "" && chainRaw != "auto" { + chains = splitChains(chainRaw) } - // Fetch balances from Moralis - balances, err := h.walletSyncer.GetWalletTokenBalances(ctx, chain, address) + // The syncer owns all provider mechanics (discovery, fan-out, native vs token). + // Partial failures arrive as a joined error alongside the balances gathered so far. + balances, err := h.walletSyncer.SyncWallet(ctx, address, chains) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("fetch wallet balances: %w", err)) + syncErrors = append(syncErrors, err.Error()) + } + + // Merge same-symbol tokens across chains, keyed by the canonical (uppercase) symbol + // so "usdc"/"USDC" collapse into one holding. The same symbol can carry different + // decimals per chain (e.g. USDC is 6 on Ethereum, 18 on BSC), so balances are summed + // as real quantities (raw / 10^decimals) and stored at the largest decimals seen — + // summing raw integers across mismatched scales would corrupt the total. + type accumulated struct { + name string + contractAddress string + qty decimal.Decimal // real token quantity, decimals applied + decimals int // max decimals seen → stored holding scale + } + bySymbol := make(map[string]*accumulated) + + for _, b := range balances { + amt, ok := new(big.Int).SetString(strings.TrimSpace(b.Amount), 10) + if !ok { + syncErrors = append(syncErrors, fmt.Sprintf("parse amount %q for %s", b.Amount, b.Symbol)) + continue + } + if amt.Sign() == 0 { + continue + } + symbol := entity.NormalizeSymbol(b.Symbol) + qty := decimal.NewFromBigInt(amt, int32(-b.Decimals)) // raw / 10^decimals + if entry, ok := bySymbol[symbol]; ok { + entry.qty = entry.qty.Add(qty) + if b.Decimals > entry.decimals { + entry.decimals = b.Decimals + } + if entry.contractAddress == "" { + entry.contractAddress = b.ContractAddress + } + } else { + bySymbol[symbol] = &accumulated{ + name: b.Name, + contractAddress: b.ContractAddress, + qty: qty, + decimals: b.Decimals, + } + } } // Build symbol → asset ID map from existing assets @@ -525,7 +679,7 @@ func (h *Handler) SyncAccount(ctx context.Context, req *connect.Request[apiv1.Sy symbolToAssetID := make(map[string]string, len(listResp.Msg.Assets)) for _, a := range listResp.Msg.Assets { if a.Symbol != nil { - symbolToAssetID[*a.Symbol] = a.Id + symbolToAssetID[entity.NormalizeSymbol(*a.Symbol)] = a.Id } } @@ -542,19 +696,27 @@ func (h *Handler) SyncAccount(ctx context.Context, req *connect.Request[apiv1.Sy defaultPortfolioID := account.PortfolioID var assetsUpserted, holdingsUpserted int32 - var syncErrors []string - for _, bal := range balances { - symbol := bal.Symbol + for symbol, entry := range bySymbol { + decimals := uint32(entry.decimals) + // holdings.amount is NUMERIC: store the merged quantity as a raw integer at the + // holding's decimals scale (exact — qty has at most `decimals` fractional digits). + amount := entry.qty.Shift(int32(entry.decimals)) // Ensure asset exists assetID, exists := symbolToAssetID[symbol] if !exists { + // Store contract address as a tag so price providers can look up by address. + var tags []string + if entry.contractAddress != "" { + tags = append(tags, "contract:"+entry.contractAddress) + } createResp, err := h.mdClient.CreateAsset(ctx, connect.NewRequest(&apiv1.CreateAssetRequest{ Asset: &apiv1.Asset{ - Name: bal.Name, + Name: entry.name, Symbol: &symbol, Type: apiv1.AssetType_ASSET_TYPE_CRYPTOCURRENCY, + Tags: tags, }, })) if err != nil { @@ -566,17 +728,10 @@ func (h *Handler) SyncAccount(ctx context.Context, req *connect.Request[apiv1.Sy assetsUpserted++ } - // Parse raw balance string to int64 - var amount int64 - if _, err := fmt.Sscanf(bal.Amount, "%d", &amount); err != nil { - syncErrors = append(syncErrors, fmt.Sprintf("parse amount for %s: %v", symbol, err)) - continue - } - if existing, ok := holdingByAssetID[assetID]; ok { // Update existing holding: only refresh amount/decimals; never touch portfolio assignment existing.Amount = amount - existing.Decimals = uint32(bal.Decimals) + existing.Decimals = decimals if _, err := h.store.UpdateHolding(ctx, existing, []string{"amount", "decimals"}); err != nil { syncErrors = append(syncErrors, fmt.Sprintf("update holding %s: %v", symbol, err)) continue @@ -588,7 +743,7 @@ func (h *Handler) SyncAccount(ctx context.Context, req *connect.Request[apiv1.Sy AccountID: req.Msg.AccountId, PortfolioID: defaultPortfolioID, Amount: amount, - Decimals: uint32(bal.Decimals), + Decimals: decimals, }) if err != nil { syncErrors = append(syncErrors, fmt.Sprintf("create holding %s: %v", symbol, err)) @@ -750,27 +905,39 @@ func portfolioToProto(p *entity.Portfolio) *apiv1.Portfolio { return result } -func holdingFromProto(h *apiv1.Holding) *entity.Holding { +func holdingFromProto(h *apiv1.Holding) (*entity.Holding, error) { + // Empty amount is treated as unset (zero) so partial updates that omit it still work; + // a non-empty but malformed amount is rejected rather than silently coerced to zero. + amount := decimal.Zero + if h.Amount != "" { + var err error + amount, err = decimal.NewFromString(h.Amount) + if err != nil { + return nil, fmt.Errorf("invalid amount %q: %w", h.Amount, err) + } + } result := &entity.Holding{ ID: h.Id, - Amount: h.Amount, + Amount: amount, Decimals: h.Decimals, AssetID: h.AssetId, AccountID: h.AccountId, + Excluded: h.Excluded, } if h.PortfolioId != nil { result.PortfolioID = *h.PortfolioId } - return result + return result, nil } func holdingToProto(h *entity.Holding) *apiv1.Holding { result := &apiv1.Holding{ Id: h.ID, - Amount: h.Amount, + Amount: h.Amount.String(), Decimals: h.Decimals, AssetId: h.AssetID, AccountId: h.AccountID, + Excluded: h.Excluded, CreatedAt: timestamppb.New(h.CreatedAt), UpdatedAt: timestamppb.New(h.UpdatedAt), } @@ -826,6 +993,25 @@ func transactionFromProto(t *apiv1.Transaction) *entity.Transaction { } } +// splitChains splits a comma-separated chain string, defaulting to ["eth"] when empty. +func splitChains(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return []string{"eth"} + } + parts := strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ' ' }) + if len(parts) == 0 { + return []string{"eth"} + } + result := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + result = append(result, p) + } + } + return result +} + func transactionToProto(t *entity.Transaction) *apiv1.Transaction { return &apiv1.Transaction{ Id: t.ID, diff --git a/internal/service/portfolio/handler_test.go b/internal/service/portfolio/handler_test.go index bb56732..f9a563c 100644 --- a/internal/service/portfolio/handler_test.go +++ b/internal/service/portfolio/handler_test.go @@ -9,10 +9,11 @@ import ( "time" "connectrpc.com/connect" - apiv1 "github.com/foxcool/greedy-eye/internal/api/v1" + apiv1 "github.com/foxcool/greedy-eye/api/v1" "github.com/foxcool/greedy-eye/internal/entity" "github.com/foxcool/greedy-eye/internal/middleware" "github.com/foxcool/greedy-eye/internal/store" + "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -167,15 +168,15 @@ func (m *mockStore) ListTransactions(ctx context.Context, opts ListTransactionsO // Fixed UUID v7 constants for use across unit tests. // Using real UUID format so tests remain valid if UUID validation moves to handler layer. const ( - testUserID = "01926d35-6a1e-7001-8001-000000000001" - testUserID2 = "01926d35-6a1e-7001-8001-000000000002" - testPortfolioID = "01926d35-6a1e-7002-8002-000000000001" + testUserID = "01926d35-6a1e-7001-8001-000000000001" + testUserID2 = "01926d35-6a1e-7001-8001-000000000002" + testPortfolioID = "01926d35-6a1e-7002-8002-000000000001" testPortfolioID2 = "01926d35-6a1e-7002-8002-000000000002" - testAccountID = "01926d35-6a1e-7003-8003-000000000001" - testHoldingID = "01926d35-6a1e-7004-8004-000000000001" - testHoldingID2 = "01926d35-6a1e-7004-8004-000000000002" - testAssetID = "01926d35-6a1e-7005-8005-000000000001" - testTxID = "01926d35-6a1e-7006-8006-000000000001" + testAccountID = "01926d35-6a1e-7003-8003-000000000001" + testHoldingID = "01926d35-6a1e-7004-8004-000000000001" + testHoldingID2 = "01926d35-6a1e-7004-8004-000000000002" + testAssetID = "01926d35-6a1e-7005-8005-000000000001" + testTxID = "01926d35-6a1e-7006-8006-000000000001" ) // --- Helpers --- @@ -212,7 +213,7 @@ func testAccount(id string) *entity.Account { func testHolding(id string) *entity.Holding { return &entity.Holding{ ID: id, - Amount: 100000, + Amount: decimal.NewFromInt(100000), Decimals: 8, AssetID: testAssetID, AccountID: testAccountID, @@ -367,7 +368,7 @@ func TestCreateHolding_OK(t *testing.T) { h := newHandler(s) resp, err := h.CreateHolding(context.Background(), connect.NewRequest(&apiv1.CreateHoldingRequest{ - Holding: &apiv1.Holding{AssetId: testAssetID, AccountId: testAccountID, Amount: 100000, Decimals: 8}, + Holding: &apiv1.Holding{AssetId: testAssetID, AccountId: testAccountID, Amount: "100000", Decimals: 8}, })) require.NoError(t, err) assert.Equal(t, testHoldingID, resp.Msg.Id) @@ -376,11 +377,11 @@ func TestCreateHolding_OK(t *testing.T) { func TestListHoldings_WithFilters(t *testing.T) { s := &mockStore{} portfolioID := testPortfolioID - s.On("ListHoldings", mock.Anything, ListHoldingsOpts{PortfolioID: testPortfolioID}). + s.On("ListHoldings", mock.Anything, ListHoldingsOpts{UserID: testUserID, PortfolioID: testPortfolioID}). Return([]*entity.Holding{testHolding(testHoldingID), testHolding(testHoldingID2)}, "next", nil) h := newHandler(s) - resp, err := h.ListHoldings(context.Background(), connect.NewRequest(&apiv1.ListHoldingsRequest{ + resp, err := h.ListHoldings(ctxWithUser(testUserID), connect.NewRequest(&apiv1.ListHoldingsRequest{ PortfolioId: &portfolioID, })) require.NoError(t, err) @@ -388,6 +389,14 @@ func TestListHoldings_WithFilters(t *testing.T) { assert.Equal(t, "next", resp.Msg.NextPageToken) } +func TestListHoldings_Unauthenticated(t *testing.T) { + h := newHandler(&mockStore{}) + + _, err := h.ListHoldings(context.Background(), connect.NewRequest(&apiv1.ListHoldingsRequest{})) + require.Error(t, err) + assert.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err)) +} + // --- Tests: Transaction --- func TestCreateTransaction_MissingTransaction(t *testing.T) { @@ -416,6 +425,43 @@ func TestCreateTransaction_OK(t *testing.T) { assert.Equal(t, testTxID, resp.Msg.Id) } +// TestCalculatePortfolioValue_CrossRate verifies a holding priced only in its own +// traded pair (USDT) is valued in the requested quote (USD) via a cross rate, and that +// a depeg in the USDT/USD leg is reflected rather than assuming USDT == 1 USD. +func TestCalculatePortfolioValue_CrossRate(t *testing.T) { + const ( + assetX = "00000000-0000-0000-0000-0000000000a1" + usdtUUID = "00000000-0000-0000-0000-0000000000d7" + ) + s := &mockStore{} + s.On("ListHoldings", mock.Anything, mock.Anything).Return([]*entity.Holding{{ + ID: testHoldingID, AssetID: assetX, Amount: decimal.NewFromInt(100000000), Decimals: 8, // 1.0 token + }}, "", nil) + + md := &mockMDClient{} + // 1. No direct X/USD price. + md.On("GetLatestPrice", mock.Anything, mock.MatchedBy(func(r *connect.Request[apiv1.GetLatestPriceRequest]) bool { + return r.Msg.AssetId == assetX && r.Msg.BaseAssetId == "USD" + })).Return(nil, connect.NewError(connect.CodeNotFound, errors.New("not found"))) + // 2. X actually trades in USDT at 2.0. + md.On("GetLatestPrice", mock.Anything, mock.MatchedBy(func(r *connect.Request[apiv1.GetLatestPriceRequest]) bool { + return r.Msg.AssetId == assetX && r.Msg.BaseAssetId == "" + })).Return(connect.NewResponse(&apiv1.Price{Last: "200000000", Decimals: 8, BaseAssetId: usdtUUID}), nil) + // 3. USDT depegged to 0.99 USD. + md.On("GetLatestPrice", mock.Anything, mock.MatchedBy(func(r *connect.Request[apiv1.GetLatestPriceRequest]) bool { + return r.Msg.AssetId == usdtUUID && r.Msg.BaseAssetId == "USD" + })).Return(connect.NewResponse(&apiv1.Price{Last: "99000000", Decimals: 8, BaseAssetId: "USD"}), nil) + + h := newHandler(s).WithMarketDataClient(md) + resp, err := h.CalculatePortfolioValue(context.Background(), connect.NewRequest(&apiv1.CalculatePortfolioValueRequest{ + PortfolioId: testPortfolioID, + })) + require.NoError(t, err) + // 1.0 token × 2.0 USDT × 0.99 USD/USDT = 1.98 USD → 198 (2 decimals). + assert.Equal(t, "198", resp.Msg.TotalValueAmount) + assert.Equal(t, uint32(2), resp.Msg.Decimals) +} + // --- Tests: Stubs return Unimplemented --- func TestStubs_ReturnUnimplemented(t *testing.T) { @@ -428,3 +474,149 @@ func TestStubs_ReturnUnimplemented(t *testing.T) { _, err = h.GetPortfolioPerformance(ctx, connect.NewRequest(&apiv1.GetPortfolioPerformanceRequest{})) assert.Equal(t, connect.CodeUnimplemented, connect.CodeOf(err)) } + +// --- Mock WalletSyncer --- + +type mockWalletSyncer struct { + mock.Mock +} + +func (m *mockWalletSyncer) SyncWallet(ctx context.Context, address string, chains []string) ([]entity.WalletBalance, error) { + args := m.Called(ctx, address, chains) + if v := args.Get(0); v != nil { + return v.([]entity.WalletBalance), args.Error(1) + } + return nil, args.Error(1) +} + +// --- Mock MarketDataClient --- + +type mockMDClient struct { + mock.Mock +} + +func (m *mockMDClient) CreateAsset(ctx context.Context, req *connect.Request[apiv1.CreateAssetRequest]) (*connect.Response[apiv1.Asset], error) { + args := m.Called(ctx, req) + if v := args.Get(0); v != nil { + return v.(*connect.Response[apiv1.Asset]), args.Error(1) + } + return nil, args.Error(1) +} + +func (m *mockMDClient) ListAssets(ctx context.Context, req *connect.Request[apiv1.ListAssetsRequest]) (*connect.Response[apiv1.ListAssetsResponse], error) { + args := m.Called(ctx, req) + if v := args.Get(0); v != nil { + return v.(*connect.Response[apiv1.ListAssetsResponse]), args.Error(1) + } + return nil, args.Error(1) +} + +func (m *mockMDClient) GetLatestPrice(ctx context.Context, req *connect.Request[apiv1.GetLatestPriceRequest]) (*connect.Response[apiv1.Price], error) { + args := m.Called(ctx, req) + if v := args.Get(0); v != nil { + return v.(*connect.Response[apiv1.Price]), args.Error(1) + } + return nil, args.Error(1) +} + +func (m *mockMDClient) ListPriceHistory(ctx context.Context, req *connect.Request[apiv1.ListPriceHistoryRequest]) (*connect.Response[apiv1.ListPriceHistoryResponse], error) { + args := m.Called(ctx, req) + if v := args.Get(0); v != nil { + return v.(*connect.Response[apiv1.ListPriceHistoryResponse]), args.Error(1) + } + return nil, args.Error(1) +} + +func (m *mockMDClient) FetchExternalPrices(ctx context.Context, req *connect.Request[apiv1.FetchExternalPricesRequest]) (*connect.Response[apiv1.FetchExternalPricesResponse], error) { + args := m.Called(ctx, req) + if v := args.Get(0); v != nil { + return v.(*connect.Response[apiv1.FetchExternalPricesResponse]), args.Error(1) + } + return nil, args.Error(1) +} + +// TestSyncAccount_LargeBalance verifies a uint256 balance that overflows int64 is stored +// losslessly as a decimal string (regression for the bigint storage limit). +func TestSyncAccount_LargeBalance(t *testing.T) { + // 1000 ETH at 18 decimals = 1e21, well above int64 max (~9.2e18). + const bigAmount = "1000000000000000000000" + + acct := testAccount(testAccountID) + acct.Type = entity.AccountTypeWallet + acct.Data = map[string]string{"address": "0xabc", "chain": "eth"} + + s := &mockStore{} + s.On("GetAccount", mock.Anything, testAccountID).Return(acct, nil) + s.On("ListHoldings", mock.Anything, mock.Anything).Return([]*entity.Holding{}, "", nil) + // The created holding must carry the full uint256 string, not a truncated int64. + s.On("CreateHolding", mock.Anything, mock.MatchedBy(func(h *entity.Holding) bool { + return h.Amount.String() == bigAmount && h.Decimals == 18 + })).Return(&entity.Holding{ID: testHoldingID}, nil) + + ws := &mockWalletSyncer{} + ws.On("SyncWallet", mock.Anything, "0xabc", []string{"eth"}).Return([]entity.WalletBalance{ + {Symbol: "ETH", Name: "Ethereum", Amount: bigAmount, Decimals: 18}, + }, nil) + + md := &mockMDClient{} + md.On("ListAssets", mock.Anything, mock.Anything). + Return(connect.NewResponse(&apiv1.ListAssetsResponse{}), nil) + md.On("CreateAsset", mock.Anything, mock.Anything). + Return(connect.NewResponse(&apiv1.Asset{Id: testAssetID}), nil) + md.On("FetchExternalPrices", mock.Anything, mock.Anything). + Return(connect.NewResponse(&apiv1.FetchExternalPricesResponse{}), nil) + + h := newHandler(s).WithMarketDataClient(md).WithWalletSyncer(ws) + + resp, err := h.SyncAccount(context.Background(), connect.NewRequest(&apiv1.SyncAccountRequest{ + AccountId: testAccountID, + })) + require.NoError(t, err) + assert.Empty(t, resp.Msg.Errors) + assert.Equal(t, int32(1), resp.Msg.AssetsUpserted) + assert.Equal(t, int32(1), resp.Msg.HoldingsUpserted) + s.AssertExpectations(t) +} + +// TestSyncAccount_MergeMixedDecimals verifies the same symbol across chains with +// different decimals (USDC is 6 on Ethereum, 18 on BSC) and mixed case is merged by +// real quantity, not by summing raw integers at mismatched scales. +func TestSyncAccount_MergeMixedDecimals(t *testing.T) { + acct := testAccount(testAccountID) + acct.Type = entity.AccountTypeWallet + acct.Data = map[string]string{"address": "0xabc", "chain": "eth,bsc"} + + s := &mockStore{} + s.On("GetAccount", mock.Anything, testAccountID).Return(acct, nil) + s.On("ListHoldings", mock.Anything, mock.Anything).Return([]*entity.Holding{}, "", nil) + // 1.0 USDC (6 dec) + 2.0 USDC (18 dec) = 3.0 USDC, stored at max decimals (18). + s.On("CreateHolding", mock.Anything, mock.MatchedBy(func(h *entity.Holding) bool { + return h.Amount.String() == "3000000000000000000" && h.Decimals == 18 + })).Return(&entity.Holding{ID: testHoldingID}, nil) + + ws := &mockWalletSyncer{} + ws.On("SyncWallet", mock.Anything, "0xabc", []string{"eth", "bsc"}).Return([]entity.WalletBalance{ + {Symbol: "usdc", Name: "USD Coin", Amount: "1000000", Decimals: 6}, // 1.0 on Ethereum + {Symbol: "USDC", Name: "USD Coin", Amount: "2000000000000000000", Decimals: 18}, // 2.0 on BSC + }, nil) + + md := &mockMDClient{} + md.On("ListAssets", mock.Anything, mock.Anything). + Return(connect.NewResponse(&apiv1.ListAssetsResponse{}), nil) + md.On("CreateAsset", mock.Anything, mock.Anything). + Return(connect.NewResponse(&apiv1.Asset{Id: testAssetID}), nil) + md.On("FetchExternalPrices", mock.Anything, mock.Anything). + Return(connect.NewResponse(&apiv1.FetchExternalPricesResponse{}), nil) + + h := newHandler(s).WithMarketDataClient(md).WithWalletSyncer(ws) + + resp, err := h.SyncAccount(context.Background(), connect.NewRequest(&apiv1.SyncAccountRequest{ + AccountId: testAccountID, + })) + require.NoError(t, err) + assert.Empty(t, resp.Msg.Errors) + // One asset, one holding — the two chain balances collapsed into a single USDC holding. + assert.Equal(t, int32(1), resp.Msg.AssetsUpserted) + assert.Equal(t, int32(1), resp.Msg.HoldingsUpserted) + s.AssertExpectations(t) +} diff --git a/internal/service/portfolio/store.go b/internal/service/portfolio/store.go index 0ac7cc2..d663fad 100644 --- a/internal/service/portfolio/store.go +++ b/internal/service/portfolio/store.go @@ -4,7 +4,7 @@ import ( "context" "connectrpc.com/connect" - apiv1 "github.com/foxcool/greedy-eye/internal/api/v1" + apiv1 "github.com/foxcool/greedy-eye/api/v1" "github.com/foxcool/greedy-eye/internal/entity" ) @@ -66,11 +66,17 @@ type ListAccountsOpts struct { // ListHoldingsOpts contains options for listing holdings. type ListHoldingsOpts struct { + // UserID scopes results to holdings whose owning portfolio (own or + // inherited from the account) belongs to this user. Holdings outside + // any portfolio are scoped by account owner instead. + UserID string PortfolioID string AccountID string AssetID string PageSize int PageToken string + // HideExcluded filters out holdings where excluded=true when set to true. + HideExcluded bool } // ListTransactionsOpts contains options for listing transactions. diff --git a/internal/store/postgres/marketdata.go b/internal/store/postgres/marketdata.go index 0d34f50..456ebda 100644 --- a/internal/store/postgres/marketdata.go +++ b/internal/store/postgres/marketdata.go @@ -43,6 +43,8 @@ func (s *MarketDataStore) CreateAsset(ctx context.Context, asset *entity.Asset) return nil, fmt.Errorf("%w: asset type is required", store.ErrInvalidArgument) } + asset.Symbol = entity.NormalizeSymbol(asset.Symbol) + id, err := uuid.NewV7() if err != nil { return nil, fmt.Errorf("failed to generate ID: %w", err) @@ -118,6 +120,74 @@ func (s *MarketDataStore) GetAsset(ctx context.Context, id string) (*entity.Asse return &asset, nil } +// GetOrCreateAssetBySymbol returns an existing asset by symbol or creates a new one. +// typeIfNew and nameIfNew are used only when creating. Safe under concurrent inserts: +// if a concurrent write wins the race, the existing row is returned. +func (s *MarketDataStore) GetOrCreateAssetBySymbol(ctx context.Context, symbol, nameIfNew string, typeIfNew entity.AssetType) (*entity.Asset, error) { + a, err := s.GetAssetBySymbol(ctx, symbol) + if err == nil { + return a, nil + } + if !errors.Is(err, store.ErrNotFound) { + return nil, err + } + created, err := s.CreateAsset(ctx, &entity.Asset{ + Symbol: symbol, + Name: nameIfNew, + Type: typeIfNew, + Tags: []string{}, + }) + if err != nil { + // Concurrent insert: another process created it first — read back. + if errors.Is(err, store.ErrConstraint) { + return s.GetAssetBySymbol(ctx, symbol) + } + return nil, err + } + return created, nil +} + +// GetAssetBySymbol returns an asset by its symbol. The symbol is normalized +// (trim + uppercase) so lookups are case-insensitive. +func (s *MarketDataStore) GetAssetBySymbol(ctx context.Context, symbol string) (*entity.Asset, error) { + symbol = entity.NormalizeSymbol(symbol) + if symbol == "" { + return nil, fmt.Errorf("%w: symbol is required", store.ErrInvalidArgument) + } + + query := ` + SELECT id, symbol, name, type, tags, created_at, updated_at + FROM assets + WHERE symbol = $1` + + var asset entity.Asset + var typeStr string + var tagsJSON []byte + + err := s.pool.QueryRow(ctx, query, symbol).Scan( + &asset.ID, + &asset.Symbol, + &asset.Name, + &typeStr, + &tagsJSON, + &asset.CreatedAt, + &asset.UpdatedAt, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, fmt.Errorf("%w: asset with symbol %s", store.ErrNotFound, symbol) + } + return nil, fmt.Errorf("failed to get asset by symbol: %w", err) + } + + asset.Type = stringToAssetType(typeStr) + if err := json.Unmarshal(tagsJSON, &asset.Tags); err != nil { + return nil, fmt.Errorf("failed to unmarshal tags: %w", err) + } + + return &asset, nil +} + // UpdateAsset updates an asset with the specified fields. func (s *MarketDataStore) UpdateAsset(ctx context.Context, asset *entity.Asset, fields []string) (*entity.Asset, error) { if asset == nil || asset.ID == "" { @@ -135,7 +205,7 @@ func (s *MarketDataStore) UpdateAsset(ctx context.Context, asset *entity.Asset, switch field { case "symbol": setClauses = append(setClauses, fmt.Sprintf("symbol = $%d", argIdx)) - args = append(args, asset.Symbol) + args = append(args, entity.NormalizeSymbol(asset.Symbol)) argIdx++ case "name": setClauses = append(setClauses, fmt.Sprintf("name = $%d", argIdx)) @@ -361,36 +431,50 @@ func (s *MarketDataStore) CreatePrice(ctx context.Context, price *entity.StoredP } // CreatePrices creates multiple prices in bulk. +// Individual failures are counted and returned as a combined error so callers +// can surface partial success instead of silently dropping records. func (s *MarketDataStore) CreatePrices(ctx context.Context, prices []*entity.StoredPrice) (int, error) { count := 0 + var errs []string for _, p := range prices { - _, err := s.CreatePrice(ctx, p) - if err == nil { + if _, err := s.CreatePrice(ctx, p); err != nil { + errs = append(errs, fmt.Sprintf("%s/%s: %v", p.AssetID, p.BaseAssetID, err)) + } else { count++ } } + if len(errs) > 0 { + return count, fmt.Errorf("%d price(s) failed: %s", len(errs), strings.Join(errs, "; ")) + } return count, nil } // GetLatestPrice returns the most recent price for asset/base/source. func (s *MarketDataStore) GetLatestPrice(ctx context.Context, assetID, baseAssetID, sourceID string) (*entity.StoredPrice, error) { - if assetID == "" || baseAssetID == "" { - return nil, fmt.Errorf("%w: asset_id and base_asset_id are required", store.ErrInvalidArgument) + if assetID == "" { + return nil, fmt.Errorf("%w: asset_id is required", store.ErrInvalidArgument) } - args := []any{assetID, baseAssetID} - sourceFilter := "" + // base_asset_id and source_id are optional filters: empty means "any". Omitting + // base_asset_id yields the asset's latest price in whatever pair it trades against, + // which portfolio valuation uses to convert via cross rates. + args := []any{assetID} + filters := "" + if baseAssetID != "" { + args = append(args, baseAssetID) + filters += fmt.Sprintf(" AND base_asset_id = $%d", len(args)) + } if sourceID != "" { - sourceFilter = "AND source_id = $3" args = append(args, sourceID) + filters += fmt.Sprintf(" AND source_id = $%d", len(args)) } query := fmt.Sprintf(` SELECT id, source_id, asset_id, base_asset_id, interval, decimals, last, open, high, low, close, volume, timestamp FROM prices - WHERE asset_id = $1 AND base_asset_id = $2 %s + WHERE asset_id = $1%s ORDER BY timestamp DESC - LIMIT 1`, sourceFilter) + LIMIT 1`, filters) var price entity.StoredPrice err := s.pool.QueryRow(ctx, query, args...).Scan( @@ -592,9 +676,6 @@ func (s *MarketDataStore) DeletePrices(ctx context.Context, opts marketdata.Dele } if opts.BaseAssetID != "" { - if !isValidUUID(opts.BaseAssetID) { - return fmt.Errorf("%w: invalid base_asset_id format", store.ErrInvalidArgument) - } whereClauses = append(whereClauses, fmt.Sprintf("base_asset_id = $%d", argIdx)) args = append(args, opts.BaseAssetID) argIdx++ diff --git a/internal/store/postgres/marketdata_integration_test.go b/internal/store/postgres/marketdata_integration_test.go index cb297cb..e087ac8 100644 --- a/internal/store/postgres/marketdata_integration_test.go +++ b/internal/store/postgres/marketdata_integration_test.go @@ -14,6 +14,7 @@ import ( "github.com/foxcool/greedy-eye/internal/service/marketdata" "github.com/foxcool/greedy-eye/internal/store" "github.com/google/uuid" + "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -42,11 +43,9 @@ func createTestAsset(t *testing.T, s *MarketDataStore, name string) *entity.Asse func createTestPrice(t *testing.T, s *MarketDataStore, assetID, baseAssetID, sourceID string) *entity.StoredPrice { t.Helper() - open := rand.Int64N(1000000) - close := rand.Int64N(1000000) - high := rand.Int64N(1000000) - low := rand.Int64N(1000000) - volume := rand.Int64N(1000000) + nd := func() decimal.NullDecimal { + return decimal.NullDecimal{Decimal: decimal.NewFromInt(rand.Int64N(1000000)), Valid: true} + } price := &entity.StoredPrice{ SourceID: sourceID, @@ -54,12 +53,12 @@ func createTestPrice(t *testing.T, s *MarketDataStore, assetID, baseAssetID, sou BaseAssetID: baseAssetID, Interval: "latest", Decimals: 4, - Last: rand.Int64N(1000000), - Open: &open, - Close: &close, - High: &high, - Low: &low, - Volume: &volume, + Last: decimal.NewFromInt(rand.Int64N(1000000)), + Open: nd(), + Close: nd(), + High: nd(), + Low: nd(), + Volume: nd(), Timestamp: time.Now(), } @@ -274,7 +273,7 @@ func TestCreatePrice(t *testing.T) { AssetID: asset1.ID, BaseAssetID: asset2.ID, Interval: "1m", - Last: 1000000, + Last: decimal.NewFromInt(1000000), Decimals: 2, Timestamp: time.Now(), } @@ -289,7 +288,7 @@ func TestCreatePrice(t *testing.T) { SourceID: "binance", BaseAssetID: asset2.ID, Interval: "1m", - Last: 1000000, + Last: decimal.NewFromInt(1000000), } _, err := s.CreatePrice(context.Background(), price) assert.ErrorIs(t, err, store.ErrInvalidArgument) @@ -301,7 +300,7 @@ func TestCreatePrice(t *testing.T) { AssetID: uuid.New().String(), BaseAssetID: asset2.ID, Interval: "1m", - Last: 1000000, + Last: decimal.NewFromInt(1000000), } _, err := s.CreatePrice(context.Background(), price) assert.ErrorIs(t, err, store.ErrConstraint) diff --git a/internal/store/postgres/pool.go b/internal/store/postgres/pool.go new file mode 100644 index 0000000..4bacba2 --- /dev/null +++ b/internal/store/postgres/pool.go @@ -0,0 +1,26 @@ +package postgres + +import ( + "context" + "fmt" + + pgxdecimal "github.com/jackc/pgx-shopspring-decimal" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// NewPool creates a pgxpool with the shopspring/decimal codec registered on every +// connection, so NUMERIC columns scan to/from decimal.Decimal directly. All money +// fields (holding amounts, prices) use this codec — construct pools via NewPool, not +// pgxpool.New, or numeric scans into decimal.Decimal will fail. +func NewPool(ctx context.Context, url string) (*pgxpool.Pool, error) { + cfg, err := pgxpool.ParseConfig(url) + if err != nil { + return nil, fmt.Errorf("parse pool config: %w", err) + } + cfg.AfterConnect = func(_ context.Context, conn *pgx.Conn) error { + pgxdecimal.Register(conn.TypeMap()) + return nil + } + return pgxpool.NewWithConfig(ctx, cfg) +} diff --git a/internal/store/postgres/portfolio.go b/internal/store/postgres/portfolio.go index e692e6c..b171886 100644 --- a/internal/store/postgres/portfolio.go +++ b/internal/store/postgres/portfolio.go @@ -422,6 +422,9 @@ func (s *PortfolioStore) UpdateAccount(ctx context.Context, a *entity.Account, f args = append(args, accountTypeToString(a.Type)) argIdx++ case "data": + if a.Data == nil { + continue // Not provided — preserve existing credentials + } dataJSON, err := json.Marshal(a.Data) if err != nil { return nil, fmt.Errorf("failed to marshal data: %w", err) @@ -599,8 +602,8 @@ func (s *PortfolioStore) CreateHolding(ctx context.Context, h *entity.Holding) ( } query := ` - INSERT INTO holdings (id, amount, decimals, asset_id, account_id, portfolio_id, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) + INSERT INTO holdings (id, amount, decimals, asset_id, account_id, portfolio_id, excluded, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) RETURNING created_at, updated_at` err = s.pool.QueryRow(ctx, query, @@ -610,6 +613,7 @@ func (s *PortfolioStore) CreateHolding(ctx context.Context, h *entity.Holding) ( h.AssetID, h.AccountID, portfolioID, + h.Excluded, ).Scan(&h.CreatedAt, &h.UpdatedAt) if err != nil { if isConstraintError(err) { @@ -630,7 +634,7 @@ func (s *PortfolioStore) GetHolding(ctx context.Context, id string) (*entity.Hol } query := ` - SELECT id, amount, decimals, asset_id, account_id, portfolio_id, created_at, updated_at + SELECT id, amount, decimals, asset_id, account_id, portfolio_id, excluded, created_at, updated_at FROM holdings WHERE id = $1` @@ -644,6 +648,7 @@ func (s *PortfolioStore) GetHolding(ctx context.Context, id string) (*entity.Hol &h.AssetID, &h.AccountID, &portfolioID, + &h.Excluded, &h.CreatedAt, &h.UpdatedAt, ) @@ -691,6 +696,10 @@ func (s *PortfolioStore) UpdateHolding(ctx context.Context, h *entity.Holding, f setClauses = append(setClauses, fmt.Sprintf("portfolio_id = $%d", argIdx)) args = append(args, portfolioID) argIdx++ + case "excluded": + setClauses = append(setClauses, fmt.Sprintf("excluded = $%d", argIdx)) + args = append(args, h.Excluded) + argIdx++ } } @@ -742,28 +751,44 @@ func (s *PortfolioStore) ListHoldings(ctx context.Context, opts portfolio.ListHo argIdx := 1 whereClauses := []string{} + // Scope by owning portfolio (holding's own or inherited from account). + // Holdings outside any portfolio fall back to the account owner. + // Revisit when shared portfolio ownership lands: replace the p.user_id + // check with a portfolio-visibility predicate. + if opts.UserID != "" { + whereClauses = append(whereClauses, fmt.Sprintf("(p.user_id = $%d OR (p.id IS NULL AND a.user_id = $%d))", argIdx, argIdx)) + args = append(args, opts.UserID) + argIdx++ + } + + // Use COALESCE(h.portfolio_id, a.portfolio_id) so holdings with NULL portfolio_id + // inherit the account's portfolio_id for filtering. if opts.PortfolioID != "" { - whereClauses = append(whereClauses, fmt.Sprintf("portfolio_id = $%d", argIdx)) + whereClauses = append(whereClauses, fmt.Sprintf("COALESCE(h.portfolio_id, a.portfolio_id) = $%d", argIdx)) args = append(args, opts.PortfolioID) argIdx++ } if opts.AccountID != "" { - whereClauses = append(whereClauses, fmt.Sprintf("account_id = $%d", argIdx)) + whereClauses = append(whereClauses, fmt.Sprintf("h.account_id = $%d", argIdx)) args = append(args, opts.AccountID) argIdx++ } if opts.AssetID != "" { - whereClauses = append(whereClauses, fmt.Sprintf("asset_id = $%d", argIdx)) + whereClauses = append(whereClauses, fmt.Sprintf("h.asset_id = $%d", argIdx)) args = append(args, opts.AssetID) argIdx++ } + if opts.HideExcluded { + whereClauses = append(whereClauses, "h.excluded = false") + } + if opts.PageToken != "" { decoded, err := base64.StdEncoding.DecodeString(opts.PageToken) if err == nil && isValidUUID(string(decoded)) { - whereClauses = append(whereClauses, fmt.Sprintf("id > $%d", argIdx)) + whereClauses = append(whereClauses, fmt.Sprintf("h.id > $%d", argIdx)) args = append(args, string(decoded)) argIdx++ } @@ -775,10 +800,12 @@ func (s *PortfolioStore) ListHoldings(ctx context.Context, opts portfolio.ListHo } query := fmt.Sprintf(` - SELECT id, amount, decimals, asset_id, account_id, portfolio_id, created_at, updated_at - FROM holdings + SELECT h.id, h.amount, h.decimals, h.asset_id, h.account_id, h.portfolio_id, h.excluded, h.created_at, h.updated_at + FROM holdings h + LEFT JOIN accounts a ON a.id = h.account_id + LEFT JOIN portfolios p ON p.id = COALESCE(h.portfolio_id, a.portfolio_id) %s - ORDER BY id + ORDER BY h.id LIMIT $%d`, whereClause, argIdx) args = append(args, limit+1) @@ -801,6 +828,7 @@ func (s *PortfolioStore) ListHoldings(ctx context.Context, opts portfolio.ListHo &h.AssetID, &h.AccountID, &portfolioID, + &h.Excluded, &h.CreatedAt, &h.UpdatedAt, ); err != nil { diff --git a/internal/store/postgres/testdb_test.go b/internal/store/postgres/testdb_test.go index 5f32ba4..5844b88 100644 --- a/internal/store/postgres/testdb_test.go +++ b/internal/store/postgres/testdb_test.go @@ -60,7 +60,7 @@ func NewTestDB(ctx context.Context) (*TestDB, error) { return nil, fmt.Errorf("apply schema: %w", err) } - pool, err := pgxpool.New(ctx, connStr) + pool, err := NewPool(ctx, connStr) if err != nil { container.Terminate(ctx) return nil, fmt.Errorf("create connection pool: %w", err) diff --git a/internal/store/postgres/testutil_test.go b/internal/store/postgres/testutil_test.go index 4f501e3..7f4f2e2 100644 --- a/internal/store/postgres/testutil_test.go +++ b/internal/store/postgres/testutil_test.go @@ -51,4 +51,3 @@ func getTestPool(t *testing.T) *pgxpool.Pool { return testDB.Pool } - diff --git a/pkg/packages.md b/pkg/packages.md deleted file mode 100644 index 11a08e8..0000000 --- a/pkg/packages.md +++ /dev/null @@ -1,2 +0,0 @@ -# Packages - diff --git a/schema.hcl b/schema.hcl index af2cba1..74296ff 100644 --- a/schema.hcl +++ b/schema.hcl @@ -132,6 +132,11 @@ table "assets" { columns = [column.id] } + index "asset_symbol" { + columns = [column.symbol] + unique = true + } + index "asset_tags" { columns = [column.tags] type = GIN @@ -198,7 +203,8 @@ table "holdings" { null = false } column "amount" { - type = bigint + # NUMERIC (arbitrary precision) holds raw uint256 token balances that overflow bigint. + type = numeric null = false } column "decimals" { @@ -217,6 +223,11 @@ table "holdings" { type = uuid null = true } + column "excluded" { + type = boolean + null = false + default = false + } primary_key { columns = [column.id] @@ -264,27 +275,28 @@ table "prices" { null = false } column "last" { - type = bigint + # NUMERIC: raw integer price scaled by decimals; arbitrary precision, no overflow. + type = numeric null = false } column "open" { - type = bigint + type = numeric null = true } column "high" { - type = bigint + type = numeric null = true } column "low" { - type = bigint + type = numeric null = true } column "close" { - type = bigint + type = numeric null = true } column "volume" { - type = bigint + type = numeric null = true } column "timestamp" { @@ -309,18 +321,18 @@ table "prices" { unique = true } - foreign_key "prices_assets_prices" { + foreign_key "prices_assets_asset" { columns = [column.asset_id] ref_columns = [table.assets.column.id] on_update = NO_ACTION on_delete = NO_ACTION } - foreign_key "prices_assets_prices_base" { + foreign_key "prices_assets_base_asset" { columns = [column.base_asset_id] ref_columns = [table.assets.column.id] on_update = NO_ACTION - on_delete = NO_ACTION + on_delete = RESTRICT } } diff --git a/test/smoke/asset_test.go b/test/smoke/asset_test.go new file mode 100644 index 0000000..348f050 --- /dev/null +++ b/test/smoke/asset_test.go @@ -0,0 +1,104 @@ +//go:build smoke + +package smoke_test + +import ( + "context" + "net/http" + "testing" + + "connectrpc.com/connect" + v1 "github.com/foxcool/greedy-eye/api/v1" + "github.com/foxcool/greedy-eye/api/v1/apiv1connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/fieldmaskpb" +) + +func TestAssetCRUD(t *testing.T) { + resetDB(t) + ctx := context.Background() + client := newMDClient(smokeTestUserID) + + sym := "BTC" + + // Create + createResp, err := client.CreateAsset(ctx, connect.NewRequest(&v1.CreateAssetRequest{ + Asset: &v1.Asset{ + Name: "Bitcoin", + Symbol: &sym, + Type: v1.AssetType_ASSET_TYPE_CRYPTOCURRENCY, + Tags: []string{"crypto", "pow"}, + }, + })) + require.NoError(t, err) + btcID := createResp.Msg.GetId() + require.NotEmpty(t, btcID) + assert.Equal(t, "Bitcoin", createResp.Msg.GetName()) + assert.Equal(t, "BTC", createResp.Msg.GetSymbol()) + + // Get + getResp, err := client.GetAsset(ctx, connect.NewRequest(&v1.GetAssetRequest{Id: btcID})) + require.NoError(t, err) + assert.Equal(t, btcID, getResp.Msg.GetId()) + assert.Equal(t, "Bitcoin", getResp.Msg.GetName()) + + // Update name via UpdateMask + updatedName := "Bitcoin (Updated)" + updateResp, err := client.UpdateAsset(ctx, connect.NewRequest(&v1.UpdateAssetRequest{ + Asset: &v1.Asset{ + Id: btcID, + Name: updatedName, + }, + UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"name"}}, + })) + require.NoError(t, err) + assert.Equal(t, updatedName, updateResp.Msg.GetName()) + + // List — create two more, then paginate + ethSym := "ETH" + _, err = client.CreateAsset(ctx, connect.NewRequest(&v1.CreateAssetRequest{ + Asset: &v1.Asset{Name: "Ethereum", Symbol: ðSym, Type: v1.AssetType_ASSET_TYPE_CRYPTOCURRENCY}, + })) + require.NoError(t, err) + + solSym := "SOL" + _, err = client.CreateAsset(ctx, connect.NewRequest(&v1.CreateAssetRequest{ + Asset: &v1.Asset{Name: "Solana", Symbol: &solSym, Type: v1.AssetType_ASSET_TYPE_CRYPTOCURRENCY}, + })) + require.NoError(t, err) + + pageSize := int32(2) + listResp, err := client.ListAssets(ctx, connect.NewRequest(&v1.ListAssetsRequest{ + PageSize: &pageSize, + })) + require.NoError(t, err) + assert.Len(t, listResp.Msg.GetAssets(), 2) + assert.NotEmpty(t, listResp.Msg.GetNextPageToken(), "expect more pages") + + // Delete + _, err = client.DeleteAsset(ctx, connect.NewRequest(&v1.DeleteAssetRequest{Id: btcID})) + require.NoError(t, err) + + // Get deleted — expect NotFound + _, err = client.GetAsset(ctx, connect.NewRequest(&v1.GetAssetRequest{Id: btcID})) + require.Error(t, err) + var connectErr *connect.Error + require.ErrorAs(t, err, &connectErr) + assert.Equal(t, connect.CodeNotFound, connectErr.Code()) +} + +func TestAssetCRUD_NoUserHeader(t *testing.T) { + resetDB(t) + ctx := context.Background() + + sym := "BTC" + bare := apiv1connect.NewMarketDataServiceClient(http.DefaultClient, serverURL) + _, err := bare.CreateAsset(ctx, connect.NewRequest(&v1.CreateAssetRequest{ + Asset: &v1.Asset{Name: "Bitcoin", Symbol: &sym, Type: v1.AssetType_ASSET_TYPE_CRYPTOCURRENCY}, + })) + require.Error(t, err) + var connectErr *connect.Error + require.ErrorAs(t, err, &connectErr) + assert.Equal(t, connect.CodeUnauthenticated, connectErr.Code()) +} diff --git a/test/smoke/client_test.go b/test/smoke/client_test.go new file mode 100644 index 0000000..0d85e5b --- /dev/null +++ b/test/smoke/client_test.go @@ -0,0 +1,61 @@ +//go:build smoke + +package smoke_test + +import ( + "context" + "net/http" + + "connectrpc.com/connect" + "github.com/foxcool/greedy-eye/api/v1/apiv1connect" +) + +// headerInterceptor is a client-side Connect interceptor that adds HTTP headers to each request. +type headerInterceptor map[string]string + +func (h headerInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + for k, v := range h { + req.Header().Set(k, v) + } + return next(ctx, req) + } +} + +func (h headerInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc { + return next +} + +func (h headerInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc { + return next +} + +func newMDClient(userID string) apiv1connect.MarketDataServiceClient { + return apiv1connect.NewMarketDataServiceClient( + http.DefaultClient, serverURL, + connect.WithInterceptors(userHeaders(userID)), + ) +} + +func newPortfolioClient(userID string) apiv1connect.PortfolioServiceClient { + return apiv1connect.NewPortfolioServiceClient( + http.DefaultClient, serverURL, + connect.WithInterceptors(userHeaders(userID)), + ) +} + +func newAutomationClient(userID string) apiv1connect.AutomationServiceClient { + return apiv1connect.NewAutomationServiceClient( + http.DefaultClient, serverURL, + connect.WithInterceptors(userHeaders(userID)), + ) +} + +// userHeaders returns an interceptor that sets X-User-Id and X-User-Email. +// Email is derived from the user ID to satisfy the unique constraint on users.email. +func userHeaders(userID string) headerInterceptor { + return headerInterceptor{ + "X-User-Id": userID, + "X-User-Email": userID + "@smoke.test", + } +} diff --git a/test/smoke/main_test.go b/test/smoke/main_test.go new file mode 100644 index 0000000..57ebfb5 --- /dev/null +++ b/test/smoke/main_test.go @@ -0,0 +1,44 @@ +//go:build smoke + +package smoke_test + +import ( + "context" + "log/slog" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +var ( + serverURL string + dbPool *pgxpool.Pool +) + +func TestMain(m *testing.M) { + log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + ctx := context.Background() + + serverURL = backendURL() + log.Info("smoke tests targeting backend", "url", serverURL) + + var err error + dbPool, err = pgxpool.New(ctx, os.Getenv("EYE_DB_URL")) + if err != nil { + log.Error("failed to connect to database", "error", err) + os.Exit(1) + } + + code := m.Run() + dbPool.Close() + os.Exit(code) +} + +// backendURL returns the backend URL, defaulting to the compose service name. +func backendURL() string { + if u := os.Getenv("SMOKE_BACKEND_URL"); u != "" { + return u + } + return "http://eye-dev:8080" +} diff --git a/test/smoke/portfolio_test.go b/test/smoke/portfolio_test.go new file mode 100644 index 0000000..6ba0430 --- /dev/null +++ b/test/smoke/portfolio_test.go @@ -0,0 +1,116 @@ +//go:build smoke + +package smoke_test + +import ( + "context" + "testing" + + "connectrpc.com/connect" + v1 "github.com/foxcool/greedy-eye/api/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPortfolioCRUD(t *testing.T) { + resetDB(t) + ctx := context.Background() + client := newPortfolioClient(smokeTestUserID) + + // Create + createResp, err := client.CreatePortfolio(ctx, connect.NewRequest(&v1.CreatePortfolioRequest{ + Portfolio: &v1.Portfolio{Name: "My Crypto"}, + })) + require.NoError(t, err) + portID := createResp.Msg.GetId() + require.NotEmpty(t, portID) + assert.Equal(t, "My Crypto", createResp.Msg.GetName()) + assert.NotEmpty(t, createResp.Msg.GetUserId(), "user_id must be set from X-User-Id") + + // Get + getResp, err := client.GetPortfolio(ctx, connect.NewRequest(&v1.GetPortfolioRequest{Id: portID})) + require.NoError(t, err) + assert.Equal(t, portID, getResp.Msg.GetId()) + + // Update name + desc := "Updated description" + _, err = client.UpdatePortfolio(ctx, connect.NewRequest(&v1.UpdatePortfolioRequest{ + Portfolio: &v1.Portfolio{Id: portID, Name: "My Crypto", Description: &desc}, + })) + require.NoError(t, err) + + // List — user sees their own portfolio + listResp, err := client.ListPortfolios(ctx, connect.NewRequest(&v1.ListPortfoliosRequest{})) + require.NoError(t, err) + assert.Len(t, listResp.Msg.GetPortfolios(), 1) + + // User isolation: a different user sees no portfolios + otherClient := newPortfolioClient(smokeTestOtherUserID) + otherResp, err := otherClient.ListPortfolios(ctx, connect.NewRequest(&v1.ListPortfoliosRequest{})) + require.NoError(t, err) + assert.Empty(t, otherResp.Msg.GetPortfolios(), "other user must not see portfolios they don't own") + + // Delete + _, err = client.DeletePortfolio(ctx, connect.NewRequest(&v1.DeletePortfolioRequest{Id: portID})) + require.NoError(t, err) + + _, err = client.GetPortfolio(ctx, connect.NewRequest(&v1.GetPortfolioRequest{Id: portID})) + require.Error(t, err) + var connectErr *connect.Error + require.ErrorAs(t, err, &connectErr) + assert.Equal(t, connect.CodeNotFound, connectErr.Code()) +} + +func TestAccountCRUD(t *testing.T) { + resetDB(t) + ctx := context.Background() + client := newPortfolioClient(smokeTestUserID) + + // Create a portfolio to attach the account to + portResp, err := client.CreatePortfolio(ctx, connect.NewRequest(&v1.CreatePortfolioRequest{ + Portfolio: &v1.Portfolio{Name: "Test Portfolio"}, + })) + require.NoError(t, err) + portID := portResp.Msg.GetId() + + // Create wallet account + walletAddr := "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" + createResp, err := client.CreateAccount(ctx, connect.NewRequest(&v1.CreateAccountRequest{ + Account: &v1.Account{ + Name: "Vitalik's Wallet", + Type: v1.AccountType_ACCOUNT_TYPE_WALLET, + PortfolioId: &portID, + Data: map[string]string{"address": walletAddr}, + }, + })) + require.NoError(t, err) + accountID := createResp.Msg.GetId() + require.NotEmpty(t, accountID) + assert.Equal(t, walletAddr, createResp.Msg.GetData()["address"]) + + // Get + getResp, err := client.GetAccount(ctx, connect.NewRequest(&v1.GetAccountRequest{Id: accountID})) + require.NoError(t, err) + assert.Equal(t, accountID, getResp.Msg.GetId()) + + // Update name + _, err = client.UpdateAccount(ctx, connect.NewRequest(&v1.UpdateAccountRequest{ + Account: &v1.Account{Id: accountID, Name: "Vitalik's ETH Wallet", Type: v1.AccountType_ACCOUNT_TYPE_WALLET}, + })) + require.NoError(t, err) + + // List accounts for this user + listResp, err := client.ListAccounts(ctx, connect.NewRequest(&v1.ListAccountsRequest{})) + require.NoError(t, err) + assert.Len(t, listResp.Msg.GetAccounts(), 1) + + // Delete + _, err = client.DeleteAccount(ctx, connect.NewRequest(&v1.DeleteAccountRequest{Id: accountID})) + require.NoError(t, err) + + _, err = client.GetAccount(ctx, connect.NewRequest(&v1.GetAccountRequest{Id: accountID})) + require.Error(t, err) + var connectErr *connect.Error + require.ErrorAs(t, err, &connectErr) + assert.Equal(t, connect.CodeNotFound, connectErr.Code()) +} diff --git a/test/smoke/prices_test.go b/test/smoke/prices_test.go new file mode 100644 index 0000000..040ee23 --- /dev/null +++ b/test/smoke/prices_test.go @@ -0,0 +1,103 @@ +//go:build smoke + +package smoke_test + +import ( + "context" + "os" + "testing" + + "connectrpc.com/connect" + binanceadapter "github.com/foxcool/greedy-eye/internal/adapter/binance" + v1 "github.com/foxcool/greedy-eye/api/v1" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// assertPositivePrice parses a raw integer price string and asserts it is > 0. +func assertPositivePrice(t *testing.T, raw, msg string) { + t.Helper() + d, err := decimal.NewFromString(raw) + require.NoError(t, err, "price must be a valid decimal string") + assert.True(t, d.IsPositive(), msg) +} + +// TestFetchExternalPrices_Binance always runs — Binance ticker endpoint is public (no API key). +// Creates a BTC asset, fetches its price from Binance, verifies the price is stored. +func TestFetchExternalPrices_Binance(t *testing.T) { + resetDB(t) + ctx := context.Background() + client := newMDClient(smokeTestUserID) + + sym := "BTC" + createResp, err := client.CreateAsset(ctx, connect.NewRequest(&v1.CreateAssetRequest{ + Asset: &v1.Asset{ + Name: "Bitcoin", + Symbol: &sym, + Type: v1.AssetType_ASSET_TYPE_CRYPTOCURRENCY, + }, + })) + require.NoError(t, err) + btcID := createResp.Msg.GetId() + + fetchResp, err := client.FetchExternalPrices(ctx, connect.NewRequest(&v1.FetchExternalPricesRequest{ + SourceIds: []string{binanceadapter.ProviderName}, + AssetIds: []string{btcID}, + })) + require.NoError(t, err) + assert.Empty(t, fetchResp.Msg.GetErrors(), "expected no fetch errors") + assert.Greater(t, fetchResp.Msg.GetPricesFetched(), int32(0), "expected at least one price fetched") + assert.Greater(t, fetchResp.Msg.GetPricesStored(), int32(0), "expected at least one price stored") + + // Binance uses USDT as the quote currency — resolve via symbol "USDT" + latestResp, err := client.GetLatestPrice(ctx, connect.NewRequest(&v1.GetLatestPriceRequest{ + AssetId: btcID, + BaseAssetId: "USDT", + })) + require.NoError(t, err) + assertPositivePrice(t, latestResp.Msg.GetLast(), "BTC price should be > 0") + assert.Equal(t, binanceadapter.ProviderName, latestResp.Msg.GetSourceId()) +} + +// TestFetchExternalPrices_CoinGecko requires EYE_COINGECKO_APIKEY to be set. +// Fetches BTC and ETH prices from CoinGecko and verifies storage. +func TestFetchExternalPrices_CoinGecko(t *testing.T) { + if os.Getenv("EYE_COINGECKO_APIKEY") == "" { + t.Skip("EYE_COINGECKO_APIKEY not set — skipping CoinGecko price fetch test") + } + + resetDB(t) + ctx := context.Background() + client := newMDClient(smokeTestUserID) + + // CoinGecko provider resolves BTC by symbol via its internal nativeCoinID map. + // "btc" → "bitcoin" is a hardcoded mapping; no special tags needed. + btcSym := "BTC" + createResp, err := client.CreateAsset(ctx, connect.NewRequest(&v1.CreateAssetRequest{ + Asset: &v1.Asset{ + Name: "Bitcoin", + Symbol: &btcSym, + Type: v1.AssetType_ASSET_TYPE_CRYPTOCURRENCY, + }, + })) + require.NoError(t, err) + btcID := createResp.Msg.GetId() + + fetchResp, err := client.FetchExternalPrices(ctx, connect.NewRequest(&v1.FetchExternalPricesRequest{ + SourceIds: []string{"coingecko"}, + AssetIds: []string{btcID}, + })) + require.NoError(t, err) + assert.Empty(t, fetchResp.Msg.GetErrors(), "expected no fetch errors") + assert.Greater(t, fetchResp.Msg.GetPricesFetched(), int32(0)) + assert.Greater(t, fetchResp.Msg.GetPricesStored(), int32(0)) + + latestResp, err := client.GetLatestPrice(ctx, connect.NewRequest(&v1.GetLatestPriceRequest{ + AssetId: btcID, + BaseAssetId: "USD", + })) + require.NoError(t, err) + assertPositivePrice(t, latestResp.Msg.GetLast(), "BTC/USD price should be > 0") + assert.Equal(t, "coingecko", latestResp.Msg.GetSourceId()) +} diff --git a/test/smoke/sync_test.go b/test/smoke/sync_test.go new file mode 100644 index 0000000..1c58e5a --- /dev/null +++ b/test/smoke/sync_test.go @@ -0,0 +1,111 @@ +//go:build smoke + +package smoke_test + +import ( + "context" + "os" + "testing" + + "connectrpc.com/connect" + v1 "github.com/foxcool/greedy-eye/api/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSyncAccount_NoMoralisKey verifies that SyncAccount returns CodeUnimplemented +// when the server was started without a Moralis API key (walletSyncer == nil). +func TestSyncAccount_NoMoralisKey(t *testing.T) { + if os.Getenv("EYE_MORALIS_APIKEY") != "" { + t.Skip("Moralis key present — run TestSyncAccount_WithMoralis instead") + } + + resetDB(t) + ctx := context.Background() + client := newPortfolioClient(smokeTestUserID) + + portResp, err := client.CreatePortfolio(ctx, connect.NewRequest(&v1.CreatePortfolioRequest{ + Portfolio: &v1.Portfolio{Name: "Test Portfolio"}, + })) + require.NoError(t, err) + portID := portResp.Msg.GetId() + + walletAddr := "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" + acctResp, err := client.CreateAccount(ctx, connect.NewRequest(&v1.CreateAccountRequest{ + Account: &v1.Account{ + Name: "Vitalik's Wallet", + Type: v1.AccountType_ACCOUNT_TYPE_WALLET, + PortfolioId: &portID, + Data: map[string]string{"address": walletAddr}, + }, + })) + require.NoError(t, err) + accountID := acctResp.Msg.GetId() + + _, err = client.SyncAccount(ctx, connect.NewRequest(&v1.SyncAccountRequest{ + AccountId: accountID, + })) + require.Error(t, err) + var connectErr *connect.Error + require.ErrorAs(t, err, &connectErr) + assert.Equal(t, connect.CodeUnimplemented, connectErr.Code(), + "SyncAccount without walletSyncer should return CodeUnimplemented") +} + +// TestSyncAccount_WithMoralis requires EYE_MORALIS_APIKEY and tests the full sync flow +// against vitalik.eth — a public wallet with known token holdings on Ethereum mainnet. +// +// All operations are READ-ONLY: only wallet balances are queried, nothing is changed on-chain. +func TestSyncAccount_WithMoralis(t *testing.T) { + if os.Getenv("EYE_MORALIS_APIKEY") == "" { + t.Skip("EYE_MORALIS_APIKEY not set — skipping Moralis sync test") + } + + resetDB(t) + ctx := context.Background() + client := newPortfolioClient(smokeTestUserID) + + portResp, err := client.CreatePortfolio(ctx, connect.NewRequest(&v1.CreatePortfolioRequest{ + Portfolio: &v1.Portfolio{Name: "Vitalik Portfolio"}, + })) + require.NoError(t, err) + portID := portResp.Msg.GetId() + + // vitalik.eth — public Ethereum wallet with significant on-chain activity. + // READ-ONLY: SyncAccount only queries balances via Moralis API. + walletAddr := "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" + acctResp, err := client.CreateAccount(ctx, connect.NewRequest(&v1.CreateAccountRequest{ + Account: &v1.Account{ + Name: "vitalik.eth", + Type: v1.AccountType_ACCOUNT_TYPE_WALLET, + PortfolioId: &portID, + Data: map[string]string{"address": walletAddr}, + }, + })) + require.NoError(t, err) + accountID := acctResp.Msg.GetId() + + syncResp, err := client.SyncAccount(ctx, connect.NewRequest(&v1.SyncAccountRequest{ + AccountId: accountID, + })) + require.NoError(t, err) + // Some tokens may fail (e.g. missing name in Moralis metadata) — that's acceptable. + // Key assertion: at least some holdings were synced successfully. + assert.Greater(t, syncResp.Msg.GetHoldingsUpserted(), int32(0), + "vitalik.eth should have at least one token holding on Ethereum mainnet") + + // Verify holdings were stored — list them for the portfolio + holdingsResp, err := client.ListHoldings(ctx, connect.NewRequest(&v1.ListHoldingsRequest{ + PortfolioId: &portID, + })) + require.NoError(t, err) + assert.Greater(t, len(holdingsResp.Msg.GetHoldings()), 0, + "at least one holding should be stored after sync") + + // Verify assets were created for discovered tokens + mdClient := newMDClient(smokeTestUserID) + assetsResp, err := mdClient.ListAssets(ctx, connect.NewRequest(&v1.ListAssetsRequest{})) + require.NoError(t, err) + assert.Greater(t, len(assetsResp.Msg.GetAssets()), 0, + "assets should be created for tokens found in vitalik.eth wallet") +} diff --git a/test/smoke/truncate_test.go b/test/smoke/truncate_test.go new file mode 100644 index 0000000..6d8e965 --- /dev/null +++ b/test/smoke/truncate_test.go @@ -0,0 +1,30 @@ +//go:build smoke + +package smoke_test + +import ( + "context" + "testing" +) + +// resetDB truncates all tables to give each test a clean state. +// Uses a direct DB connection — the same database the running backend writes to. +func resetDB(t *testing.T) { + t.Helper() + ctx := context.Background() + for _, table := range []string{ + "rule_executions", + "rules", + "transactions", + "holdings", + "prices", + "portfolios", + "accounts", + "assets", + "users", + } { + if _, err := dbPool.Exec(ctx, "TRUNCATE TABLE "+table+" CASCADE"); err != nil { + t.Fatalf("truncate %s: %v", table, err) + } + } +} diff --git a/test/smoke/users_test.go b/test/smoke/users_test.go new file mode 100644 index 0000000..d8f435c --- /dev/null +++ b/test/smoke/users_test.go @@ -0,0 +1,12 @@ +//go:build smoke + +package smoke_test + +// smokeTestUserID is the fixed identity used for all smoke test requests. +// Must be a valid UUID — UserStore.GetOrCreate validates the format. +// All data created during smoke tests lives in the compose test database +// under this well-known ID, making test rows easy to identify. +const ( + smokeTestUserID = "00000000-0000-0000-0000-c0ffee000001" + smokeTestOtherUserID = "00000000-0000-0000-0000-c0ffee000002" +)