Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions internal/chain/hms_ledger.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
118 changes: 112 additions & 6 deletions internal/chain/zz_hms_transfers_settle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)
}
}
Loading