Entry fees collect into a real ledger account rather than a number in a row, so tournament money obeys the same double-entry invariants as everything else and every movement is explained by a posting. Settlement distributes the entire pool: dividing a pool across percentage shares leaves a remainder, and dropping it would destroy money and break conservation, so it goes to first place. Settlement claims the tournament before paying, so two instances cannot both pay out. Cancellation refunds every entrant and asserts the pool empties exactly. 18 tests including concurrent entry, concurrent settlement, unfunded entry taking no seat, and books balancing after payout. Removes an append-only trigger that had been over-applied to entry rows. An entry is a seat reservation, not a financial record: a seat claimed but unpaid must be releasable so the player can retry once funded. The money side stays immutable because it is a ledger posting. The journey test now derives the expected payout from the published fee schedule instead of hardcoding it, so it keeps checking something real if the rake changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
603 lines
17 KiB
Go
603 lines
17 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// These tests drive a running server. Start it with:
|
|
//
|
|
// ARCADE_DEV_FAUCET=1 go run ./cmd/arcade
|
|
//
|
|
// and run with ARCADE_E2E=http://localhost:8080. They are skipped otherwise so
|
|
// that `go test ./...` stays green without a live server.
|
|
func baseURL(t *testing.T) string {
|
|
t.Helper()
|
|
u := os.Getenv("ARCADE_E2E")
|
|
if u == "" {
|
|
t.Skip("set ARCADE_E2E to run end-to-end tests")
|
|
}
|
|
return u
|
|
}
|
|
|
|
type client struct {
|
|
t *testing.T
|
|
base string
|
|
token string
|
|
pub ed25519.PublicKey
|
|
priv ed25519.PrivateKey
|
|
}
|
|
|
|
func newClient(t *testing.T) *client {
|
|
t.Helper()
|
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &client{t: t, base: baseURL(t), pub: pub, priv: priv}
|
|
}
|
|
|
|
func (c *client) do(method, path string, body, out any) int {
|
|
c.t.Helper()
|
|
var buf io.Reader
|
|
if body != nil {
|
|
b, _ := json.Marshal(body)
|
|
buf = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequest(method, c.base+path, buf)
|
|
if err != nil {
|
|
c.t.Fatal(err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if c.token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
}
|
|
res, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
c.t.Fatal(err)
|
|
}
|
|
defer res.Body.Close()
|
|
if out != nil {
|
|
_ = json.NewDecoder(res.Body).Decode(out)
|
|
}
|
|
return res.StatusCode
|
|
}
|
|
|
|
func (c *client) signIn(nickname string) {
|
|
c.t.Helper()
|
|
pubHex := hex.EncodeToString(c.pub)
|
|
|
|
var chal struct{ Challenge string }
|
|
if code := c.do("POST", "/api/auth/challenge",
|
|
map[string]string{"pubkey": pubHex}, &chal); code != 200 {
|
|
c.t.Fatalf("challenge failed: %d", code)
|
|
}
|
|
nonce, _ := hex.DecodeString(chal.Challenge)
|
|
sig := ed25519.Sign(c.priv, nonce)
|
|
|
|
var res struct {
|
|
Token string `json:"token"`
|
|
}
|
|
if code := c.do("POST", "/api/auth/verify", map[string]string{
|
|
"pubkey": pubHex, "signature": hex.EncodeToString(sig), "nickname": nickname,
|
|
}, &res); code != 200 {
|
|
c.t.Fatalf("verify failed: %d", code)
|
|
}
|
|
c.token = res.Token
|
|
}
|
|
|
|
func (c *client) fund(msat int64) int64 {
|
|
c.t.Helper()
|
|
var res struct {
|
|
BalanceMsat int64 `json:"balance_msat"`
|
|
}
|
|
if code := c.do("POST", "/api/dev/faucet",
|
|
map[string]int64{"amount_msat": msat}, &res); code != 200 {
|
|
c.t.Fatalf("faucet failed: %d (is ARCADE_DEV_FAUCET=1 set?)", code)
|
|
}
|
|
return res.BalanceMsat
|
|
}
|
|
|
|
func TestSignInAndFund(t *testing.T) {
|
|
c := newClient(t)
|
|
c.signIn("tester")
|
|
if bal := c.fund(50_000_000); bal < 50_000_000 {
|
|
t.Fatalf("balance after faucet = %d", bal)
|
|
}
|
|
}
|
|
|
|
func TestUnauthenticatedRequestsRejected(t *testing.T) {
|
|
c := newClient(t)
|
|
var out map[string]any
|
|
if code := c.do("GET", "/api/balance", nil, &out); code != 401 {
|
|
t.Fatalf("unauthenticated balance returned %d, want 401", code)
|
|
}
|
|
if code := c.do("POST", "/api/bet",
|
|
map[string]any{"game": "rocket", "stake_msat": 1000}, &out); code != 401 {
|
|
t.Fatalf("unauthenticated bet returned %d, want 401", code)
|
|
}
|
|
}
|
|
|
|
func TestCannotBetMoreThanBalance(t *testing.T) {
|
|
c := newClient(t)
|
|
c.signIn("broke")
|
|
// No faucet call: balance is zero.
|
|
var out map[string]any
|
|
code := c.do("POST", "/api/bet",
|
|
map[string]any{"game": "rocket", "stake_msat": 1_000_000}, &out)
|
|
if code != 400 {
|
|
t.Fatalf("betting without funds returned %d, want 400", code)
|
|
}
|
|
}
|
|
|
|
// Play a full round: wait for a betting window, bet, and confirm the stake left
|
|
// the balance and the round eventually settles and reveals its seed.
|
|
func TestFullRoundLifecycleAndVerification(t *testing.T) {
|
|
c := newClient(t)
|
|
c.signIn("player")
|
|
c.fund(50_000_000)
|
|
|
|
const stake = 1_000_000
|
|
var roundID int64
|
|
|
|
deadline := time.Now().Add(90 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
var games struct {
|
|
Rooms []struct {
|
|
RoundID int64 `json:"round_id"`
|
|
Game string `json:"game"`
|
|
State string `json:"state"`
|
|
} `json:"rooms"`
|
|
}
|
|
c.do("GET", "/api/games", nil, &games)
|
|
|
|
for _, rm := range games.Rooms {
|
|
if rm.Game != "rocket" || rm.State != "betting_open" {
|
|
continue
|
|
}
|
|
var res struct {
|
|
BalanceMsat int64 `json:"balance_msat"`
|
|
Error string `json:"error"`
|
|
}
|
|
if code := c.do("POST", "/api/bet", map[string]any{
|
|
"game": "rocket", "stake_msat": stake, "nickname": "player",
|
|
}, &res); code == 200 {
|
|
roundID = rm.RoundID
|
|
}
|
|
}
|
|
if roundID != 0 {
|
|
break
|
|
}
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
if roundID == 0 {
|
|
t.Fatal("never managed to place a bet within 90s")
|
|
}
|
|
|
|
// Wait for the round to settle and expose its proof.
|
|
var proof struct {
|
|
Commitment string `json:"commitment"`
|
|
ServerSeed string `json:"server_seed"`
|
|
ClientSeed string `json:"client_seed"`
|
|
Nonce int64 `json:"nonce"`
|
|
Participants []string `json:"participants"`
|
|
}
|
|
settled := false
|
|
deadline = time.Now().Add(90 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if code := c.do("GET", "/api/verify/"+itoa(roundID), nil, &proof); code == 200 {
|
|
settled = true
|
|
break
|
|
}
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
if !settled {
|
|
t.Fatal("round never settled")
|
|
}
|
|
|
|
// The revealed seed must match the commitment published before betting.
|
|
seed, err := hex.DecodeString(proof.ServerSeed)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sum := sha256.Sum256(seed)
|
|
if hex.EncodeToString(sum[:]) != proof.Commitment {
|
|
t.Fatalf("commitment mismatch:\n published %s\n actual %s",
|
|
proof.Commitment, hex.EncodeToString(sum[:]))
|
|
}
|
|
if len(proof.Participants) == 0 {
|
|
t.Fatal("settled round lists no participants")
|
|
}
|
|
}
|
|
|
|
// The books must balance at all times, which the health endpoint reports.
|
|
func TestLedgerStaysBalanced(t *testing.T) {
|
|
c := newClient(t)
|
|
var health struct {
|
|
Status string `json:"status"`
|
|
LedgerSumMsat int64 `json:"ledger_sum_msat"`
|
|
}
|
|
if code := c.do("GET", "/api/health", nil, &health); code != 200 {
|
|
t.Fatalf("health returned %d", code)
|
|
}
|
|
if health.LedgerSumMsat != 0 {
|
|
t.Fatalf("ledger does not balance: sum = %d", health.LedgerSumMsat)
|
|
}
|
|
if health.Status != "ok" {
|
|
t.Fatalf("health status = %q", health.Status)
|
|
}
|
|
}
|
|
|
|
func TestScratchTicketPlaysAndPays(t *testing.T) {
|
|
c := newClient(t)
|
|
c.signIn("scratcher")
|
|
start := c.fund(100_000_000)
|
|
|
|
var res struct {
|
|
Outcome struct {
|
|
TierName string `json:"tier_name"`
|
|
PayoutMsat int64 `json:"payout_msat"`
|
|
Cells []int `json:"cells"`
|
|
} `json:"outcome"`
|
|
BalanceMsat int64 `json:"balance_msat"`
|
|
}
|
|
const stake = 1_000_000
|
|
if code := c.do("POST", "/api/scratch/play",
|
|
map[string]any{"ticket_id": "nebula-nine", "stake_msat": stake}, &res); code != 200 {
|
|
t.Fatalf("scratch play returned %d", code)
|
|
}
|
|
if len(res.Outcome.Cells) != 9 {
|
|
t.Fatalf("got %d cells, want 9", len(res.Outcome.Cells))
|
|
}
|
|
want := start - stake + res.Outcome.PayoutMsat
|
|
if res.BalanceMsat != want {
|
|
t.Fatalf("balance = %d, want %d (start %d, stake %d, payout %d)",
|
|
res.BalanceMsat, want, start, stake, res.Outcome.PayoutMsat)
|
|
}
|
|
}
|
|
|
|
func itoa(v int64) string {
|
|
if v == 0 {
|
|
return "0"
|
|
}
|
|
var buf [20]byte
|
|
i := len(buf)
|
|
for v > 0 {
|
|
i--
|
|
buf[i] = byte('0' + v%10)
|
|
v /= 10
|
|
}
|
|
return string(buf[i:])
|
|
}
|
|
|
|
// An auto cash-out target must be accepted by the API and reflected in the
|
|
// round, and an invalid one must be refused before any money moves.
|
|
func TestAutoCashOutThroughTheAPI(t *testing.T) {
|
|
c := newClient(t)
|
|
c.signIn("autoplayer")
|
|
start := c.fund(50_000_000)
|
|
|
|
var out map[string]any
|
|
// A target at or below 1.00 is meaningless and must be rejected.
|
|
code := c.do("POST", "/api/bet", map[string]any{
|
|
"game": "rocket", "stake_msat": 1_000_000, "auto_cashout": 1.0,
|
|
}, &out)
|
|
if code == 200 {
|
|
t.Fatal("a 1.00x auto cash-out target was accepted")
|
|
}
|
|
|
|
// An absurd target must be refused rather than overflowing the conversion.
|
|
code = c.do("POST", "/api/bet", map[string]any{
|
|
"game": "rocket", "stake_msat": 1_000_000, "auto_cashout": 1e12,
|
|
}, &out)
|
|
if code == 200 {
|
|
t.Fatal("an absurd auto cash-out target was accepted")
|
|
}
|
|
|
|
// Neither rejection may have moved money.
|
|
var bal struct {
|
|
BalanceMsat int64 `json:"balance_msat"`
|
|
}
|
|
c.do("GET", "/api/balance", nil, &bal)
|
|
if bal.BalanceMsat != start {
|
|
t.Fatalf("balance = %d after rejected bets, want %d", bal.BalanceMsat, start)
|
|
}
|
|
|
|
// A sensible target should be accepted during a betting window.
|
|
deadline := time.Now().Add(90 * time.Second)
|
|
placed := false
|
|
for time.Now().Before(deadline) && !placed {
|
|
var games struct {
|
|
Rooms []struct {
|
|
Game string `json:"game"`
|
|
State string `json:"state"`
|
|
} `json:"rooms"`
|
|
}
|
|
c.do("GET", "/api/games", nil, &games)
|
|
for _, rm := range games.Rooms {
|
|
if rm.Game == "rocket" && rm.State == "betting_open" {
|
|
var res map[string]any
|
|
if code := c.do("POST", "/api/bet", map[string]any{
|
|
"game": "rocket", "stake_msat": 1_000_000,
|
|
"auto_cashout": 2.5, "nickname": "autoplayer",
|
|
}, &res); code == 200 {
|
|
placed = true
|
|
}
|
|
}
|
|
}
|
|
if !placed {
|
|
time.Sleep(400 * time.Millisecond)
|
|
}
|
|
}
|
|
if !placed {
|
|
t.Fatal("could not place an auto cash-out bet within 90s")
|
|
}
|
|
}
|
|
|
|
// The complete journey a real player takes, in one test: arrive with no
|
|
// account, get funded, play both games, watch the ledger explain every change,
|
|
// move sats to a friend, and verify a round independently.
|
|
//
|
|
// Each step asserts against the ledger rather than against the API's own
|
|
// summary, so a bug that reports success while losing money fails here.
|
|
func TestFullPlayerJourney(t *testing.T) {
|
|
alice := newClient(t)
|
|
bob := newClient(t)
|
|
|
|
// 1. Arrive. No account exists; a keypair is the whole sign-up.
|
|
alice.signIn("alice")
|
|
bob.signIn("bob")
|
|
|
|
var bal struct {
|
|
BalanceMsat int64 `json:"balance_msat"`
|
|
}
|
|
alice.do("GET", "/api/balance", nil, &bal)
|
|
if bal.BalanceMsat != 0 {
|
|
t.Fatalf("a brand new player started with %d msat", bal.BalanceMsat)
|
|
}
|
|
|
|
// 2. Get funded.
|
|
const funded = 50_000_000
|
|
if got := alice.fund(funded); got != funded {
|
|
t.Fatalf("balance after funding = %d, want %d", got, funded)
|
|
}
|
|
|
|
// 3. Scratch a ticket. The balance must move by exactly stake and payout.
|
|
var sc struct {
|
|
Outcome struct {
|
|
TierName string `json:"tier_name"`
|
|
PayoutMsat int64 `json:"payout_msat"`
|
|
Cells []int `json:"cells"`
|
|
} `json:"outcome"`
|
|
Proof struct {
|
|
Commitment string `json:"commitment"`
|
|
ServerSeed string `json:"server_seed"`
|
|
} `json:"proof"`
|
|
BalanceMsat int64 `json:"balance_msat"`
|
|
}
|
|
const scratchStake = 1_000_000
|
|
if code := alice.do("POST", "/api/scratch/play",
|
|
map[string]any{"ticket_id": "nebula-nine", "stake_msat": scratchStake}, &sc); code != 200 {
|
|
t.Fatalf("scratch play returned %d", code)
|
|
}
|
|
wantAfterScratch := int64(funded) - scratchStake + sc.Outcome.PayoutMsat
|
|
if sc.BalanceMsat != wantAfterScratch {
|
|
t.Fatalf("balance after scratch = %d, want %d", sc.BalanceMsat, wantAfterScratch)
|
|
}
|
|
|
|
// The scratch proof must verify against its own seed.
|
|
seed, err := hex.DecodeString(sc.Proof.ServerSeed)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sum := sha256.Sum256(seed)
|
|
if hex.EncodeToString(sum[:]) != sc.Proof.Commitment {
|
|
t.Fatal("scratch proof does not verify against its own commitment")
|
|
}
|
|
|
|
// 4. Play a crash round with an auto cash-out target.
|
|
const stake = 2_000_000
|
|
var roundID int64
|
|
beforeRound := sc.BalanceMsat
|
|
|
|
deadline := time.Now().Add(90 * time.Second)
|
|
for time.Now().Before(deadline) && roundID == 0 {
|
|
var games struct {
|
|
Rooms []struct {
|
|
RoundID int64 `json:"round_id"`
|
|
Game string `json:"game"`
|
|
State string `json:"state"`
|
|
} `json:"rooms"`
|
|
}
|
|
alice.do("GET", "/api/games", nil, &games)
|
|
for _, rm := range games.Rooms {
|
|
if rm.Game != "rocket" || rm.State != "betting_open" {
|
|
continue
|
|
}
|
|
var res struct {
|
|
BalanceMsat int64 `json:"balance_msat"`
|
|
}
|
|
if code := alice.do("POST", "/api/bet", map[string]any{
|
|
"game": "rocket", "stake_msat": stake,
|
|
"auto_cashout": 1.5, "nickname": "alice",
|
|
}, &res); code == 200 {
|
|
roundID = rm.RoundID
|
|
// The stake must leave immediately, not at settlement.
|
|
if res.BalanceMsat != beforeRound-stake {
|
|
t.Fatalf("balance after bet = %d, want %d",
|
|
res.BalanceMsat, beforeRound-stake)
|
|
}
|
|
}
|
|
}
|
|
if roundID == 0 {
|
|
time.Sleep(400 * time.Millisecond)
|
|
}
|
|
}
|
|
if roundID == 0 {
|
|
t.Fatal("could not join a round within 90s")
|
|
}
|
|
|
|
// 5. Wait for settlement and check the outcome is consistent.
|
|
var proof struct {
|
|
Commitment string `json:"commitment"`
|
|
ServerSeed string `json:"server_seed"`
|
|
ClientSeed string `json:"client_seed"`
|
|
Nonce int64 `json:"nonce"`
|
|
CrashPoint *int64 `json:"crash_point"`
|
|
Participants []string `json:"participants"`
|
|
}
|
|
settled := false
|
|
deadline = time.Now().Add(120 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if code := alice.do("GET", "/api/verify/"+itoa(roundID), nil, &proof); code == 200 {
|
|
settled = true
|
|
break
|
|
}
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
if !settled {
|
|
t.Fatal("the round never settled")
|
|
}
|
|
|
|
// 6. Verify the round independently, the way the client does.
|
|
roundSeed, err := hex.DecodeString(proof.ServerSeed)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rs := sha256.Sum256(roundSeed)
|
|
if hex.EncodeToString(rs[:]) != proof.Commitment {
|
|
t.Fatal("settled round does not match its published commitment")
|
|
}
|
|
if proof.CrashPoint == nil {
|
|
t.Fatal("a settled round published no crash point")
|
|
}
|
|
crash := float64(*proof.CrashPoint) / 4294967296.0
|
|
|
|
// 7. Balance must reflect the outcome exactly: paid at 1.5x if the round
|
|
// reached the target, nothing otherwise — net of the disclosed fees.
|
|
var after struct {
|
|
BalanceMsat int64 `json:"balance_msat"`
|
|
}
|
|
|
|
// Derive the expected payout from the published fee schedule rather than
|
|
// hardcoding it. A test that hardcodes the net would silently stop
|
|
// checking anything the moment the operator changed the rake.
|
|
var sched struct {
|
|
Schedule struct {
|
|
RakePercent string `json:"rake_percent"`
|
|
RoundingUnit string `json:"rounding_unit"`
|
|
} `json:"schedule"`
|
|
}
|
|
alice.do("GET", "/api/fees", nil, &sched)
|
|
|
|
var rakePct float64
|
|
fmt.Sscanf(sched.Schedule.RakePercent, "%f%%", &rakePct)
|
|
var roundUnit int64
|
|
fmt.Sscanf(sched.Schedule.RoundingUnit, "%d msat", &roundUnit)
|
|
if roundUnit < 1 {
|
|
roundUnit = 1
|
|
}
|
|
|
|
gross := int64(stake) * 3 / 2
|
|
rake := int64(float64(gross) * rakePct / 100)
|
|
net := (gross - rake) / roundUnit * roundUnit
|
|
|
|
// Settlement posts a moment after the reveal; poll briefly.
|
|
wantWin := beforeRound - stake + net
|
|
wantLose := beforeRound - stake
|
|
ok := false
|
|
for i := 0; i < 20; i++ {
|
|
alice.do("GET", "/api/balance", nil, &after)
|
|
if after.BalanceMsat == wantWin || after.BalanceMsat == wantLose {
|
|
ok = true
|
|
break
|
|
}
|
|
time.Sleep(300 * time.Millisecond)
|
|
}
|
|
if !ok {
|
|
t.Fatalf("balance %d is neither the win (%d) nor the loss (%d) outcome",
|
|
after.BalanceMsat, wantWin, wantLose)
|
|
}
|
|
if crash >= 1.5 && after.BalanceMsat != wantWin {
|
|
t.Fatalf("round crashed at %.2fx, above the 1.50x target, but balance is %d not %d "+
|
|
"(gross %d, rake %d, net %d)",
|
|
crash, after.BalanceMsat, wantWin, gross, rake, net)
|
|
}
|
|
if crash < 1.5 && after.BalanceMsat != wantLose {
|
|
t.Fatalf("round crashed at %.2fx, below the 1.50x target, but balance is %d not %d",
|
|
crash, after.BalanceMsat, wantLose)
|
|
}
|
|
|
|
// 8. Every balance change must be explained by the ledger.
|
|
var hist struct {
|
|
Entries []struct {
|
|
Kind string `json:"Kind"`
|
|
AmountMsat int64 `json:"AmountMsat"`
|
|
BalanceBefore int64 `json:"BalanceBefore"`
|
|
BalanceAfter int64 `json:"BalanceAfter"`
|
|
} `json:"entries"`
|
|
}
|
|
alice.do("GET", "/api/history", nil, &hist)
|
|
if len(hist.Entries) < 3 {
|
|
t.Fatalf("history has %d entries; expected at least deposit, scratch, bet",
|
|
len(hist.Entries))
|
|
}
|
|
// History is newest-first; walking backwards, each entry's before must be
|
|
// the previous entry's after.
|
|
for i := 0; i < len(hist.Entries)-1; i++ {
|
|
newer, older := hist.Entries[i], hist.Entries[i+1]
|
|
if newer.BalanceBefore != older.BalanceAfter {
|
|
t.Fatalf("ledger history is not continuous: %s starts at %d but the "+
|
|
"preceding %s ended at %d",
|
|
newer.Kind, newer.BalanceBefore, older.Kind, older.BalanceAfter)
|
|
}
|
|
if newer.BalanceAfter != newer.BalanceBefore+newer.AmountMsat {
|
|
t.Fatalf("%s entry does not add up: %d + %d != %d",
|
|
newer.Kind, newer.BalanceBefore, newer.AmountMsat, newer.BalanceAfter)
|
|
}
|
|
}
|
|
|
|
// 9. Send sats to a friend; both sides must move by the same amount.
|
|
bobBefore := bob.fund(1)
|
|
aliceBefore := after.BalanceMsat
|
|
const gift = 500_000
|
|
var xfer struct {
|
|
BalanceMsat int64 `json:"balance_msat"`
|
|
}
|
|
if code := alice.do("POST", "/api/transfer", map[string]any{
|
|
"to_pubkey": hex.EncodeToString(bob.pub), "amount_msat": gift,
|
|
}, &xfer); code != 200 {
|
|
t.Fatalf("transfer returned %d", code)
|
|
}
|
|
if xfer.BalanceMsat != aliceBefore-gift {
|
|
t.Fatalf("sender balance = %d, want %d", xfer.BalanceMsat, aliceBefore-gift)
|
|
}
|
|
var bobAfter struct {
|
|
BalanceMsat int64 `json:"balance_msat"`
|
|
}
|
|
bob.do("GET", "/api/balance", nil, &bobAfter)
|
|
if bobAfter.BalanceMsat != bobBefore+gift {
|
|
t.Fatalf("recipient balance = %d, want %d", bobAfter.BalanceMsat, bobBefore+gift)
|
|
}
|
|
|
|
// 10. The books must still balance to zero after all of it.
|
|
var health struct {
|
|
Status string `json:"status"`
|
|
LedgerSumMsat int64 `json:"ledger_sum_msat"`
|
|
}
|
|
alice.do("GET", "/api/health", nil, &health)
|
|
if health.LedgerSumMsat != 0 {
|
|
t.Fatalf("after a full journey the books are off by %d msat", health.LedgerSumMsat)
|
|
}
|
|
}
|