Files
zeekay 61d403336e test(bridge): TON broadcast + v4r2 assembler test coverage
internal/broadcast/ton_test.go:

- Happy path: hex payload → base64 on wire, X-API-Key header set
  per-network, response hash returned verbatim.
- Base64 payload pass-through (skip the hex re-encode).
- Error classification matrix:
    429 / "rate" → ErrTONRateLimited (retryable)
    "invalid boc" / "wrong signature" → ErrTONMalformedBOC (fatal)
    "account not initialized" → ErrTONAccountNotInitialized (re-build w/ state_init)
    HTTP 502/503/504 → TONError{Retryable:true}
- Family-dispatch parity: TON_TESTNET must NOT trip ErrFamilyNotImplemented.
- Direct parser tests (no HTTP) for non-JSON 5xx + hash-less success
  payloads (synthesized "ton:sendBoc:ok:200" identifier).
- isHexString + payload normalizer unit tests.

internal/txassembler/ton_test.go:

- Address derivation: same pubkey ⇒ same address (deterministic),
  different pubkeys ⇒ different addresses, UQ vs EQ flags surface
  correctly via TONAddressFromPubKey / …BounceableFromPubKey, raw
  form parses back to the same address (parse-roundtrip via
  ParseTONAddress).
- PreSign native transfer: deterministic body hash under a fixed
  clock, StateInit populated/skipped per SourceInitialized flag,
  rejects nil / zero / negative amounts + bad pubkey + spec
  SourceAddress mismatch.
- PreSign jetton: spec-provided JettonSourceWallet skips the
  JettonProvider, provider path is invoked with the right
  (network, master, owner) args, missing-provider + missing-master
  both error cleanly.
- PreSign + FinalizeTONHex round-trip: locally sign u.SigHash with
  Ed25519, finalize, decode the resulting BOC, verify
  ExternalMessage.Body's first 64 bytes match the signature.
- Body schema: 1 ref (the internal message), 112 bits of header
  data (subwallet 32 + valid_until 32 + seqno 32 + op 8 + mode 8).
- State_init hash matches the pubkey-derived address — confirms
  the wallet contract we deploy on first send IS the wallet
  contract whose address we keygen'd to.
- Static seqno provider drives v4r2 body's seqno field.
2026-05-27 17:55:34 -07:00

332 lines
10 KiB
Go

// Tests for the TON broadcast handler — focuses on response parsing,
// retry classification, payload normalization, and the HTTP wire shape.
package broadcast
import (
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// tonCenterServer is an httptest.Server mock that records the last
// request and lets a test pin the response shape.
type tonCenterServer struct {
t *testing.T
server *httptest.Server
respStatus int
respBody string
lastBoc string
lastAPIKey string
gotBodyJSON map[string]any
calls int
}
func newTONCenter(t *testing.T) *tonCenterServer {
t.Helper()
s := &tonCenterServer{t: t, respStatus: 200}
s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.calls++
s.lastAPIKey = r.Header.Get("X-API-Key")
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &s.gotBodyJSON)
if b, ok := s.gotBodyJSON["boc"].(string); ok {
s.lastBoc = b
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(s.respStatus)
_, _ = w.Write([]byte(s.respBody))
}))
t.Cleanup(s.server.Close)
return s
}
// =============================================================================
// Happy path
// =============================================================================
func TestBroadcast_TON_Confirmed(t *testing.T) {
srv := newTONCenter(t)
srv.respStatus = 200
srv.respBody = `{"ok":true,"result":{"@type":"ok","hash":"deadbeef"}}`
c := &Client{
Timeout: time.Second,
RPCURLOverrides: map[string]string{"TON_TESTNET": srv.server.URL},
TONAPIKeys: map[string]string{"TON_TESTNET": "tk-secret"},
}
res, err := c.Broadcast(context.Background(), "TON_TESTNET", "0xdeadbeefcafe")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.TxHash != "deadbeef" {
t.Errorf("TxHash = %q, want \"deadbeef\"", res.TxHash)
}
if srv.calls != 1 {
t.Errorf("calls = %d, want 1", srv.calls)
}
if srv.lastAPIKey != "tk-secret" {
t.Errorf("X-API-Key = %q, want \"tk-secret\"", srv.lastAPIKey)
}
// hex input should be repackaged as base64.
if srv.lastBoc == "" {
t.Error("body missing boc field")
}
// Verify the boc is base64 of the original hex bytes.
want, _ := hex.DecodeString("deadbeefcafe")
if got, err := base64.StdEncoding.DecodeString(srv.lastBoc); err != nil || string(got) != string(want) {
t.Errorf("boc = %q (decoded=%x), want %x", srv.lastBoc, got, want)
}
}
func TestBroadcast_TON_AcceptsBase64Payload(t *testing.T) {
srv := newTONCenter(t)
srv.respBody = `{"ok":true,"result":{"hash":"abc"}}`
c := &Client{
Timeout: time.Second,
RPCURLOverrides: map[string]string{"TON_MAINNET": srv.server.URL},
}
// Use a non-hex base64 string so isHexString returns false and the
// normalizer leaves it intact.
b64 := base64.StdEncoding.EncodeToString([]byte("not-hex-bytes-some-payload"))
if _, err := c.Broadcast(context.Background(), "TON_MAINNET", b64); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if srv.lastBoc != b64 {
t.Errorf("base64 input not passed through: got %q want %q", srv.lastBoc, b64)
}
}
func TestBroadcast_TON_SetTONAPIKey(t *testing.T) {
c := New(time.Second)
c.SetTONAPIKey("TON_MAINNET", "abc-key")
c.SetTONAPIKey("TON_TESTNET", "xyz-key")
if c.tonAPIKey("TON_MAINNET") != "abc-key" {
t.Error("SetTONAPIKey did not persist mainnet key")
}
if c.tonAPIKey("TON_TESTNET") != "xyz-key" {
t.Error("SetTONAPIKey did not persist testnet key")
}
if c.tonAPIKey("UNKNOWN") != "" {
t.Error("unconfigured network should return empty key")
}
}
// =============================================================================
// Error paths — retry classification
// =============================================================================
func TestBroadcast_TON_RateLimitRetryable(t *testing.T) {
srv := newTONCenter(t)
srv.respStatus = 429
srv.respBody = `{"ok":false,"error":"Too many requests","code":429}`
c := &Client{
Timeout: time.Second,
RPCURLOverrides: map[string]string{"TON_TESTNET": srv.server.URL},
}
_, err := c.Broadcast(context.Background(), "TON_TESTNET", "0xab")
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, ErrTONRateLimited) {
t.Errorf("expected ErrTONRateLimited, got %v", err)
}
}
func TestBroadcast_TON_MalformedBOCFatal(t *testing.T) {
srv := newTONCenter(t)
srv.respStatus = 400
srv.respBody = `{"ok":false,"error":"Bad Request: invalid boc","code":400}`
c := &Client{
Timeout: time.Second,
RPCURLOverrides: map[string]string{"TON_MAINNET": srv.server.URL},
}
_, err := c.Broadcast(context.Background(), "TON_MAINNET", "0xab")
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, ErrTONMalformedBOC) {
t.Errorf("expected ErrTONMalformedBOC, got %v", err)
}
}
func TestBroadcast_TON_AccountNotInitialized(t *testing.T) {
srv := newTONCenter(t)
srv.respStatus = 400
srv.respBody = `{"ok":false,"error":"account is not initialized","code":400}`
c := &Client{
Timeout: time.Second,
RPCURLOverrides: map[string]string{"TON_TESTNET": srv.server.URL},
}
_, err := c.Broadcast(context.Background(), "TON_TESTNET", "0xab")
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, ErrTONAccountNotInitialized) {
t.Errorf("expected ErrTONAccountNotInitialized, got %v", err)
}
}
func TestBroadcast_TON_HTTP502Retryable(t *testing.T) {
srv := newTONCenter(t)
srv.respStatus = 502
srv.respBody = `{"ok":false,"error":"Bad Gateway","code":502}`
c := &Client{
Timeout: time.Second,
RPCURLOverrides: map[string]string{"TON_MAINNET": srv.server.URL},
}
_, err := c.Broadcast(context.Background(), "TON_MAINNET", "0xab")
if err == nil {
t.Fatal("expected error, got nil")
}
var te *TONError
if !errors.As(err, &te) {
t.Fatalf("expected *TONError, got %T: %v", err, err)
}
if !te.Retryable {
t.Errorf("HTTP 502 should be Retryable, got %+v", te)
}
}
// =============================================================================
// Direct response parser tests (no HTTP)
// =============================================================================
func TestParseTONResponse_HashFromHexResult(t *testing.T) {
body := `{"ok":true,"result":{"hash":"abc123"}}`
res, err := parseTONResponse(200, []byte(body))
if err != nil {
t.Fatalf("unexpected: %v", err)
}
if res.TxHash != "abc123" {
t.Errorf("TxHash = %q", res.TxHash)
}
}
func TestParseTONResponse_NonJSON5xx(t *testing.T) {
_, err := parseTONResponse(503, []byte("upstream down"))
if err == nil {
t.Fatal("expected error")
}
var te *TONError
if !errors.As(err, &te) || !te.Retryable {
t.Errorf("non-JSON 5xx should be retryable TONError, got %v", err)
}
}
func TestParseTONResponse_EmptyHashSynthesized(t *testing.T) {
// Some forks return ok:true with no hash field. Make sure we
// synthesize a non-empty identifier so the broadcast driver
// advances the swap.
body := `{"ok":true,"result":{"@type":"ok"}}`
res, err := parseTONResponse(200, []byte(body))
if err != nil {
t.Fatal(err)
}
if res.TxHash == "" {
t.Error("expected synthesized hash on missing-hash success response")
}
if !strings.HasPrefix(res.TxHash, "ton:sendBoc:") {
t.Errorf("synthesized hash should be prefixed with ton:sendBoc:, got %q", res.TxHash)
}
}
// =============================================================================
// Payload normalizer
// =============================================================================
func TestNormalizeTONPayload_Empty(t *testing.T) {
if _, err := normalizeTONPayload(""); err == nil {
t.Error("empty input should error")
}
}
func TestNormalizeTONPayload_HexPrefix(t *testing.T) {
out, err := normalizeTONPayload("0xdead")
if err != nil {
t.Fatal(err)
}
want := base64.StdEncoding.EncodeToString([]byte{0xde, 0xad})
if out != want {
t.Errorf("got %q want %q", out, want)
}
}
func TestNormalizeTONPayload_BareHex(t *testing.T) {
out, err := normalizeTONPayload("cafe")
if err != nil {
t.Fatal(err)
}
want := base64.StdEncoding.EncodeToString([]byte{0xca, 0xfe})
if out != want {
t.Errorf("got %q want %q", out, want)
}
}
func TestNormalizeTONPayload_Base64PassThrough(t *testing.T) {
// "te6cck" — typical TON BOC prefix in base64.
in := "te6ccgEBAQEABgAACAAAAAA="
out, err := normalizeTONPayload(in)
if err != nil {
t.Fatal(err)
}
if out != in {
t.Errorf("base64 input should pass through; got %q", out)
}
}
// =============================================================================
// Existing-test parity: ErrFamilyNotImplemented should still fire for
// the non-EVM non-TON families.
// =============================================================================
func TestBroadcast_TON_RoutesToTONFamily(t *testing.T) {
// Without an override URL, the TON network falls through to
// ErrUnsupportedNetwork (no entry in the rpcURLs table). With an
// override, the family dispatch kicks in.
c := New(time.Second)
c.RPCURLOverrides = map[string]string{"TON_TESTNET": "http://127.0.0.1:0"}
_, err := c.Broadcast(context.Background(), "TON_TESTNET", "0xab")
if err == nil {
t.Fatal("expected an error from unreachable URL")
}
// Whatever happens, it must NOT be ErrFamilyNotImplemented (that
// would mean dispatch didn't route to TON).
if errors.Is(err, ErrFamilyNotImplemented) {
t.Errorf("TON family should be implemented; got ErrFamilyNotImplemented: %v", err)
}
}
// =============================================================================
// Hex helpers
// =============================================================================
func TestIsHexString(t *testing.T) {
cases := []struct {
s string
want bool
}{
{"", false},
{"a", false}, // odd length
{"ab", true}, // ok
{"AB", true}, // upper
{"xy", false}, // non-hex
{"abcdef0123456789", true},
}
for _, tc := range cases {
t.Run(tc.s, func(t *testing.T) {
if got := isHexString(tc.s); got != tc.want {
t.Errorf("isHexString(%q) = %v, want %v", tc.s, got, tc.want)
}
})
}
}