diff --git a/internal/chain/hms_ledger.go b/internal/chain/hms_ledger.go index a6c1c58..838018d 100644 --- a/internal/chain/hms_ledger.go +++ b/internal/chain/hms_ledger.go @@ -120,6 +120,12 @@ func ValidateHmsTransferShape(tx HmsTransferTx) (code, msg string) { if from == "" || to == "" || !strings.HasPrefix(from, "HMC-") || !strings.HasPrefix(to, "HMC-") { return "invalid_address", "from/to must be HMC- addresses" } + // The signing payload and the checks above trim addresses, but settlement + // credits the raw tx strings: a padded recipient would settle into an account + // row no address lookup can find, stranding the funds. Reject instead. + if tx.From != from || tx.To != to { + return "invalid_address", "from/to must not contain surrounding whitespace" + } // Parity with the HMC lane and the report #30 SUP fix: self-sends are rejected. // Without this, the recipient UPSERT in applyPendingHmsTransfers would clobber // the sender debit and mint HMS. diff --git a/internal/chain/zz_hms_transfers_settle_test.go b/internal/chain/zz_hms_transfers_settle_test.go index 7b38388..8d122d7 100755 --- a/internal/chain/zz_hms_transfers_settle_test.go +++ b/internal/chain/zz_hms_transfers_settle_test.go @@ -2,19 +2,23 @@ package chain // HMS transfer settlement regression tests. // -// On main, applyPendingHmsTransfers has zero call sites, so accepted HMS -// transfers stay pending forever (the endpoint returns a tx hash and the -// pool is never drained), and ValidateHmsTransferShape does not reject -// From == To. These tests pin the fixed behavior: transfers settle on the -// next PoH block (same as the HMC and SUP lanes) and self-sends are -// rejected at submit (parity with the HMC lane and the report #30 SUP fix). +// Before #26, applyPendingHmsTransfers had zero call sites, so accepted HMS +// transfers stayed pending forever (the endpoint returned a tx hash and the +// pool was never drained), and ValidateHmsTransferShape did not reject +// From == To. #26 wired the applier and the self-send rejection; this file +// pins the settled behavior: transfers settle on the next PoH block (same as +// the HMC and SUP lanes), self-sends are rejected at submit (parity with the +// HMC lane and the report #30 SUP fix), and addresses with surrounding +// whitespace are rejected before they can strand funds. import ( "context" "crypto/ed25519" "crypto/rand" "encoding/hex" + "encoding/json" "path/filepath" + "strings" "testing" "time" @@ -141,3 +145,105 @@ func TestHMSSelfTransferRejectedAtSubmit(t *testing.T) { t.Fatalf("self-send must not enter the pool: rows=%d", cnt) } } + +func TestHMSTransferRejectsPaddedAddress(t *testing.T) { + cases := []struct { + name string + pad string // "from" = append a space to From + to string + }{ + {"trailing space in to", "", "HMC-cccccccccccccccc "}, + {"leading space in to", "", " HMC-cccccccccccccccc"}, + {"trailing tab in to", "", "HMC-cccccccccccccccc\t"}, + {"trailing newline in to", "", "HMC-cccccccccccccccc\n"}, + {"trailing crlf in to", "", "HMC-cccccccccccccccc\r\n"}, + {"trailing carriage return in to", "", "HMC-cccccccccccccccc\r"}, + {"padded from", "from", "HMC-cccccccccccccccc"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + svc, addrA, privA := hmsSettleWallet(t) + from := addrA + if tc.pad == "from" { + from = addrA + " " + } + // canonicalBytes trims From/To for signing, so the tx carries a valid + // signature over the trimmed form while the raw payload is padded. + tx := hmsSettleTx(t, svc, from, tc.to, HMSToUnits(1.0), 0, privA) + _, st, err := svc.SubmitHmsTransferTx(ctx, tx) + if st != "invalid_address" || err == nil { + t.Fatalf("padded address must be rejected with invalid_address: st=%q err=%v", st, err) + } + if !strings.Contains(err.Error(), "surrounding whitespace") { + t.Fatalf("want the whitespace guard to fire, got err=%v", err) + } + var cnt int + if err := svc.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM hms_tx_pool`).Scan(&cnt); err != nil { + t.Fatal(err) + } + if cnt != 0 { + t.Fatalf("padded-address tx must not enter the pool: rows=%d", cnt) + } + }) + } +} + +func TestHMSTransferPaddedRowRejectedAtApply(t *testing.T) { + ctx := context.Background() + svc, addrA, privA := hmsSettleWallet(t) + // Emulate a padded-transfer row that entered hms_tx_pool before this guard + // shipped: sign it over the trimmed form exactly like a wallet that + // accepted it pre-guard, then insert it directly, bypassing submit-time + // validation. With the guard, shape validation runs before the signature + // check at apply time, so the padded To is rejected; without it, the row + // settles and credits the raw padded address. + tx := hmsSettleTx(t, svc, addrA, "HMC-cccccccccccccccc ", HMSToUnits(1.0), 0, privA) + h, err := tx.HashHex() + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(tx) + if err != nil { + t.Fatal(err) + } + if _, err := svc.db.ExecContext(ctx, + `INSERT INTO hms_tx_pool (tx_hash, tx_json, from_address, to_address, nonce, fee_units, amount_units, received_at, status, reject_code) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', '')`, + h, string(raw), tx.From, tx.To, tx.Nonce, tx.FeeUnits, tx.AmountUnits, time.Now().Unix()); err != nil { + t.Fatal(err) + } + hmsSettleAppendBlock(t, svc) + + var status, code string + if err := svc.db.QueryRowContext(ctx, `SELECT status, reject_code FROM hms_tx_history WHERE tx_hash=?`, h).Scan(&status, &code); err != nil { + t.Fatal(err) + } + if status != "rejected" || code != "invalid_address" { + t.Fatalf("padded row must be rejected at apply: status=%q code=%q", status, code) + } + var rows int + if err := svc.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM hms_tx_pool`).Scan(&rows); err != nil { + t.Fatal(err) + } + if rows != 0 { + t.Fatalf("padded row must be deleted from the pool: rows=%d", rows) + } + var credited int + if err := svc.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM accounts WHERE address=?`, "HMC-cccccccccccccccc ").Scan(&credited); err != nil { + t.Fatal(err) + } + if credited != 0 { + t.Fatalf("no balance must be credited to the raw padded address: rows=%d", credited) + } + stA, err := svc.HmsAddressState(ctx, addrA) + if err != nil { + t.Fatal(err) + } + if stA.BalanceHMSUnits != HMSToUnits(5.0) { + t.Fatalf("sender must not be debited when the row is rejected: units=%d", stA.BalanceHMSUnits) + } + if stA.HMSNextNonce != 0 { + t.Fatalf("sender nonce must be untouched when the row is rejected: nonce=%d", stA.HMSNextNonce) + } +}