diff --git a/protocol/api/asset.go b/protocol/api/asset.go new file mode 100644 index 0000000..f0a6a3c --- /dev/null +++ b/protocol/api/asset.go @@ -0,0 +1,214 @@ +package api + +import ( + "math/bits" + "strconv" + "strings" + + "github.com/pkg/errors" +) + +// 精度常量(对齐 steemd STEEM_PRECISION_*)。 +// STEEM 与 SBD 是 3 位小数,VESTS 是 6 位小数。 +const ( + PrecisionSteem = 3 + PrecisionSbd = 3 + PrecisionVests = 6 +) + +// MaxSatoshis 对齐 STEEM_MAX_SATOSHIS = 2^62-1(steemd config.hpp:241)。 +// 链上任何 asset 的原子单位绝对值都不允许超过此值。 +const MaxSatoshis int64 = 4611686018427387903 // (1 << 62) - 1 + +// Asset 表示 Steem 链上资产:int64 原子单位 + symbol。 +// 精度(小数位数)从 symbol 派生,仅在 ParseAsset/String 时参与,不单独存储, +// 这样 Asset 始终是"链端真相"的精确表达,杜绝 float 中转。 +// +// 与 protocol 根包的 Asset(用于交易二进制序列化)不同:本类型是 +// protocol/api 层的"解释型"原语,面向 wire JSON 字段(如 "1.500 STEEM")。 +type Asset struct { + Amount int64 + Symbol string // STEEM | SBD | VESTS | TESTS | TBD +} + +// symbolPrecision 返回 symbol 的小数位数;非法 symbol 报错。 +// 对齐 steemd asset_symbol::decimals()。 +func symbolPrecision(symbol string) (int, error) { + switch strings.ToUpper(symbol) { + case "STEEM", "SBD", "TESTS", "TBD": + return PrecisionSteem, nil + case "VESTS": + return PrecisionVests, nil + default: + return 0, errors.Errorf("unknown asset symbol: %q", symbol) + } +} + +// ParseAsset 解析 "1.500 STEEM" 形式的 asset 字符串为 Asset。 +// +// 流程(全程零 float): +// - 按单空格切分 [amountStr, symbol];symbol 转大写后校验合法性。 +// - 按 '.' 切分整数/小数部分,拼成纯数字串后 strconv.ParseInt(不经 float 中转)。 +// - 校验小数位数 == symbolPrecision(symbol)(VESTS 给 3 位小数即报错)。 +// - 校验 0 <= amount <= MaxSatoshis(对齐 steemd asset::validate)。 +// +// 不接受负数:steemd wire 字段在本应用场景下均为非负(余额、供给量等)。 +func ParseAsset(s string) (Asset, error) { + parts := strings.Split(strings.TrimSpace(s), " ") + if len(parts) != 2 { + return Asset{}, errors.Errorf("invalid asset format: %q (expected 'amount symbol')", s) + } + + amountStr := parts[0] + symbol := strings.ToUpper(parts[1]) + + prec, err := symbolPrecision(symbol) + if err != nil { + return Asset{}, err + } + + // 切出整数与小数部分,纯字符串操作,杜绝 float。 + var neg bool + intPart, fracPart := amountStr, "" + if strings.HasPrefix(amountStr, "-") { + neg = true + intPart = amountStr[1:] + } else if strings.HasPrefix(amountStr, "+") { + intPart = amountStr[1:] + } + if dot := strings.Index(intPart, "."); dot >= 0 { + intPart, fracPart = intPart[:dot], intPart[dot+1:] + } + + // 校验整数/小数部分都是纯数字(空串也算非法,防止 "." / "1." / ".5")。 + if intPart == "" && fracPart == "" { + return Asset{}, errors.Errorf("invalid asset amount: %q", amountStr) + } + for _, ch := range intPart + fracPart { + if ch < '0' || ch > '9' { + return Asset{}, errors.Errorf("invalid asset amount: %q", amountStr) + } + } + + // 校验小数位数与 symbol 精度一致。 + if len(fracPart) != prec { + return Asset{}, errors.Errorf( + "asset %q has %d decimal places, but symbol %s requires %d", + s, len(fracPart), symbol, prec, + ) + } + + // 拼成纯数字串解析(去前导零交给 ParseInt 处理)。 + combined := intPart + fracPart + if combined == "" { + combined = "0" + } + parsed, err := strconv.ParseInt(combined, 10, 64) + if err != nil { + // 大概率是溢出 int64。 + return Asset{}, errors.Wrapf(err, "asset amount overflow: %q", amountStr) + } + if neg { + parsed = -parsed + } + + // 链端校验:|amount| <= MaxSatoshis,且本场景限定非负。 + if parsed < 0 || parsed > MaxSatoshis { + return Asset{}, errors.Errorf( + "asset amount %d out of range [0, %d]", parsed, MaxSatoshis, + ) + } + + return Asset{Amount: parsed, Symbol: symbol}, nil +} + +// Precision 返回该 asset symbol 的小数位数。 +// +// 前置条件:Symbol 必须是经 ParseAsset 校验过的合法 symbol。直接用 Asset{} +// 字面量构造非法 symbol 属于编程错误,对未知 symbol 直接 panic 而非静默退化 +// (静默返回 0 会让 String() 输出丢精度且无报错,难排查)。 +func (a Asset) Precision() int { + prec, err := symbolPrecision(a.Symbol) + if err != nil { + panic(err) + } + return prec +} + +// String 从 int64 重建 "1.500 STEEM" 字符串。 +// 纯整数除法 + 取模重建小数点,避免 strconv.FormatFloat 的 round-half-even。 +// 不经任何 float 中转,与 ParseAsset 严格互逆(round-trip)。 +func (a Asset) String() string { + prec := a.Precision() + amount := a.Amount + negative := amount < 0 + if negative { + amount = -amount + } + + // 整数部分与小数(原子)部分。 + var intPart, fracPart string + if prec == 0 { + intPart = strconv.FormatInt(amount, 10) + } else { + divisor := int64(1) + for i := 0; i < prec; i++ { + divisor *= 10 + } + intVal := amount / divisor + fracVal := amount % divisor + intPart = strconv.FormatInt(intVal, 10) + // 小数部分左侧补零到 prec 位。 + fracPart = strconv.FormatInt(fracVal, 10) + if len(fracPart) < prec { + fracPart = strings.Repeat("0", prec-len(fracPart)) + fracPart + } + } + + out := intPart + if prec > 0 { + out = out + "." + fracPart + } + if negative { + out = "-" + out + } + return out + " " + a.Symbol +} + +// Add 同 symbol 加法(带溢出检查),对齐 steemd asset::operator+ 的 safe 语义。 +// 不同 symbol 报错(链端 assert base.asset == addend.asset)。 +// +// 用 math/bits.Add64 做符号无关的精确溢出检查:拿到进位标志即可判定,无需 +// 针对正负组合分别写条件(避免"只在 b>0 时检查"一类遗漏)。 +func (a Asset) Add(b Asset) (Asset, error) { + if a.Symbol != b.Symbol { + return Asset{}, errors.Errorf( + "cannot add assets of different symbols: %s vs %s", a.Symbol, b.Symbol, + ) + } + sum, carry := bits.Add64(uint64(a.Amount), uint64(b.Amount), 0) + if carry != 0 || sum > uint64(MaxSatoshis) { + return Asset{}, errors.Errorf("asset addition overflow: %d + %d", a.Amount, b.Amount) + } + return Asset{Amount: int64(sum), Symbol: a.Symbol}, nil +} + +// Sub 同 symbol 减法(带溢出检查),对齐 steemd asset::operator-。 +// +// 结果为负即报错:与 ParseAsset 的"非负"不变式保持一致——所有 Asset(无论来源) +// 始终代表链上合法值 [0, MaxSatoshis]。需要"净流入"等可能为负的场景由调用方在 +// Sub 前比较大小、自行交换操作数;这样 Asset 的 round-trip 契约(String ↔ ParseAsset) +// 对所有合法值都严格成立。 +func (a Asset) Sub(b Asset) (Asset, error) { + if a.Symbol != b.Symbol { + return Asset{}, errors.Errorf( + "cannot subtract assets of different symbols: %s vs %s", a.Symbol, b.Symbol, + ) + } + if b.Amount > a.Amount { + return Asset{}, errors.Errorf( + "asset subtraction would be negative: %d - %d", a.Amount, b.Amount, + ) + } + return Asset{Amount: a.Amount - b.Amount, Symbol: a.Symbol}, nil +} diff --git a/protocol/api/asset_test.go b/protocol/api/asset_test.go new file mode 100644 index 0000000..1acc366 --- /dev/null +++ b/protocol/api/asset_test.go @@ -0,0 +1,284 @@ +package api + +import ( + "testing" +) + +// TestParseAsset_RoundTrip 覆盖验收标准 1:ParseAsset/String 严格互逆。 +// 全程不经 float,VESTS 6 位小数也能精确重建。 +func TestParseAsset_RoundTrip(t *testing.T) { + cases := []string{ + "0.001 STEEM", + "1.000 SBD", + "100.500 SBD", + "0.000001 VESTS", + "12345.678 STEEM", + "0.123456 VESTS", + "9999999.999 SBD", + } + for _, s := range cases { + a, err := ParseAsset(s) + if err != nil { + t.Fatalf("ParseAsset(%q): %v", s, err) + } + if got := a.String(); got != s { + t.Errorf("round-trip mismatch: in=%q out=%q (atoms=%d)", s, got, a.Amount) + } + } +} + +// TestParseAsset_AtomValues 手算原子单位,确认小数→int64 拼接无误。 +func TestParseAsset_AtomValues(t *testing.T) { + cases := []struct { + in string + amount int64 + symbol string + }{ + {"0.001 STEEM", 1, "STEEM"}, + {"1.000 SBD", 1000, "SBD"}, + {"0.000001 VESTS", 1, "VESTS"}, + {"1.000000 VESTS", 1000000, "VESTS"}, + {"1.500 STEEM", 1500, "STEEM"}, + {"1000.000 STEEM", 1000000, "STEEM"}, + } + for _, c := range cases { + a, err := ParseAsset(c.in) + if err != nil { + t.Fatalf("ParseAsset(%q): %v", c.in, err) + } + if a.Amount != c.amount { + t.Errorf("%q: amount=%d want %d", c.in, a.Amount, c.amount) + } + if a.Symbol != c.symbol { + t.Errorf("%q: symbol=%q want %q", c.in, a.Symbol, c.symbol) + } + } +} + +// TestParseAsset_SymbolLowercase 校验大小写不敏感(symbol 转大写)。 +func TestParseAsset_SymbolLowercase(t *testing.T) { + a, err := ParseAsset("1.000 steem") + if err != nil { + t.Fatalf("ParseAsset lowercase: %v", err) + } + if a.Symbol != "STEEM" { + t.Errorf("expected uppercased STEEM, got %q", a.Symbol) + } +} + +// TestParseAsset_Errors 覆盖验收标准 1 的非法输入分支。 +func TestParseAsset_Errors(t *testing.T) { + cases := []struct { + name string + in string + }{ + {"missing space", "1.000STEEM"}, + {"too many parts", "1.000 STEEM EXTRA"}, + {"unknown symbol", "1.000 XYZ"}, + {"vests wrong precision", "0.001 VESTS"}, // VESTS 需 6 位 + {"steem wrong precision", "0.0001 STEEM"}, // STEEM 需 3 位 + {"empty amount", " STEEM"}, + {"non-numeric", "a.000 STEEM"}, + {"negative", "-1.000 STEEM"}, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + _, err := ParseAsset(c.in) + if err == nil { + t.Errorf("ParseAsset(%q): expected error, got nil", c.in) + } + }) + } +} + +// TestParseAsset_ExceedsMaxSatoshis 构造超 MaxSatoshis 的输入。 +// MaxSatoshis = 2^62-1 = 4611686018427387903。 +// 4611686018427387904 = 2^62 即越界。 +func TestParseAsset_ExceedsMaxSatoshis(t *testing.T) { + over := "4611686018427387.904 STEEM" // 原子 = 2^62,超过 MaxSatoshis + _, err := ParseAsset(over) + if err == nil { + t.Errorf("ParseAsset(%q): expected range error, got nil", over) + } + // 边界值:恰好等于 MaxSatoshis 应通过。 + maxAtoms := "4611686018427387.903 STEEM" + a, err := ParseAsset(maxAtoms) + if err != nil { + t.Fatalf("ParseAsset(MaxSatoshis) unexpected err: %v", err) + } + if a.Amount != MaxSatoshis { + t.Errorf("MaxSatoshis atoms = %d want %d", a.Amount, MaxSatoshis) + } +} + +// TestAsset_AddSubZeroFloatError 覆盖验收标准 1 的"0.1+0.2 零误差"等价。 +// 用原子单位:0.100 STEEM + 0.200 STEEM = 0.300 STEEM(100+200=300), +// 零浮点 round-off。 +func TestAsset_AddSubZeroFloatError(t *testing.T) { + a, _ := ParseAsset("0.100 STEEM") + b, _ := ParseAsset("0.200 STEEM") + sum, err := a.Add(b) + if err != nil { + t.Fatalf("Add: %v", err) + } + if sum.Amount != 300 { + t.Errorf("0.100+0.200 atoms = %d want 300", sum.Amount) + } + if got := sum.String(); got != "0.300 STEEM" { + t.Errorf("sum string = %q want %q", got, "0.300 STEEM") + } + + diff, err := sum.Sub(b) + if err != nil { + t.Fatalf("Sub: %v", err) + } + if diff.Amount != 100 { + t.Errorf("0.300-0.200 atoms = %d want 100", diff.Amount) + } + if got := diff.String(); got != "0.100 STEEM" { + t.Errorf("diff string = %q want %q", got, "0.100 STEEM") + } +} + +// TestAsset_Add_DifferentSymbol 不同 symbol 必须报错。 +func TestAsset_Add_DifferentSymbol(t *testing.T) { + a, _ := ParseAsset("1.000 STEEM") + b, _ := ParseAsset("1.000 SBD") + if _, err := a.Add(b); err == nil { + t.Error("Add across symbols: expected error") + } + if _, err := a.Sub(b); err == nil { + t.Error("Sub across symbols: expected error") + } +} + +// TestAsset_Add_Overflow 构造两个接近 MaxSatoshis 的值相加溢出。 +func TestAsset_Add_Overflow(t *testing.T) { + // MaxSatoshis - 1 与 2 相加 → MaxSatoshis+1,溢出。 + a := Asset{Amount: MaxSatoshis - 1, Symbol: "STEEM"} + b := Asset{Amount: 2, Symbol: "STEEM"} + if _, err := a.Add(b); err == nil { + t.Error("Add overflow: expected error") + } + // 边界:MaxSatoshis-1 + 1 = MaxSatoshis,恰好不溢出。 + c := Asset{Amount: 1, Symbol: "STEEM"} + sum, err := a.Add(c) + if err != nil { + t.Fatalf("Add boundary: %v", err) + } + if sum.Amount != MaxSatoshis { + t.Errorf("boundary sum = %d want %d", sum.Amount, MaxSatoshis) + } +} + +// TestAsset_Precision 确认精度派生正确。 +func TestAsset_Precision(t *testing.T) { + cases := []struct { + symbol string + prec int + }{ + {"STEEM", PrecisionSteem}, + {"SBD", PrecisionSbd}, + {"VESTS", PrecisionVests}, + {"TESTS", PrecisionSteem}, + {"TBD", PrecisionSteem}, + } + for _, c := range cases { + a := Asset{Amount: 1, Symbol: c.symbol} + if got := a.Precision(); got != c.prec { + t.Errorf("%s precision = %d want %d", c.symbol, got, c.prec) + } + } +} + +// TestAsset_String_Format 精确重建小数点格式,无 round-half-even。 +func TestAsset_String_Format(t *testing.T) { + cases := []struct { + asset Asset + out string + }{ + {Asset{Amount: 0, Symbol: "STEEM"}, "0.000 STEEM"}, + {Asset{Amount: 1, Symbol: "STEEM"}, "0.001 STEEM"}, + {Asset{Amount: 1500, Symbol: "STEEM"}, "1.500 STEEM"}, + {Asset{Amount: 1000000, Symbol: "STEEM"}, "1000.000 STEEM"}, + {Asset{Amount: 1, Symbol: "VESTS"}, "0.000001 VESTS"}, + {Asset{Amount: 999999, Symbol: "VESTS"}, "0.999999 VESTS"}, + } + for _, c := range cases { + if got := c.asset.String(); got != c.out { + t.Errorf("String(%d %s) = %q want %q", c.asset.Amount, c.asset.Symbol, got, c.out) + } + } +} + +// === 审计修复回归测试 === + +// TestAsset_Sub_RejectsNegative 修复 #2:Sub 结果为负必须报错(与 ParseAsset +// 的非负不变式一致,保证 String ↔ ParseAsset round-trip 对所有合法值成立)。 +func TestAsset_Sub_RejectsNegative(t *testing.T) { + a, _ := ParseAsset("0.100 STEEM") // 100 atoms + b, _ := ParseAsset("0.200 STEEM") // 200 atoms + _, err := a.Sub(b) + if err == nil { + t.Fatal("Sub(0.100, 0.200): expected error for negative result, got nil") + } +} + +// TestAsset_Add_Overflow_NegativeOperand 修复 #2 的 Add 部分:即便 b 为负 +// (直接构造的非法 Asset),bits.Add64 也能符号无关地检出溢出,不会漏过。 +// 两个 int64 最大值相加,uint64 域必进位 → carry != 0 → 报错。 +func TestAsset_Add_Overflow_NegativeOperand(t *testing.T) { + a := Asset{Amount: 1<<63 - 1, Symbol: "STEEM"} + b := Asset{Amount: 1<<63 - 1, Symbol: "STEEM"} + if _, err := a.Add(b); err == nil { + t.Error("Add(2×MaxInt64): expected overflow error, got nil") + } +} + +// TestAsset_Precision_UnknownSymbolPanics 修复 #3:未知 symbol 的 Precision +// 直接 panic,不再静默退化到 0(避免 String 输出丢精度却无报错)。 +func TestAsset_Precision_UnknownSymbolPanics(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("Precision(UNKNOWN): expected panic, got none") + } + }() + a := Asset{Amount: 100, Symbol: "UNKNOWN"} + _ = a.Precision() +} + +// TestAsset_String_UnknownSymbolPanics 修复 #3 的连带:String() 经 Precision() +// 也会对未知 symbol panic,确保非法 symbol 不产生静默错误输出。 +func TestAsset_String_UnknownSymbolPanics(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("String(UNKNOWN): expected panic, got none") + } + }() + a := Asset{Amount: 100, Symbol: "UNKNOWN"} + _ = a.String() +} + +// TestAsset_RoundTrip_NonNegativeInvariant 修复 #2 的契约验证:所有合法 Asset +// (含 Sub 产出的中间值)都能 String ↔ ParseAsset 严格互逆。 +// 因 Sub 现在拒负,任何合法 Asset 都 ≥ 0,round-trip 恒成立。 +func TestAsset_RoundTrip_NonNegativeInvariant(t *testing.T) { + cases := []Asset{ + {Amount: 0, Symbol: "STEEM"}, + {Amount: 1, Symbol: "STEEM"}, + {Amount: MaxSatoshis, Symbol: "SBD"}, + {Amount: 1, Symbol: "VESTS"}, + } + for _, a := range cases { + s := a.String() + a2, err := ParseAsset(s) + if err != nil { + t.Errorf("round-trip ParseAsset(%q): %v", s, err) + continue + } + if a2 != a { + t.Errorf("round-trip mismatch: %+v -> %q -> %+v", a, s, a2) + } + } +} diff --git a/protocol/api/market.go b/protocol/api/market.go index 246d493..cf2b202 100644 --- a/protocol/api/market.go +++ b/protocol/api/market.go @@ -34,3 +34,16 @@ type CurrentMedianHistoryPrice struct { type FeedHistory struct { PriceHistory []CurrentMedianHistoryPrice `json:"price_history"` } + +// ToPrice 解析 OrderPrice 的 base/quote 字符串为精确的 Price 原语。 +// 是 wire string-asset 字段("1.000 SBD")与 Price 之间的桥梁。 +// 调用方拿到 Price 后即可做零浮点的 Convert/Compare 运算。 +func (o OrderPrice) ToPrice() (Price, error) { + return ParsePrice(o.Base, o.Quote) +} + +// ToPrice 解析 CurrentMedianHistoryPrice 的 base/quote 字符串为精确的 Price。 +// conveyor 用 feed_history 最后一项推导 STEEM<>USD 价格。 +func (c CurrentMedianHistoryPrice) ToPrice() (Price, error) { + return ParsePrice(c.Base, c.Quote) +} diff --git a/protocol/api/price.go b/protocol/api/price.go new file mode 100644 index 0000000..f24ef8d --- /dev/null +++ b/protocol/api/price.go @@ -0,0 +1,124 @@ +package api + +import ( + "math/big" + + "github.com/pkg/errors" +) + +// Price 表示两个 asset 的比值,复刻 steemd price {base, quote}(asset.hpp)。 +// 链上语义:base 个 Quote.Symbol 兑换 quote 个 Base.Symbol;例如 +// +// Price{Base: "1.000 SBD", Quote: "1.000 STEEM"} +// +// 表示"1 STEEM 值 1 SBD"。 +// +// 价格运算全程使用 math/big.Int(标准库),覆盖 128 位中间值且免手写 uint128, +// 并复刻 steemd "先乘后除 + 溢出检查"的语义,与链端逐位一致。 +// 零浮点、零第三方依赖。 +type Price struct { + Base, Quote Asset +} + +// ParsePrice 解析两个 asset 字符串并构造 Price。 +// 校验对齐 steemd price::validate: +// - Base.Symbol != Quote.Symbol(否则比值退化为 1,无意义)。 +// - Base 与 Quote 均非零(分母为零无意义,分子为零说明价格未初始化)。 +func ParsePrice(base, quote string) (Price, error) { + b, err := ParseAsset(base) + if err != nil { + return Price{}, errors.Wrapf(err, "parse price base %q", base) + } + q, err := ParseAsset(quote) + if err != nil { + return Price{}, errors.Wrapf(err, "parse price quote %q", quote) + } + if b.Symbol == q.Symbol { + return Price{}, errors.Errorf( + "price base and quote must have different symbols, both %s", b.Symbol, + ) + } + if b.Amount == 0 || q.Amount == 0 { + return Price{}, errors.New("price base and quote must be non-zero") + } + return Price{Base: b, Quote: q}, nil +} + +// Convert 复刻 steemd asset*price(asset.cpp:285-302): +// +// r = (a.Amount * 分子.Amount) / 分母.Amount +// +// 前置条件:Price 必须经 ParsePrice 构造(保证 Base/Quote 非零、symbol 合法、 +// 精度正确)。直接用 Price{} 字面量构造绕过校验属于编程错误,其行为未定义。 +// +// 其中分子/分母取决于 a 的符号匹配哪一边: +// - a.Symbol == Base.Symbol:结果符号 = Quote.Symbol,分子 = Quote.Amount,分母 = Base.Amount +// - a.Symbol == Quote.Symbol:结果符号 = Base.Symbol,分子 = Base.Amount,分母 = Quote.Amount +// +// 实现要点(与链端逐位一致): +// - 用 math/big.Int 计算,中间乘积可超 int64(128 位)。 +// - Quo(向零截断)而非 Div(向下取整),与 C++ 整数除法一致。 +// - 溢出检查:除法后结果必须 fit int64 且 >= 0(对齐 result.hi == 0 断言)。 +// - 返回的 Asset 是结果 symbol 的整数原子单位。 +func (p Price) Convert(a Asset) (Asset, error) { + var ( + numeratorAmount int64 + denominatorAmount int64 + resultSymbol string + ) + switch a.Symbol { + case p.Base.Symbol: + resultSymbol = p.Quote.Symbol + numeratorAmount = p.Quote.Amount + denominatorAmount = p.Base.Amount + case p.Quote.Symbol: + resultSymbol = p.Base.Symbol + numeratorAmount = p.Base.Amount + denominatorAmount = p.Quote.Amount + default: + return Asset{}, errors.Errorf( + "asset symbol %s matches neither price side (%s / %s)", + a.Symbol, p.Base.Symbol, p.Quote.Symbol, + ) + } + if denominatorAmount == 0 { + return Asset{}, errors.New("price convert: zero denominator") + } + + // a.Amount 可能为 0,结果自然为 0;但分子/分母已在校验中保证非零。 + num := new(big.Int).Mul(big.NewInt(a.Amount), big.NewInt(numeratorAmount)) + // Quo = 向零截断(truncated division),与 C++ 整数除法语义一致。 + res := new(big.Int).Quo(num, big.NewInt(denominatorAmount)) + + // 链端断言:result.hi == 0 即结果 fit uint64。本实现更严:必须 fit [0, MaxSatoshis]。 + if res.Sign() < 0 { + return Asset{}, errors.Errorf("price convert result negative: %s", res.String()) + } + if !res.IsUint64() || res.Uint64() > uint64(MaxSatoshis) { + return Asset{}, errors.Errorf("price convert result overflow int62: %s", res.String()) + } + return Asset{Amount: int64(res.Uint64()), Symbol: resultSymbol}, nil +} + +// Compare 复刻 steemd price 比较(asset.cpp:263-283): +// +// a < b ⟺ a.Base*b.Quote < b.Base*a.Quote (交叉相乘后比分子) +// +// 返回 -1/0/1。零除法零浮点。 +// 用 math/big.Int 做交叉乘,避免 int64 乘法溢出。 +// +// 注意:steemd 要求两个 price 描述同一种"兑换方向"才可比较; +// 本实现不做方向归一化,由调用方保证语义一致(与 steemd operator< 前置条件一致)。 +func (p Price) Compare(q Price) int { + // a.Base * b.Quote + lhs := new(big.Int).Mul(big.NewInt(p.Base.Amount), big.NewInt(q.Quote.Amount)) + // b.Base * a.Quote + rhs := new(big.Int).Mul(big.NewInt(q.Base.Amount), big.NewInt(p.Quote.Amount)) + return lhs.Cmp(rhs) +} + +// Invert 复刻 steemd operator~(取倒数):交换 Base 与 Quote。 +// Invert().Invert() == 原 Price(round-trip 恒等)。 +func (p Price) Invert() Price { + return Price{Base: p.Quote, Quote: p.Base} +} diff --git a/protocol/api/price_test.go b/protocol/api/price_test.go new file mode 100644 index 0000000..ae05d1e --- /dev/null +++ b/protocol/api/price_test.go @@ -0,0 +1,212 @@ +package api + +import ( + "testing" +) + +// TestParsePrice 校验 Price 构造与合法性约束。 +func TestParsePrice(t *testing.T) { + // 正常构造。 + p, err := ParsePrice("1.000 SBD", "1.000 STEEM") + if err != nil { + t.Fatalf("ParsePrice: %v", err) + } + if p.Base.Symbol != "SBD" || p.Quote.Symbol != "STEEM" { + t.Errorf("unexpected price: %+v", p) + } + + // 同 symbol 必须报错。 + if _, err := ParsePrice("1.000 STEEM", "2.000 STEEM"); err == nil { + t.Error("ParsePrice same symbol: expected error") + } + // 零值必须报错。 + if _, err := ParsePrice("0.000 SBD", "1.000 STEEM"); err == nil { + t.Error("ParsePrice zero base: expected error") + } + if _, err := ParsePrice("1.000 SBD", "0.000 STEEM"); err == nil { + t.Error("ParsePrice zero quote: expected error") + } +} + +// TestPrice_Convert_Golden1 教学向量 1(计划文档): +// +// amount = 3000000 (3000.000 STEEM,即 3×10^6 atoms) +// quote = 5 (0.000005 VESTS) +// base = 7 (0.007 STEEM) +// result = 3000000 * 5 / 7 = 2142857 (截断) → 2.142857 VESTS +// +// 校验 steemd asset*price 语义:先乘后除、向零截断。 +// 注:计划文档原注释 "3.000 STEEM" 与 "3000000 atoms" 矛盾——3.000 STEEM 实为 +// 3000 atoms;此处取 3000.000 STEEM (3000000 atoms) 以匹配文档的 amount 与 result。 +func TestPrice_Convert_Golden1(t *testing.T) { + // base=0.007 STEEM (7 atoms), quote=0.000005 VESTS (5 atoms) + p, err := ParsePrice("0.007 STEEM", "0.000005 VESTS") + if err != nil { + t.Fatalf("ParsePrice: %v", err) + } + // 输入 3000.000 STEEM (3000000 atoms),符号匹配 base,结果符号应为 quote(VESTS)。 + a, _ := ParseAsset("3000.000 STEEM") + res, err := p.Convert(a) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if res.Amount != 2142857 { + t.Errorf("Convert golden1 atoms = %d want 2142857", res.Amount) + } + if res.Symbol != "VESTS" { + t.Errorf("Convert golden1 symbol = %q want VESTS", res.Symbol) + } + if got := res.String(); got != "2.142857 VESTS" { + t.Errorf("Convert golden1 string = %q want %q", got, "2.142857 VESTS") + } +} + +// TestPrice_Convert_Golden2_128bit 教学向量 2(128 位中间值): +// +// asset.amount = 1000 (1.000 STEEM) +// total_vesting_shares = 8×10^16 (quote, VESTS atoms) +// total_vesting_fund_steem= 1.5×10^14 (base, STEEM atoms) +// 中间乘积 = 1000 × 8×10^16 = 8×10^19,超 int64 上限(9.2×10^18) +// result = 8×10^19 / 1.5×10^14 = 533333 (截断) → 0.533333 VESTS +// +// 用 math/big.Int 不溢出,结果精确。校验本实现能正确处理 128 位中间值。 +func TestPrice_Convert_Golden2_128bit(t *testing.T) { + // fund = 150000000000.000 STEEM = 150000000000 atoms (1.5×10^14) + // shares= 80000000000.000000 VESTS = 80000000000000000 atoms (8×10^16) + p, err := ParsePrice("150000000000.000 STEEM", "80000000000.000000 VESTS") + if err != nil { + t.Fatalf("ParsePrice: %v", err) + } + a, _ := ParseAsset("1.000 STEEM") + res, err := p.Convert(a) + if err != nil { + t.Fatalf("Convert: %v", err) + } + // 1000 × 8×10^16 / 1.5×10^14 = 533333 + if res.Amount != 533333 { + t.Errorf("Convert golden2 atoms = %d want 533333", res.Amount) + } + if res.Symbol != "VESTS" { + t.Errorf("Convert golden2 symbol = %q want VESTS", res.Symbol) + } + if got := res.String(); got != "0.533333 VESTS" { + t.Errorf("Convert golden2 string = %q want %q", got, "0.533333 VESTS") + } +} + +// TestPrice_Convert_Reverse 用 quote 分支而非 base 分支, +// 确认结果符号变为 base(Invert 后的方向)。 +func TestPrice_Convert_Reverse(t *testing.T) { + p, err := ParsePrice("1.000 SBD", "2.000 STEEM") + if err != nil { + t.Fatalf("ParsePrice: %v", err) + } + // 输入 STEEM(匹配 quote)→ 结果应为 base 符号 SBD。 + // result = 1.000 STEEM(1000) * 1.000 SBD(1000) / 2.000 STEEM(2000) = 500 + a, _ := ParseAsset("1.000 STEEM") + res, err := p.Convert(a) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if res.Amount != 500 || res.Symbol != "SBD" { + t.Errorf("Convert reverse = %+v want {500 SBD}", res) + } +} + +// TestPrice_Convert_UnknownSymbol 输入 symbol 既非 base 也非 quote → error。 +func TestPrice_Convert_UnknownSymbol(t *testing.T) { + p, _ := ParsePrice("1.000 SBD", "1.000 STEEM") + unknown, _ := ParseAsset("1.000000 VESTS") + if _, err := p.Convert(unknown); err == nil { + t.Error("Convert unknown symbol: expected error") + } +} + +// TestPrice_Convert_Truncation 验证向零截断(Quo 语义,非 Div 向下取整)。 +// 例:5/2=2 (positive), -5/2=-2 在 C++ 也是向零;这里只测正值,且验证非 floor。 +func TestPrice_Convert_Truncation(t *testing.T) { + // base=3 STEEM, quote=2 SBD;输入 1.000 STEEM → 1000*2/3 = 666 (截断),而非 667。 + p, _ := ParsePrice("3.000 STEEM", "2.000 SBD") + a, _ := ParseAsset("1.000 STEEM") + res, err := p.Convert(a) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if res.Amount != 666 { + t.Errorf("truncation atoms = %d want 666 (truncated, not rounded)", res.Amount) + } +} + +// TestPrice_Convert_Overflow 构造结果超过 MaxSatoshis 的输入(验收标准 3)。 +// base=1 atom STEEM, quote=MaxSatoshis atoms SBD;输入 2.000 STEEM (2000 atoms) +// → 2000 * MaxSatoshis / 1 远超 int64,应 error。 +func TestPrice_Convert_Overflow(t *testing.T) { + // quote.amount = MaxSatoshis,但要能写成合法 asset 字符串。 + // 用直接构造 Price 绕过 ParseAsset 的字符串精度限制。 + p := Price{ + Base: Asset{Amount: 1, Symbol: "STEEM"}, + Quote: Asset{Amount: MaxSatoshis, Symbol: "SBD"}, + } + a := Asset{Amount: 2000, Symbol: "STEEM"} + _, err := p.Convert(a) + if err == nil { + t.Error("Convert overflow: expected error") + } +} + +// TestPrice_Compare_Golden 教学向量(计划文档): +// +// A: 1.000 STEEM : 2.000 SBD (base=1000, quote=2000) +// B: 3.000 STEEM : 7.000 SBD (base=3000, quote=7000) +// A < B 吗?交叉乘:A.Base*B.Quote vs B.Base*A.Quote +// = 1000*7000 vs 3000*2000 = 7000000 vs 6000000 → A > B(lhs>rhs) +// +// 即 Compare(B) → -1(A 在 B 之后),实际是 p.Compare(q) 返回 lhs vs rhs。 +// 本测试直接断言交叉乘结果。 +func TestPrice_Compare_Golden(t *testing.T) { + a, _ := ParsePrice("1.000 STEEM", "2.000 SBD") + b, _ := ParsePrice("3.000 STEEM", "7.000 SBD") + // p.Compare(q): p.Base*q.Quote vs q.Base*p.Quote + // = 1000*7000 vs 3000*2000 = 7000000 vs 6000000 → +1 (p > q) + if got := a.Compare(b); got != 1 { + t.Errorf("A.Compare(B) = %d want +1 (A>B by cross-mult)", got) + } + // 反向应为 -1。 + if got := b.Compare(a); got != -1 { + t.Errorf("B.Compare(A) = %d want -1", got) + } +} + +// TestPrice_Compare_Equal 相等的交叉乘返回 0。 +func TestPrice_Compare_Equal(t *testing.T) { + a, _ := ParsePrice("1.000 STEEM", "2.000 SBD") + b, _ := ParsePrice("2.000 STEEM", "4.000 SBD") // 同比值 + if got := a.Compare(b); got != 0 { + t.Errorf("equal prices Compare = %d want 0", got) + } +} + +// TestPrice_Invert_RoundTrip Invert().Invert() == 原 Price(验收标准 4)。 +func TestPrice_Invert_RoundTrip(t *testing.T) { + original, _ := ParsePrice("1.000 SBD", "2.000 STEEM") + roundTrip := original.Invert().Invert() + if roundTrip.Base != original.Base || roundTrip.Quote != original.Quote { + t.Errorf("Invert round-trip mismatch:\n orig = %+v\n rt = %+v", original, roundTrip) + } + // 单次 Invert 应交换 base/quote。 + inverted := original.Invert() + if inverted.Base != original.Quote || inverted.Quote != original.Base { + t.Errorf("Invert did not swap: orig=%+v inv=%+v", original, inverted) + } +} + +// TestPrice_Compare_NoOverflow 用大数值确保交叉乘不会 int64 溢出。 +// 两个 base.amount 接近 2^31 的 price 相乘 → ~2^62,仍安全(这里只是回归用例)。 +func TestPrice_Compare_NoOverflow(t *testing.T) { + // 用直接构造避免 ParseAsset 精度限制;amount 在 [0, MaxSatoshis] 内合法。 + a := Price{Base: Asset{Amount: 1 << 31, Symbol: "STEEM"}, Quote: Asset{Amount: 1 << 31, Symbol: "SBD"}} + b := Price{Base: Asset{Amount: 1 << 31, Symbol: "STEEM"}, Quote: Asset{Amount: 1 << 31, Symbol: "SBD"}} + if got := a.Compare(b); got != 0 { + t.Errorf("large equal prices Compare = %d want 0", got) + } +} diff --git a/protocol/api/prices.go b/protocol/api/prices.go new file mode 100644 index 0000000..29563d7 --- /dev/null +++ b/protocol/api/prices.go @@ -0,0 +1,138 @@ +package api + +import ( + "github.com/pkg/errors" +) + +// PricesResult 对应 conveyor src/price.ts get_prices 的三项输出,但类型是 +// 精确的 Price/Asset 而非 float64。conveyor 侧按需自行转 float64 用于展示。 +// +// 字段语义对齐 conveyor: +// - SteemSbd: 单位 STEEM 折算的 SBD 原子值(整数平均)。 +// - SteemUsd: feed_history 最后一项的价格比值(STEEM<>SBD 视作 USD 近似)。 +// - SteemVest: total_vesting_fund_steem / total_vesting_shares 的 vesting 价格。 +type PricesResult struct { + // SteemSbd 是 order book 各 order Convert(1.000 STEEM) 得到的 SBD 原子值的 + // 整数算术平均(sum/count)。原 conveyor TS 用 float64 算术平均,此处换成 + // int64 整数平均以保持精确;展示层转 float64 下沉到 conveyor。 + SteemSbd Asset + // SteemUsd 直接取 feed_history.price_history 最后一项的比值,不做转换。 + SteemUsd Price + // SteemVest = ParsePrice(total_vesting_fund_steem, total_vesting_shares)。 + SteemVest Price +} + +// ComputePrices 从 wire 类型计算三项精确价格,对应 conveyor src/price.ts get_prices。 +// +// 入参均为指针:nil 表示对应数据源缺失,相应跳过该字段并返回 error +// (三项均依赖独立数据源,缺一即视为不可用)。这与 conveyor 行为一致: +// 任一关键来源缺失都使价格不可信。 +// +// 全程零浮点;任何错误(空订单簿、symbol 不匹配等)立即向上返回。 +func ComputePrices(ob *OrderBook, fh *FeedHistory, dgp *DynamicGlobalProperties) (PricesResult, error) { + var result PricesResult + + // --- SteemSbd: order book 平均 --- + if ob == nil { + return PricesResult{}, errors.New("compute prices: order book is nil") + } + totalOrders := len(ob.Asks) + len(ob.Bids) + if totalOrders == 0 { + return PricesResult{}, errors.New("compute prices: order book is empty") + } + oneSteem, err := ParseAsset("1.000 STEEM") + if err != nil { + // 不可达:常量字符串。 + return PricesResult{}, errors.Wrap(err, "compute prices: parse 1.000 STEEM") + } + + // 累加 Convert(1.000 STEEM) 的对侧 asset 原子值。 + // + // resultSymbol 由首个有效 order 的 Convert 结果决定(不再硬编码),但 + // ComputePrices 的语义只服务 STEEM/SBD 市场——所以每个 order 必须满足: + // (1) 一侧是 STEEM(与 oneSteem 匹配); + // (2) 另一侧统一是 SBD(所有 order 的结果 symbol 必须一致)。 + // 任一条件不满足即视为价格不可信,按整体设计报错而非静默贴错符号。 + // 用 int64 累加;单笔上限 MaxSatoshis ≈ 4.6e18,订单数远小于 2^63/MaxSatoshis + // 时安全。若出现溢出说明订单数异常巨大或单价异常,按链端语义视为错误。 + var ( + sumSbd int64 + resultSymbol string // 首个有效 order 决定;后续必须一致 + ) + convert := func(o Order) error { + p, err := o.OrderPrice.ToPrice() + if err != nil { + return errors.Wrapf(err, "order price base=%q quote=%q", o.OrderPrice.Base, o.OrderPrice.Quote) + } + conv, err := p.Convert(oneSteem) + if err != nil { + return errors.Wrapf(err, "convert 1.000 STEEM via base=%q quote=%q", o.OrderPrice.Base, o.OrderPrice.Quote) + } + // 校验结果是 SBD——字段名 SteemSbd 只服务 STEEM/SBD 市场。 + // 其它符号(如 VESTS)混入说明输入语义不符,报错比贴错标签安全。 + if conv.Symbol != "SBD" { + return errors.Errorf( + "order price base=%q quote=%q yields %s, but ComputePrices only supports STEEM/SBD orders", + o.OrderPrice.Base, o.OrderPrice.Quote, conv.Symbol, + ) + } + if resultSymbol == "" { + resultSymbol = conv.Symbol + } else if conv.Symbol != resultSymbol { + return errors.Errorf( + "inconsistent result symbol across orders: had %s, now %s (order base=%q quote=%q)", + resultSymbol, conv.Symbol, o.OrderPrice.Base, o.OrderPrice.Quote, + ) + } + // 溢出保护:累加前检查。 + if conv.Amount > 0 && sumSbd > MaxSatoshis-conv.Amount { + return errors.Errorf("steem_sbd sum overflow at %d + %d", sumSbd, conv.Amount) + } + sumSbd += conv.Amount + return nil + } + for _, o := range ob.Asks { + if err := convert(o); err != nil { + return PricesResult{}, err + } + } + for _, o := range ob.Bids { + if err := convert(o); err != nil { + return PricesResult{}, err + } + } + + // resultSymbol 已由 convert 校验为 "SBD"(非空保证:totalOrders>0 且 convert 成功)。 + // 整数算术平均(向零截断),贴近 conveyor 原 TS 算术平均语义。 + avgSbd := sumSbd / int64(totalOrders) + result.SteemSbd = Asset{Amount: avgSbd, Symbol: resultSymbol} + + // --- SteemUsd: feed_history 最后一项 --- + if fh == nil { + return PricesResult{}, errors.New("compute prices: feed history is nil") + } + if len(fh.PriceHistory) == 0 { + return PricesResult{}, errors.New("compute prices: feed history price_history is empty") + } + last := fh.PriceHistory[len(fh.PriceHistory)-1] + usdPrice, err := last.ToPrice() + if err != nil { + return PricesResult{}, errors.Wrapf(err, "feed history last entry base=%q quote=%q", last.Base, last.Quote) + } + result.SteemUsd = usdPrice + + // --- SteemVest: total_vesting_fund_steem / total_vesting_shares --- + if dgp == nil { + return PricesResult{}, errors.New("compute prices: dynamic global properties is nil") + } + vestPrice, err := ParsePrice(dgp.TotalVestingFundSteem, dgp.TotalVestingShares) + if err != nil { + return PricesResult{}, errors.Wrapf(err, + "vesting price fund=%q shares=%q", + dgp.TotalVestingFundSteem, dgp.TotalVestingShares, + ) + } + result.SteemVest = vestPrice + + return result, nil +} diff --git a/protocol/api/prices_test.go b/protocol/api/prices_test.go new file mode 100644 index 0000000..ddf568d --- /dev/null +++ b/protocol/api/prices_test.go @@ -0,0 +1,237 @@ +package api + +import ( + _ "embed" + "encoding/json" + "testing" +) + +//go:embed testdata/order_book.json +var orderBookJSON []byte + +//go:embed testdata/feed_history.json +var feedHistoryJSON []byte + +//go:embed testdata/dynamic_global_properties.json +var dgpJSON []byte + +// loadFixture 读取 testdata JSON 文件到对应 wire 类型,确认 fixture 可反序列化。 +func loadOrderBook(t *testing.T) *OrderBook { + t.Helper() + var ob OrderBook + if err := json.Unmarshal(orderBookJSON, &ob); err != nil { + t.Fatalf("unmarshal order_book fixture: %v", err) + } + return &ob +} + +func loadFeedHistory(t *testing.T) *FeedHistory { + t.Helper() + var fh FeedHistory + if err := json.Unmarshal(feedHistoryJSON, &fh); err != nil { + t.Fatalf("unmarshal feed_history fixture: %v", err) + } + return &fh +} + +func loadDGP(t *testing.T) *DynamicGlobalProperties { + t.Helper() + var dgp DynamicGlobalProperties + if err := json.Unmarshal(dgpJSON, &dgp); err != nil { + t.Fatalf("unmarshal dynamic_global_properties fixture: %v", err) + } + return &dgp +} + +// TestToPrice_OrderPrice 桥接方法把 wire string-asset 解析为 Price。 +func TestToPrice_OrderPrice(t *testing.T) { + op := OrderPrice{Base: "1.000 SBD", Quote: "1.000 STEEM"} + p, err := op.ToPrice() + if err != nil { + t.Fatalf("ToPrice: %v", err) + } + if p.Base.Symbol != "SBD" || p.Quote.Symbol != "STEEM" { + t.Errorf("unexpected price: %+v", p) + } + if p.Base.Amount != 1000 || p.Quote.Amount != 1000 { + t.Errorf("unexpected atoms: base=%d quote=%d", p.Base.Amount, p.Quote.Amount) + } +} + +// TestToPrice_CurrentMedianHistoryPrice 桥接方法对 feed entry 同样工作。 +func TestToPrice_CurrentMedianHistoryPrice(t *testing.T) { + c := CurrentMedianHistoryPrice{Base: "0.510 SBD", Quote: "1.000 STEEM"} + p, err := c.ToPrice() + if err != nil { + t.Fatalf("ToPrice: %v", err) + } + if p.Base.Amount != 510 || p.Quote.Amount != 1000 { + t.Errorf("unexpected atoms: base=%d quote=%d", p.Base.Amount, p.Quote.Amount) + } +} + +// TestToPrice_Errors 非法输入向上报错。 +func TestToPrice_Errors(t *testing.T) { + // 同 symbol。 + op := OrderPrice{Base: "1.000 STEEM", Quote: "1.000 STEEM"} + if _, err := op.ToPrice(); err == nil { + t.Error("OrderPrice.ToPrice same symbol: expected error") + } + // 非法 symbol。 + c := CurrentMedianHistoryPrice{Base: "1.000 XYZ", Quote: "1.000 STEEM"} + if _, err := c.ToPrice(); err == nil { + t.Error("CurrentMedianHistoryPrice.ToPrice bad symbol: expected error") + } +} + +// TestComputePrices_Fixture 用 testdata JSON 跑 ComputePrices, +// 断言每项精确原子值(验收标准 5)。 +// +// 期望(手算): +// - asks: 1.000 SBD/1.000 STEEM → Convert(1.000 STEEM)=1000 SBD atoms +// 2.000 SBD/1.000 STEEM → 2000 +// - bids: 0.500 SBD/1.000 STEEM → 500 +// 0.250 SBD/1.000 STEEM → 250 +// sum=3750, count=4 → avg=937 SBD atoms → "0.937 SBD" +// - SteemUsd: feed 最后一项 base=0.510 SBD → {base=510 SBD, quote=1000 STEEM} +// - SteemVest: fund=150000000000.000 STEEM(1.5×10^14 atoms), shares=80000000000.000000 VESTS(8×10^16 atoms) +func TestComputePrices_Fixture(t *testing.T) { + ob := loadOrderBook(t) + fh := loadFeedHistory(t) + dgp := loadDGP(t) + + res, err := ComputePrices(ob, fh, dgp) + if err != nil { + t.Fatalf("ComputePrices: %v", err) + } + + // SteemSbd + if res.SteemSbd.Amount != 937 || res.SteemSbd.Symbol != "SBD" { + t.Errorf("SteemSbd = %+v want {937 SBD}", res.SteemSbd) + } + if got := res.SteemSbd.String(); got != "0.937 SBD" { + t.Errorf("SteemSbd.String = %q want %q", got, "0.937 SBD") + } + + // SteemUsd + if res.SteemUsd.Base.Amount != 510 || res.SteemUsd.Quote.Amount != 1000 { + t.Errorf("SteemUsd = %+v want base=510 quote=1000", res.SteemUsd) + } + + // SteemVest + if res.SteemVest.Base.Amount != 150000000000000 || res.SteemVest.Quote.Amount != 80000000000000000 { + t.Errorf("SteemVest = %+v want base=150000000000000 quote=80000000000000000", res.SteemVest) + } + // 顺便验证 SteemVest 的 vesting 换算与 golden2 一致(1 STEEM → 533333 VESTS atoms)。 + oneSteem, _ := ParseAsset("1.000 STEEM") + vestPerSteem, err := res.SteemVest.Convert(oneSteem) + if err != nil { + t.Fatalf("SteemVest.Convert(1.000 STEEM): %v", err) + } + if vestPerSteem.Amount != 533333 { + t.Errorf("vestPerSteem = %d want 533333", vestPerSteem.Amount) + } +} + +// TestComputePrices_Errors 各 nil / 空输入必须报错。 +func TestComputePrices_Errors(t *testing.T) { + fh := loadFeedHistory(t) + dgp := loadDGP(t) + + // nil order book。 + if _, err := ComputePrices(nil, fh, dgp); err == nil { + t.Error("nil order book: expected error") + } + // empty order book。 + emptyOB := &OrderBook{} + if _, err := ComputePrices(emptyOB, fh, dgp); err == nil { + t.Error("empty order book: expected error") + } + // nil feed history。 + ob := loadOrderBook(t) + if _, err := ComputePrices(ob, nil, dgp); err == nil { + t.Error("nil feed history: expected error") + } + // empty price_history。 + if _, err := ComputePrices(ob, &FeedHistory{}, dgp); err == nil { + t.Error("empty price_history: expected error") + } + // nil dgp。 + if _, err := ComputePrices(ob, fh, nil); err == nil { + t.Error("nil dgp: expected error") + } +} + +// TestComputePrices_SymbolMismatch order book 中混入不匹配 STEEM 的 order 必须报错。 +func TestComputePrices_SymbolMismatch(t *testing.T) { + ob := &OrderBook{ + Asks: []Order{{OrderPrice: OrderPrice{Base: "1.000 STEEM", Quote: "1.000 STEEM"}}}, // 非法 + } + fh := loadFeedHistory(t) + dgp := loadDGP(t) + if _, err := ComputePrices(ob, fh, dgp); err == nil { + t.Error("symbol mismatch in order: expected error") + } +} + +// === 审计修复 #1 回归 === + +// TestComputePrices_RejectsNonSbdOrder 修复 #1:order book 含非 STEEM/SBD 对 +// (如 STEEM/VESTS)时,Convert 结果符号是 VESTS,必须报错而非贴硬编码 "SBD" +// 撒谎。此前的实现会返回 SteemSbd.Symbol="SBD" 但 Amount 实为 VESTS 原子, +// 导致 String() 输出连数值都错(VESTS 6 位 vs SBD 3 位精度差 1000 倍)。 +func TestComputePrices_RejectsNonSbdOrder(t *testing.T) { + ob := &OrderBook{ + Asks: []Order{ + {OrderPrice: OrderPrice{Base: "0.000001 VESTS", Quote: "1.000 STEEM"}}, + }, + } + fh := loadFeedHistory(t) + dgp := loadDGP(t) + _, err := ComputePrices(ob, fh, dgp) + if err == nil { + t.Fatal("ComputePrices with VESTS order: expected error, got nil (would silently mislabel symbol)") + } +} + +// TestComputePrices_RejectsInconsistentSymbols 修复 #1:所有 order 的结果 symbol +// 必须一致。混入一个结果非 SBD 的 order 必须报错。 +func TestComputePrices_RejectsInconsistentSymbols(t *testing.T) { + ob := &OrderBook{ + Asks: []Order{ + {OrderPrice: OrderPrice{Base: "1.000 SBD", Quote: "1.000 STEEM"}}}, + Bids: []Order{ + // Convert(1.000 STEEM) 结果是 VESTS,与 asks 的 SBD 不一致。 + {OrderPrice: OrderPrice{Base: "0.000001 VESTS", Quote: "1.000 STEEM"}}}, + } + fh := loadFeedHistory(t) + dgp := loadDGP(t) + if _, err := ComputePrices(ob, fh, dgp); err == nil { + t.Error("ComputePrices with mixed SBD/VESTS orders: expected error") + } +} + +// TestComputePrices_SbdSymbolNotHardcoded 修复 #1 的正向验证:纯 STEEM/SBD 对的 +// order book 正常工作,且结果 symbol 是从 Convert 推断的 "SBD"(而非硬编码)。 +// 关键断言:SteemSbd.Symbol 必须等于 "SBD",且数值与手算一致。 +func TestComputePrices_SbdSymbolNotHardcoded(t *testing.T) { + // 反向 order(base=STEEM quote=SBD)也能正确推导出 SBD 结果。 + ob := &OrderBook{ + Asks: []Order{ + // Convert(1.000 STEEM): STEEM 匹配 base → 结果 quote=SBD + // amount = 1000(steem) × 1000(sbd) / 1000(steem) = 1000 SBD atoms + {OrderPrice: OrderPrice{Base: "1.000 STEEM", Quote: "1.000 SBD"}}}, + } + fh := loadFeedHistory(t) + dgp := loadDGP(t) + res, err := ComputePrices(ob, fh, dgp) + if err != nil { + t.Fatalf("ComputePrices: %v", err) + } + if res.SteemSbd.Symbol != "SBD" { + t.Errorf("SteemSbd.Symbol = %q want SBD (must be inferred, not hardcoded)", res.SteemSbd.Symbol) + } + if res.SteemSbd.Amount != 1000 { + t.Errorf("SteemSbd.Amount = %d want 1000", res.SteemSbd.Amount) + } +} diff --git a/protocol/api/testdata/dynamic_global_properties.json b/protocol/api/testdata/dynamic_global_properties.json new file mode 100644 index 0000000..76bc663 --- /dev/null +++ b/protocol/api/testdata/dynamic_global_properties.json @@ -0,0 +1,4 @@ +{ + "total_vesting_fund_steem": "150000000000.000 STEEM", + "total_vesting_shares": "80000000000.000000 VESTS" +} diff --git a/protocol/api/testdata/feed_history.json b/protocol/api/testdata/feed_history.json new file mode 100644 index 0000000..50262e8 --- /dev/null +++ b/protocol/api/testdata/feed_history.json @@ -0,0 +1,7 @@ +{ + "price_history": [ + {"base": "0.400 SBD", "quote": "1.000 STEEM"}, + {"base": "0.500 SBD", "quote": "1.000 STEEM"}, + {"base": "0.510 SBD", "quote": "1.000 STEEM"} + ] +} diff --git a/protocol/api/testdata/order_book.json b/protocol/api/testdata/order_book.json new file mode 100644 index 0000000..fd5faa9 --- /dev/null +++ b/protocol/api/testdata/order_book.json @@ -0,0 +1,10 @@ +{ + "asks": [ + {"order_price": {"base": "1.000 SBD", "quote": "1.000 STEEM"}}, + {"order_price": {"base": "2.000 SBD", "quote": "1.000 STEEM"}} + ], + "bids": [ + {"order_price": {"base": "0.500 SBD", "quote": "1.000 STEEM"}}, + {"order_price": {"base": "0.250 SBD", "quote": "1.000 STEEM"}} + ] +}