-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathtransfer_safety_test.go
More file actions
331 lines (302 loc) · 12.6 KB
/
Copy pathtransfer_safety_test.go
File metadata and controls
331 lines (302 loc) · 12.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
// Copyright 2025 HOLOGRAM Project. All rights reserved.
// Regression tests for irreversible-action footguns on the send surface:
// 1. formatDEROAmount must use the correct atomic divisor (1e5), so the figure
// a user reads before approving a send is not understated.
// 2. The send path must reject a destination whose network byte does not match
// the active network — a wrong-network paste is an irreversible mis-send.
// 3. An integrated-address invoice (deroi…) must be reconciled — an expired
// address or a wrong amount is rejected, never paid silently.
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/deroproject/derohe/rpc"
)
// a real, valid mainnet address used as a known-good base for building integrated
// addresses in tests (same vector the burn-guard tests reuse).
const testBaseMainnetAddr = "dero1qy976ssakhfynpd4lnh39u7gw9spfzr9z55ckfd0yhrhsdr235glgqq28xlvm"
// integratedAddr clones the known-good base address and attaches the given invoice
// arguments, returning the parsed integrated *rpc.Address.
func integratedAddr(t *testing.T, args rpc.Arguments) *rpc.Address {
t.Helper()
base, err := rpc.NewAddress(testBaseMainnetAddr)
if err != nil {
t.Fatalf("parse base address: %v", err)
}
ia := base.Clone()
ia.Arguments = args
if _, err := ia.MarshalText(); err != nil {
t.Fatalf("encode integrated address: %v", err)
}
return &ia
}
// TestCheckIntegratedInvoice locks in the reconciliation of a deroi… invoice: an
// expired address and an amount that does not match the requested amount must be
// rejected (non-empty error), while a satisfied invoice and a plain (non-integrated)
// address must pass (empty error). A regression here re-opens silent mis-payment.
// The payer's address is attached only when the destination asks for it, and the disclosure
// is real: a DERO recipient normally cannot tell who paid, so a false positive here silently
// deanonymises an ordinary payment. Hence the negative cases carry as much weight as the
// positive one.
func TestAttachReplybackAddresses(t *testing.T) {
self, err := rpc.NewAddress(testBaseMainnetAddr)
if err != nil {
t.Fatalf("parse self address: %v", err)
}
hasReplyback := func(tr rpc.Transfer) bool {
return tr.Payload_RPC.Has(rpc.RPC_REPLYBACK_ADDRESS, rpc.DataAddress)
}
t.Run("service asks -> attached", func(t *testing.T) {
addr := integratedAddr(t, rpc.Arguments{
{Name: rpc.RPC_NEEDS_REPLYBACK_ADDRESS, DataType: rpc.DataUint64, Value: uint64(0)},
})
transfers := []rpc.Transfer{{Destination: addr.String(), Amount: 12345}}
if n := attachReplybackAddresses(transfers, *self); n != 1 {
t.Fatalf("expected 1 attachment, got %d", n)
}
if !hasReplyback(transfers[0]) {
t.Fatal("service demanded a reply address and none was attached — the service " +
"would keep the payment and answer nothing")
}
})
t.Run("plain address -> nothing attached", func(t *testing.T) {
transfers := []rpc.Transfer{{Destination: testBaseMainnetAddr, Amount: 12345}}
if n := attachReplybackAddresses(transfers, *self); n != 0 {
t.Fatalf("expected 0 attachments, got %d", n)
}
if hasReplyback(transfers[0]) {
t.Fatal("attached the payer's address to an ordinary payment — that discloses " +
"who paid when the recipient could not otherwise tell")
}
})
t.Run("destination port only -> nothing attached", func(t *testing.T) {
addr := integratedAddr(t, rpc.Arguments{
{Name: rpc.RPC_DESTINATION_PORT, DataType: rpc.DataUint64, Value: uint64(0x1234)},
})
transfers := []rpc.Transfer{{Destination: addr.String(), Amount: 12345}}
if n := attachReplybackAddresses(transfers, *self); n != 0 {
t.Fatalf("an ordinary invoice must not disclose the payer, got %d attachments", n)
}
})
// Engram overloads this same constant as DataString to carry a username in its messaging
// feature. Matching on the string form would attach an address to ordinary messages.
t.Run("string form is messaging, not a service -> nothing attached", func(t *testing.T) {
addr := integratedAddr(t, rpc.Arguments{
{Name: rpc.RPC_NEEDS_REPLYBACK_ADDRESS, DataType: rpc.DataString, Value: "someuser"},
})
transfers := []rpc.Transfer{{Destination: addr.String(), Amount: 12345}}
if n := attachReplybackAddresses(transfers, *self); n != 0 {
t.Fatalf("the DataString form is Engram messaging, not a reply-back service, got %d", n)
}
})
t.Run("never attached twice", func(t *testing.T) {
addr := integratedAddr(t, rpc.Arguments{
{Name: rpc.RPC_NEEDS_REPLYBACK_ADDRESS, DataType: rpc.DataUint64, Value: uint64(0)},
})
transfers := []rpc.Transfer{{Destination: addr.String(), Amount: 12345}}
attachReplybackAddresses(transfers, *self)
if n := attachReplybackAddresses(transfers, *self); n != 0 {
t.Fatalf("second pass must be a no-op, got %d", n)
}
})
}
func TestCheckIntegratedInvoice(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
past := now.Add(-time.Hour)
future := now.Add(time.Hour)
t.Run("plain address -> ok", func(t *testing.T) {
addr, _ := rpc.NewAddress(testBaseMainnetAddr)
if msg := checkIntegratedInvoice(addr, 12345, now); msg != "" {
t.Fatalf("plain address should pass, got %q", msg)
}
})
t.Run("nil address -> ok", func(t *testing.T) {
if msg := checkIntegratedInvoice(nil, 12345, now); msg != "" {
t.Fatalf("nil address should pass, got %q", msg)
}
})
t.Run("requested amount matches -> ok", func(t *testing.T) {
addr := integratedAddr(t, rpc.Arguments{
{Name: rpc.RPC_VALUE_TRANSFER, DataType: rpc.DataUint64, Value: uint64(500000)},
})
if msg := checkIntegratedInvoice(addr, 500000, now); msg != "" {
t.Fatalf("matching amount should pass, got %q", msg)
}
})
t.Run("requested amount mismatch -> blocked", func(t *testing.T) {
addr := integratedAddr(t, rpc.Arguments{
{Name: rpc.RPC_VALUE_TRANSFER, DataType: rpc.DataUint64, Value: uint64(500000)},
})
if msg := checkIntegratedInvoice(addr, 400000, now); msg == "" {
t.Fatal("amount mismatch must be blocked, got pass")
}
})
t.Run("expired -> blocked", func(t *testing.T) {
addr := integratedAddr(t, rpc.Arguments{
{Name: rpc.RPC_EXPIRY, DataType: rpc.DataTime, Value: past},
})
if msg := checkIntegratedInvoice(addr, 12345, now); msg == "" {
t.Fatal("expired address must be blocked, got pass")
}
})
// A reply-back service is no longer blocked — the address is attached instead (see
// TestAttachReplybackAddresses). The invoice check must not veto it.
t.Run("needs replyback -> not an invoice error", func(t *testing.T) {
addr := integratedAddr(t, rpc.Arguments{
{Name: rpc.RPC_NEEDS_REPLYBACK_ADDRESS, DataType: rpc.DataUint64, Value: uint64(0)},
})
if msg := checkIntegratedInvoice(addr, 12345, now); msg != "" {
t.Fatalf("reply-back is handled by attaching the address, not by blocking, got %q", msg)
}
})
t.Run("not yet expired -> ok", func(t *testing.T) {
addr := integratedAddr(t, rpc.Arguments{
{Name: rpc.RPC_EXPIRY, DataType: rpc.DataTime, Value: future},
})
if msg := checkIntegratedInvoice(addr, 12345, now); msg != "" {
t.Fatalf("unexpired address should pass, got %q", msg)
}
})
t.Run("expiry checked before amount", func(t *testing.T) {
// An expired invoice with a matching amount must still block on expiry.
addr := integratedAddr(t, rpc.Arguments{
{Name: rpc.RPC_EXPIRY, DataType: rpc.DataTime, Value: past},
{Name: rpc.RPC_VALUE_TRANSFER, DataType: rpc.DataUint64, Value: uint64(500000)},
})
if msg := checkIntegratedInvoice(addr, 500000, now); msg == "" {
t.Fatal("expired-but-matching invoice must be blocked")
}
})
}
// TestFormatDEROAmountUsesAtomicDivisor locks DERO's 5-decimal scale (1 DERO = 100000
// atomic units) into formatDEROAmount. A wrong divisor understates every figure on the
// send/approval/gas surface, mis-anchoring consent on an irreversible action. If anyone
// reverts the divisor (the shipped v1.0.7 code divided by 1e12, understating by 1e7),
// these assertions fail the build.
func TestFormatDEROAmountUsesAtomicDivisor(t *testing.T) {
cases := []struct {
name string
atomic uint64
want string
}{
{"one DERO", 100000, "1.00000"},
{"half DERO", 50000, "0.50000"},
{"smallest unit", 1, "0.00001"},
{"zero", 0, "0.00000"},
{"15000 DERO (the incident amount)", 1500000000, "15.00K"},
{"two DERO", 200000, "2.00000"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := formatDEROAmount(c.atomic); got != c.want {
t.Fatalf("formatDEROAmount(%d) = %q, want %q", c.atomic, got, c.want)
}
})
}
}
// TestNoWrongAtomicDivisorReintroduced is a source-level sentinel: the "/ 1e12" divisor
// (the v1.0.7 formatDEROAmount bug) must never come back. DERO is 5-decimal; every
// money-formatting site divides by 1e5/100000. If a future edit reintroduces "/ 1e12"
// anywhere in the Go sources, this fails the build loudly instead of silently shipping a
// 10,000,000x-wrong figure on the approval surface.
func TestNoWrongAtomicDivisorReintroduced(t *testing.T) {
banned := []string{"/ 1e12", "/1e12"}
skipDir := func(name string) bool {
switch name {
case "node_modules", "dist", ".git", ".task", "build", "bin", "tela-cli", "datashards":
return true
}
return false
}
var offenders []string
_ = filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
if skipDir(info.Name()) {
return filepath.SkipDir
}
return nil
}
if filepath.Ext(path) != ".go" {
return nil
}
// This sentinel test file legitimately names the banned token; skip it.
if filepath.Base(path) == "transfer_safety_test.go" {
return nil
}
data, readErr := os.ReadFile(path)
if readErr != nil {
return nil
}
for _, tok := range banned {
if strings.Contains(string(data), tok) {
offenders = append(offenders, path+" contains "+tok)
}
}
return nil
})
if len(offenders) > 0 {
t.Fatalf("wrong atomic divisor reintroduced (DERO is 5-decimal; use 1e5/100000):\n %s",
strings.Join(offenders, "\n "))
}
}
// TestTransferRejectsNetworkMismatchSentinel is a source-level sentinel that fails the
// build if the destination network-byte check is removed from the Transfer send path.
// The DERO library rejects an unparseable address but does NOT compare the address
// network byte against the wallet's network — a wrong-network (deto1 on mainnet) paste
// would otherwise build and send silently, irreversibly. The guard lives in Transfer()
// in wallet.go and must keep comparing addr.IsMainnet() to the active network.
func TestTransferRejectsNetworkMismatchSentinel(t *testing.T) {
data, err := os.ReadFile("wallet.go")
if err != nil {
t.Fatalf("read wallet.go: %v", err)
}
src := string(data)
// The guard parses the destination and compares its network to the wallet's.
// Both halves must be present in the send path; if either is dropped, fail.
required := []string{
"rpc.NewAddress(destination)",
"addr.IsMainnet() != walletIsMainnet",
}
var missing []string
for _, r := range required {
if !strings.Contains(src, r) {
missing = append(missing, r)
}
}
if len(missing) > 0 {
t.Fatalf("Transfer() destination network-mismatch guard weakened or removed; missing:\n %s\n"+
"A wrong-network destination paste is an irreversible mis-send — restore the network-byte check in Transfer().",
strings.Join(missing, "\n "))
}
}
// TestTransferReconcilesIntegratedInvoiceSentinel is a source-level sentinel that fails
// the build if Transfer() stops reconciling an integrated-address invoice. Ignoring the
// embedded RPC_VALUE_TRANSFER / RPC_EXPIRY (the original "for now, log it" stub) lets a
// user pay the wrong amount or an expired invoice — an irreversible mis-payment.
func TestTransferReconcilesIntegratedInvoiceSentinel(t *testing.T) {
data, err := os.ReadFile("wallet.go")
if err != nil {
t.Fatalf("read wallet.go: %v", err)
}
src := string(data)
if !strings.Contains(src, "checkIntegratedInvoice(addr,") {
t.Fatal("Transfer() no longer calls checkIntegratedInvoice — integrated-address invoice " +
"reconciliation removed. An expired or wrong-amount invoice would be paid silently; " +
"restore the call in Transfer().")
}
// Hot Send / XSWD use InternalWalletCall("transfer"), not App.Transfer() (R2-B7).
if !strings.Contains(src, "checkTransfersIntegratedInvoices(") {
t.Fatal("InternalWalletCall path no longer calls checkTransfersIntegratedInvoices — " +
"the Send UI and XSWD would skip invoice expiry/amount checks. Restore the call.")
}
if !strings.Contains(src, "scArgsAreRealCall(") {
t.Fatal("burn guard must use scArgsAreRealCall (not len(scArgs)>0) so junk sc_rpc " +
"cannot bypass a native-DERO burn block (R2-B6).")
}
}