fix(v2): crypto 预留键改按地址(对齐链上匹配维度)——防共享地址跨账户同额碰撞
- allocateAmount 中解析地址后改用 address+'/'+amount 作为预留键,而非 accountID - 同一地址的不同账户不能同时占用同金额,链上唯一性保证对齐 Query 的 to==addr 维度 - 新增 GetReserved() 测试方法暴露预留表副本,验证键格式 - 新增 TestReservationKeyByAddress 测试:两个账户共享地址,验证预留键基于地址 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
@@ -51,7 +51,7 @@ type Provider struct {
|
|||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
reserved map[string]time.Time // "<accountID>/<amount>" → 预留到期(canonical AmountRecentlyUsed 的进程内等价)
|
reserved map[string]time.Time // "<address>/<amount>" → 预留到期(链上匹配维度,对齐 Query 的 to==addr)(canonical AmountRecentlyUsed 的进程内等价)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Option func(*Provider)
|
type Option func(*Provider)
|
||||||
@@ -99,9 +99,27 @@ func (p *Provider) apiKey(accountID string) string {
|
|||||||
return os.Getenv("TRONGRID_API_KEY")
|
return os.Getenv("TRONGRID_API_KEY")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetReserved 仅用于测试,返回预留表副本。
|
||||||
|
func (p *Provider) GetReserved() map[string]time.Time {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
m := make(map[string]time.Time, len(p.reserved))
|
||||||
|
for k, v := range p.reserved {
|
||||||
|
m[k] = v
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
// allocateAmount 移植 canonical pay/service.go:随机尾数 [1,tailMax] + 冷却预留,
|
// allocateAmount 移植 canonical pay/service.go:随机尾数 [1,tailMax] + 冷却预留,
|
||||||
// 保证同(地址,金额)在冷却窗内唯一——迟到付款绝不可能匹配到新单。64 次重试。
|
// 保证同(地址,金额)在冷却窗内唯一——迟到付款绝不可能匹配到新单。64 次重试。
|
||||||
|
// 预留键基于地址(链上匹配维度),防止共享地址的不同账户产生同金额碰撞。
|
||||||
func (p *Provider) allocateAmount(accountID string, base int64) (amount, tail int64, err error) {
|
func (p *Provider) allocateAmount(accountID string, base int64) (amount, tail int64, err error) {
|
||||||
|
// 解析地址(同 Query 逻辑,作为链上匹配维度的真相源)
|
||||||
|
addr, err := p.address(accountID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
now := p.now()
|
now := p.now()
|
||||||
@@ -116,7 +134,7 @@ func (p *Provider) allocateAmount(accountID string, base int64) (amount, tail in
|
|||||||
return 0, 0, rerr
|
return 0, 0, rerr
|
||||||
}
|
}
|
||||||
amt := base + t
|
amt := base + t
|
||||||
key := accountID + "/" + strconv.FormatInt(amt, 10)
|
key := addr + "/" + strconv.FormatInt(amt, 10)
|
||||||
if _, used := p.reserved[key]; used {
|
if _, used := p.reserved[key]; used {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,3 +141,68 @@ func TestVerifyCallbackNotSupported(t *testing.T) {
|
|||||||
t.Fatal("crypto 无异步回调,VerifyCallback 应返回 ErrNotSupported")
|
t.Fatal("crypto 无异步回调,VerifyCallback 应返回 ErrNotSupported")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 预留键按地址而非 accountID 维度:防止共享地址的不同账户在同金额上碰撞。
|
||||||
|
// 两个 accountID 配置相同 ADDRESS,分别创建订单 → 验证预留键格式为 "address/amount",
|
||||||
|
// 而非 "accountID/amount"。
|
||||||
|
func TestReservationKeyByAddress(t *testing.T) {
|
||||||
|
const sharedAddr = "TSHARED_ADDRESS_FOR_TEST"
|
||||||
|
t.Setenv("ACC1_ADDRESS", sharedAddr)
|
||||||
|
t.Setenv("ACC1_TRONGRID_KEY", "key1")
|
||||||
|
t.Setenv("ACC2_ADDRESS", sharedAddr)
|
||||||
|
t.Setenv("ACC2_TRONGRID_KEY", "key2")
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.NotFoundHandler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
reg := accounts.New([]config.AccountConfig{
|
||||||
|
{AccountID: "acc-1", Channel: "crypto", Enabled: true, CredentialEnvPrefix: "acc1"},
|
||||||
|
{AccountID: "acc-2", Channel: "crypto", Enabled: true, CredentialEnvPrefix: "acc2"},
|
||||||
|
})
|
||||||
|
p := crypto.New(reg, crypto.WithBaseURL(ts.URL), crypto.WithHTTPClient(ts.Client()))
|
||||||
|
|
||||||
|
base := int64(50000000)
|
||||||
|
|
||||||
|
// 为账户 1 创建订单
|
||||||
|
sess1, err1 := p.Create(context.Background(), provider.CreateRequest{
|
||||||
|
OutTradeNo: "ORDER-1",
|
||||||
|
AmountMinor: base,
|
||||||
|
Currency: "USDT",
|
||||||
|
Account: config.AccountConfig{AccountID: "acc-1", CredentialEnvPrefix: "acc1"},
|
||||||
|
})
|
||||||
|
if err1 != nil {
|
||||||
|
t.Fatalf("acc-1 create: %v", err1)
|
||||||
|
}
|
||||||
|
amt1 := sess1.Payload["amount_minor"].(int64)
|
||||||
|
|
||||||
|
// 为账户 2 创建订单(同 base,共享地址)
|
||||||
|
sess2, err2 := p.Create(context.Background(), provider.CreateRequest{
|
||||||
|
OutTradeNo: "ORDER-2",
|
||||||
|
AmountMinor: base,
|
||||||
|
Currency: "USDT",
|
||||||
|
Account: config.AccountConfig{AccountID: "acc-2", CredentialEnvPrefix: "acc2"},
|
||||||
|
})
|
||||||
|
if err2 != nil {
|
||||||
|
t.Fatalf("acc-2 create: %v", err2)
|
||||||
|
}
|
||||||
|
amt2 := sess2.Payload["amount_minor"].(int64)
|
||||||
|
|
||||||
|
// 白盒验证:检查预留键格式(应为 "address/amount" 而非 "accountID/amount")
|
||||||
|
reserved := p.GetReserved()
|
||||||
|
|
||||||
|
// 新代码:键应为 "address/amount"
|
||||||
|
expectedKey1 := sharedAddr + "/" + strconv.FormatInt(amt1, 10)
|
||||||
|
expectedKey2 := sharedAddr + "/" + strconv.FormatInt(amt2, 10)
|
||||||
|
|
||||||
|
if _, found := reserved[expectedKey1]; !found {
|
||||||
|
t.Fatalf("预留键应为 %q(address/amount 格式), 实际键: %v", expectedKey1, reserved)
|
||||||
|
}
|
||||||
|
if _, found := reserved[expectedKey2]; !found {
|
||||||
|
t.Fatalf("预留键应为 %q(address/amount 格式), 实际键: %v", expectedKey2, reserved)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 两个账户应分配不同金额(防止链上同地址同金额碰撞)
|
||||||
|
if amt1 == amt2 {
|
||||||
|
t.Fatalf("共享地址的两个账户不应分配相同金额: %d", amt1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user