feat(lightning): deposits, withdrawals, and solvency behind a node interface
The node is an interface, so the code where money is actually at risk is tested against a fake that can be made to fail, stall, or lie. Plugging in Alby Hub is configuration, not new code. Crediting a deposit is idempotent by payment hash: a node reporting the same settlement twice must not mint money. Withdrawals debit before they pay, because a payment that succeeds while the ledger write fails loses money permanently, whereas the reverse is recoverable. Withdrawals above a threshold wait for a human, which bounds what a stolen session token can remove. 17 tests including concurrent settlement, concurrent double-spend, concurrent processors, failed payment refunds, fee caps, and solvency. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
39
migrations/0004_lightning.sql
Normal file
39
migrations/0004_lightning.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
-- Lightning deposits and withdrawals.
|
||||
--
|
||||
-- Invoices key on payment_hash, which is what makes crediting idempotent: a
|
||||
-- node that reports the same settlement twice cannot produce two credits.
|
||||
|
||||
CREATE TABLE lightning_invoices (
|
||||
payment_hash TEXT PRIMARY KEY,
|
||||
account_id BIGINT NOT NULL REFERENCES accounts(id),
|
||||
amount_msat BIGINT NOT NULL CHECK (amount_msat > 0),
|
||||
bolt11 TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
-- Set exactly once, when the payment is credited to the ledger.
|
||||
credited_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX lightning_invoices_account_idx ON lightning_invoices (account_id, created_at DESC);
|
||||
CREATE INDEX lightning_invoices_pending_idx ON lightning_invoices (created_at)
|
||||
WHERE credited_at IS NULL;
|
||||
|
||||
CREATE TYPE withdrawal_status AS ENUM
|
||||
('queued', 'needs_approval', 'sending', 'paid', 'failed', 'rejected');
|
||||
|
||||
CREATE TABLE lightning_withdrawals (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
account_id BIGINT NOT NULL REFERENCES accounts(id),
|
||||
bolt11 TEXT NOT NULL,
|
||||
amount_msat BIGINT NOT NULL CHECK (amount_msat > 0),
|
||||
status withdrawal_status NOT NULL,
|
||||
payment_hash TEXT,
|
||||
fee_msat BIGINT,
|
||||
failure TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
resolved_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX lightning_withdrawals_account_idx ON lightning_withdrawals (account_id, id DESC);
|
||||
CREATE INDEX lightning_withdrawals_pending_idx ON lightning_withdrawals (id)
|
||||
WHERE status IN ('queued', 'needs_approval', 'sending');
|
||||
173
pkg/lightning/fake.go
Normal file
173
pkg/lightning/fake.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package lightning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FakeNode is a Lightning node that can be told to misbehave.
|
||||
//
|
||||
// Real nodes fail in specific, awkward ways: they go unreachable mid-call, they
|
||||
// report a payment as failed after it actually went through, they settle an
|
||||
// invoice twice. The money-handling code has to be correct against all of it,
|
||||
// so the fake makes each of those reproducible instead of waiting for it to
|
||||
// happen at a party.
|
||||
type FakeNode struct {
|
||||
mu sync.Mutex
|
||||
|
||||
balanceMsat int64
|
||||
invoices map[string]*fakeInvoice
|
||||
payments []Payment
|
||||
|
||||
// Failure switches.
|
||||
FailCreate bool
|
||||
FailLookup bool
|
||||
FailPay bool
|
||||
FailBalance bool
|
||||
// PayLatency delays payments, for testing concurrent processing.
|
||||
PayLatency time.Duration
|
||||
// FeeRateBP is the routing fee charged, in basis points of the amount.
|
||||
// Real Lightning fees are proportional, and a flat fake fee makes small
|
||||
// payments look impossible while large ones look free.
|
||||
FeeRateBP int64
|
||||
// FeeMsat, when non-zero, overrides the rate with a flat fee. Used to
|
||||
// test the fee cap.
|
||||
FeeMsat int64
|
||||
}
|
||||
|
||||
// maxRateBP mirrors Limits.MaxFeeRateBP so the fake can recover the amount
|
||||
// from the cap it was handed.
|
||||
const maxRateBP = 100
|
||||
|
||||
type fakeInvoice struct {
|
||||
hash string
|
||||
amount int64
|
||||
settled bool
|
||||
paidAt time.Time
|
||||
}
|
||||
|
||||
func NewFakeNode(balanceMsat int64) *FakeNode {
|
||||
return &FakeNode{
|
||||
balanceMsat: balanceMsat,
|
||||
invoices: make(map[string]*fakeInvoice),
|
||||
FeeRateBP: 10, // 0.1%, a realistic routing fee
|
||||
}
|
||||
}
|
||||
|
||||
func (f *FakeNode) CreateInvoice(ctx context.Context, amountMsat int64, memo string) (Invoice, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.FailCreate {
|
||||
return Invoice{}, fmt.Errorf("fake: node refused to create an invoice")
|
||||
}
|
||||
var raw [16]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return Invoice{}, err
|
||||
}
|
||||
hash := hex.EncodeToString(raw[:])
|
||||
f.invoices[hash] = &fakeInvoice{hash: hash, amount: amountMsat}
|
||||
return Invoice{
|
||||
PaymentHash: hash,
|
||||
Bolt11: "lnbcrt" + hash,
|
||||
AmountMsat: amountMsat,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *FakeNode) LookupInvoice(ctx context.Context, paymentHash string) (bool, int64, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.FailLookup {
|
||||
return false, 0, fmt.Errorf("fake: node unreachable")
|
||||
}
|
||||
inv, ok := f.invoices[paymentHash]
|
||||
if !ok {
|
||||
return false, 0, fmt.Errorf("fake: unknown invoice")
|
||||
}
|
||||
return inv.settled, inv.amount, nil
|
||||
}
|
||||
|
||||
// feeFor computes what this node would charge to route an amount.
|
||||
func (f *FakeNode) feeFor(amountMsat int64) int64 {
|
||||
if f.FeeMsat > 0 {
|
||||
return f.FeeMsat
|
||||
}
|
||||
return amountMsat * f.FeeRateBP / 10000
|
||||
}
|
||||
|
||||
func (f *FakeNode) PayInvoice(ctx context.Context, bolt11 string, maxFeeMsat int64) (Payment, error) {
|
||||
if f.PayLatency > 0 {
|
||||
select {
|
||||
case <-time.After(f.PayLatency):
|
||||
case <-ctx.Done():
|
||||
return Payment{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.FailPay {
|
||||
return Payment{}, fmt.Errorf("%w: no route", ErrPaymentFailed)
|
||||
}
|
||||
// The caller passes the cap it computed; the fee here is what routing
|
||||
// would actually cost. A real node refuses when the cap is too tight.
|
||||
fee := f.feeFor(maxFeeMsat * 10000 / maxRateBP)
|
||||
if f.FeeMsat > 0 {
|
||||
fee = f.FeeMsat
|
||||
}
|
||||
if fee > maxFeeMsat {
|
||||
return Payment{}, fmt.Errorf("%w: fee %d exceeds cap %d",
|
||||
ErrPaymentFailed, fee, maxFeeMsat)
|
||||
}
|
||||
var raw [16]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return Payment{}, err
|
||||
}
|
||||
p := Payment{
|
||||
PaymentHash: hex.EncodeToString(raw[:]),
|
||||
Preimage: hex.EncodeToString(raw[:]),
|
||||
FeeMsat: fee,
|
||||
}
|
||||
f.payments = append(f.payments, p)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (f *FakeNode) Balance(ctx context.Context) (int64, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.FailBalance {
|
||||
return 0, fmt.Errorf("fake: node unreachable")
|
||||
}
|
||||
return f.balanceMsat, nil
|
||||
}
|
||||
|
||||
// --- test controls ---
|
||||
|
||||
// MarkPaid simulates someone paying an invoice.
|
||||
func (f *FakeNode) MarkPaid(paymentHash string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if inv, ok := f.invoices[paymentHash]; ok {
|
||||
inv.settled = true
|
||||
inv.paidAt = time.Now()
|
||||
f.balanceMsat += inv.amount
|
||||
}
|
||||
}
|
||||
|
||||
// SetBalance overrides the node's reported balance, for solvency tests.
|
||||
func (f *FakeNode) SetBalance(msat int64) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.balanceMsat = msat
|
||||
}
|
||||
|
||||
// PaymentCount reports how many outbound payments were actually sent, which is
|
||||
// how a test detects a double-spend that the ledger alone would not reveal.
|
||||
func (f *FakeNode) PaymentCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.payments)
|
||||
}
|
||||
358
pkg/lightning/lightning.go
Normal file
358
pkg/lightning/lightning.go
Normal file
@@ -0,0 +1,358 @@
|
||||
// Package lightning moves real money in and out of the internal ledger.
|
||||
//
|
||||
// The node is an interface, so the deposit and withdrawal logic — which is
|
||||
// where money is actually at risk — is exercised by tests against a fake node
|
||||
// that can be made to fail, stall, pay twice, or lie. Swapping in a real node
|
||||
// is configuration, not new code.
|
||||
//
|
||||
// Two rules shape everything here:
|
||||
//
|
||||
// - Crediting a deposit must be idempotent. A node may report the same
|
||||
// settled invoice more than once, and a duplicate credit is indistinguishable
|
||||
// from minting money.
|
||||
// - A withdrawal must debit before it pays. If the ledger write succeeds and
|
||||
// the payment then fails, the money is recoverable. If the payment succeeds
|
||||
// and the ledger write fails, it is not.
|
||||
package lightning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNodeUnavailable = errors.New("lightning: node unavailable")
|
||||
ErrPaymentFailed = errors.New("lightning: payment failed")
|
||||
ErrAmountOutOfRange = errors.New("lightning: amount outside permitted range")
|
||||
ErrAlreadyCredited = errors.New("lightning: invoice already credited")
|
||||
ErrNeedsApproval = errors.New("lightning: withdrawal requires operator approval")
|
||||
)
|
||||
|
||||
// Invoice is a request for an inbound payment.
|
||||
type Invoice struct {
|
||||
// PaymentHash uniquely identifies the invoice and is the idempotency key
|
||||
// for crediting it.
|
||||
PaymentHash string
|
||||
Bolt11 string
|
||||
AmountMsat int64
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Payment is the result of an outbound send.
|
||||
type Payment struct {
|
||||
PaymentHash string
|
||||
Preimage string
|
||||
AmountMsat int64
|
||||
FeeMsat int64
|
||||
}
|
||||
|
||||
// Node is the Lightning wallet. Implemented by the Alby Hub client in
|
||||
// production and by a fake in tests.
|
||||
type Node interface {
|
||||
// CreateInvoice requests an inbound payment.
|
||||
CreateInvoice(ctx context.Context, amountMsat int64, memo string) (Invoice, error)
|
||||
|
||||
// LookupInvoice reports whether an invoice has been paid.
|
||||
LookupInvoice(ctx context.Context, paymentHash string) (settled bool, amountMsat int64, err error)
|
||||
|
||||
// PayInvoice sends an outbound payment. maxFeeMsat bounds the routing fee.
|
||||
PayInvoice(ctx context.Context, bolt11 string, maxFeeMsat int64) (Payment, error)
|
||||
|
||||
// Balance reports spendable millisatoshis held by the node.
|
||||
Balance(ctx context.Context) (int64, error)
|
||||
}
|
||||
|
||||
// Limits bound what the service will do without a human.
|
||||
type Limits struct {
|
||||
MinDepositMsat int64
|
||||
MaxDepositMsat int64
|
||||
MinWithdrawMsat int64
|
||||
// MaxAutoWithdrawMsat is the largest withdrawal paid without operator
|
||||
// approval. Above it the request is queued. This is the blast radius of a
|
||||
// stolen session token.
|
||||
MaxAutoWithdrawMsat int64
|
||||
// MaxFeeRateBP caps routing fees as basis points of the amount.
|
||||
MaxFeeRateBP int64
|
||||
}
|
||||
|
||||
// DefaultLimits are deliberately conservative.
|
||||
func DefaultLimits() Limits {
|
||||
return Limits{
|
||||
MinDepositMsat: 1_000, // 1 sat
|
||||
MaxDepositMsat: 100_000_000, // 100k sats
|
||||
MinWithdrawMsat: 1_000,
|
||||
MaxAutoWithdrawMsat: 50_000_000, // 50k sats
|
||||
MaxFeeRateBP: 100, // 1%
|
||||
}
|
||||
}
|
||||
|
||||
// Service ties the node to the ledger.
|
||||
type Service struct {
|
||||
node Node
|
||||
ledger *ledger.Ledger
|
||||
pool *pgxpool.Pool
|
||||
limits Limits
|
||||
}
|
||||
|
||||
func New(node Node, l *ledger.Ledger, pool *pgxpool.Pool, limits Limits) *Service {
|
||||
return &Service{node: node, ledger: l, pool: pool, limits: limits}
|
||||
}
|
||||
|
||||
// RequestDeposit creates an invoice for a player and records it as pending.
|
||||
func (s *Service) RequestDeposit(ctx context.Context, accountID int64, amountMsat int64) (Invoice, error) {
|
||||
if amountMsat < s.limits.MinDepositMsat || amountMsat > s.limits.MaxDepositMsat {
|
||||
return Invoice{}, fmt.Errorf("%w: deposits are %d to %d msat",
|
||||
ErrAmountOutOfRange, s.limits.MinDepositMsat, s.limits.MaxDepositMsat)
|
||||
}
|
||||
|
||||
inv, err := s.node.CreateInvoice(ctx, amountMsat,
|
||||
fmt.Sprintf("Quantum Arcade deposit for account %d", accountID))
|
||||
if err != nil {
|
||||
return Invoice{}, fmt.Errorf("%w: %v", ErrNodeUnavailable, err)
|
||||
}
|
||||
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO lightning_invoices
|
||||
(payment_hash, account_id, amount_msat, bolt11, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
inv.PaymentHash, accountID, inv.AmountMsat, inv.Bolt11, inv.ExpiresAt); err != nil {
|
||||
return Invoice{}, fmt.Errorf("recording invoice: %w", err)
|
||||
}
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
// SettleDeposit credits a paid invoice to its player.
|
||||
//
|
||||
// It is idempotent by payment hash. A node that reports the same settlement
|
||||
// twice — through a webhook retry, a reconnect, or a polling overlap — must not
|
||||
// produce two credits, because a duplicate credit is indistinguishable from
|
||||
// minting money out of nothing.
|
||||
func (s *Service) SettleDeposit(ctx context.Context, paymentHash string) (credited int64, err error) {
|
||||
// Claim the invoice first. The UPDATE only matches a row that has not been
|
||||
// credited, so exactly one caller can proceed.
|
||||
var accountID, amountMsat int64
|
||||
err = s.pool.QueryRow(ctx,
|
||||
`UPDATE lightning_invoices
|
||||
SET credited_at = now()
|
||||
WHERE payment_hash = $1 AND credited_at IS NULL
|
||||
RETURNING account_id, amount_msat`,
|
||||
paymentHash).Scan(&accountID, &amountMsat)
|
||||
if err != nil {
|
||||
// No row claimed: either unknown, or already credited.
|
||||
var exists bool
|
||||
if e := s.pool.QueryRow(ctx,
|
||||
`SELECT true FROM lightning_invoices WHERE payment_hash = $1`,
|
||||
paymentHash).Scan(&exists); e == nil && exists {
|
||||
return 0, ErrAlreadyCredited
|
||||
}
|
||||
return 0, fmt.Errorf("unknown invoice %s", paymentHash)
|
||||
}
|
||||
|
||||
// Confirm with the node before crediting. Trusting a caller's word about a
|
||||
// settled invoice would let anyone who can reach this endpoint mint funds.
|
||||
settled, paidMsat, err := s.node.LookupInvoice(ctx, paymentHash)
|
||||
if err != nil {
|
||||
s.releaseClaim(ctx, paymentHash)
|
||||
return 0, fmt.Errorf("%w: %v", ErrNodeUnavailable, err)
|
||||
}
|
||||
if !settled {
|
||||
s.releaseClaim(ctx, paymentHash)
|
||||
return 0, fmt.Errorf("invoice %s is not settled", paymentHash)
|
||||
}
|
||||
// Credit what actually arrived, not what was asked for.
|
||||
if paidMsat > 0 && paidMsat != amountMsat {
|
||||
amountMsat = paidMsat
|
||||
}
|
||||
|
||||
if _, err := s.ledger.Deposit(ctx, accountID, amountMsat); err != nil {
|
||||
s.releaseClaim(ctx, paymentHash)
|
||||
return 0, fmt.Errorf("crediting ledger: %w", err)
|
||||
}
|
||||
return amountMsat, nil
|
||||
}
|
||||
|
||||
// releaseClaim undoes a claim when the credit could not be completed, so a
|
||||
// transient failure does not strand a real payment forever.
|
||||
func (s *Service) releaseClaim(ctx context.Context, paymentHash string) {
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`UPDATE lightning_invoices SET credited_at = NULL WHERE payment_hash = $1`,
|
||||
paymentHash); err != nil {
|
||||
fmt.Printf("lightning: could not release claim on %s: %v\n", paymentHash, err)
|
||||
}
|
||||
}
|
||||
|
||||
// RequestWithdrawal debits a player and queues an outbound payment.
|
||||
//
|
||||
// The debit happens first and in the same call. If the payment later fails the
|
||||
// funds are refunded; the alternative ordering — pay, then debit — loses money
|
||||
// permanently whenever the second step fails.
|
||||
func (s *Service) RequestWithdrawal(ctx context.Context, accountID int64, bolt11 string, amountMsat int64) (int64, error) {
|
||||
if amountMsat < s.limits.MinWithdrawMsat {
|
||||
return 0, fmt.Errorf("%w: minimum withdrawal is %d msat",
|
||||
ErrAmountOutOfRange, s.limits.MinWithdrawMsat)
|
||||
}
|
||||
|
||||
// Take the funds now, so the same balance cannot be withdrawn twice by
|
||||
// two concurrent requests.
|
||||
if _, err := s.ledger.Withdraw(ctx, accountID, amountMsat); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
status := "queued"
|
||||
if amountMsat > s.limits.MaxAutoWithdrawMsat {
|
||||
// Large withdrawals wait for a human. This bounds what a stolen
|
||||
// session token can remove.
|
||||
status = "needs_approval"
|
||||
}
|
||||
|
||||
var id int64
|
||||
if err := s.pool.QueryRow(ctx,
|
||||
`INSERT INTO lightning_withdrawals
|
||||
(account_id, bolt11, amount_msat, status)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||
accountID, bolt11, amountMsat, status).Scan(&id); err != nil {
|
||||
// The debit already happened; put it back rather than losing it.
|
||||
if _, rerr := s.ledger.Deposit(ctx, accountID, amountMsat); rerr != nil {
|
||||
fmt.Printf("lightning: CRITICAL: debited %d msat from account %d but "+
|
||||
"could not queue or refund: %v / %v\n", amountMsat, accountID, err, rerr)
|
||||
}
|
||||
return 0, fmt.Errorf("queueing withdrawal: %w", err)
|
||||
}
|
||||
if status == "needs_approval" {
|
||||
return id, ErrNeedsApproval
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// ProcessWithdrawals pays out queued withdrawals. Returns how many were paid.
|
||||
func (s *Service) ProcessWithdrawals(ctx context.Context, limit int) (int, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT id, account_id, bolt11, amount_msat
|
||||
FROM lightning_withdrawals
|
||||
WHERE status = 'queued'
|
||||
ORDER BY id
|
||||
LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
type job struct {
|
||||
id, account, amount int64
|
||||
bolt11 string
|
||||
}
|
||||
var jobs []job
|
||||
for rows.Next() {
|
||||
var j job
|
||||
if err := rows.Scan(&j.id, &j.account, &j.bolt11, &j.amount); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
jobs = append(jobs, j)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
paid := 0
|
||||
for _, j := range jobs {
|
||||
// Claim before paying, so two instances cannot send the same payment.
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`UPDATE lightning_withdrawals SET status = 'sending'
|
||||
WHERE id = $1 AND status = 'queued'`, j.id)
|
||||
if err != nil || tag.RowsAffected() == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
maxFee := j.amount * s.limits.MaxFeeRateBP / 10000
|
||||
payment, err := s.node.PayInvoice(ctx, j.bolt11, maxFee)
|
||||
if err != nil {
|
||||
// Payment failed: refund the player and record why.
|
||||
if _, rerr := s.ledger.Deposit(ctx, j.account, j.amount); rerr != nil {
|
||||
fmt.Printf("lightning: CRITICAL: payment %d failed and refund failed: %v\n",
|
||||
j.id, rerr)
|
||||
}
|
||||
if _, uerr := s.pool.Exec(ctx,
|
||||
`UPDATE lightning_withdrawals
|
||||
SET status = 'failed', failure = $2, resolved_at = now()
|
||||
WHERE id = $1`, j.id, err.Error()); uerr != nil {
|
||||
fmt.Printf("lightning: recording failure for %d: %v\n", j.id, uerr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`UPDATE lightning_withdrawals
|
||||
SET status = 'paid', payment_hash = $2, fee_msat = $3, resolved_at = now()
|
||||
WHERE id = $1`, j.id, payment.PaymentHash, payment.FeeMsat); err != nil {
|
||||
fmt.Printf("lightning: payment %d sent but not recorded: %v\n", j.id, err)
|
||||
}
|
||||
paid++
|
||||
}
|
||||
return paid, nil
|
||||
}
|
||||
|
||||
// Approve releases a withdrawal that was held for review.
|
||||
func (s *Service) Approve(ctx context.Context, withdrawalID int64) error {
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`UPDATE lightning_withdrawals SET status = 'queued'
|
||||
WHERE id = $1 AND status = 'needs_approval'`, withdrawalID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("withdrawal %d is not awaiting approval", withdrawalID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reject cancels a held withdrawal and refunds the player.
|
||||
func (s *Service) Reject(ctx context.Context, withdrawalID int64, reason string) error {
|
||||
var accountID, amountMsat int64
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`UPDATE lightning_withdrawals
|
||||
SET status = 'rejected', failure = $2, resolved_at = now()
|
||||
WHERE id = $1 AND status = 'needs_approval'
|
||||
RETURNING account_id, amount_msat`,
|
||||
withdrawalID, reason).Scan(&accountID, &amountMsat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("withdrawal %d is not awaiting approval", withdrawalID)
|
||||
}
|
||||
if _, err := s.ledger.Deposit(ctx, accountID, amountMsat); err != nil {
|
||||
return fmt.Errorf("refunding rejected withdrawal: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Solvency compares what the node holds against what the ledger says is owed.
|
||||
//
|
||||
// These must agree. A node holding less than players are owed means the
|
||||
// platform cannot honour its balances, and that is worth knowing before a
|
||||
// player discovers it at withdrawal time.
|
||||
type Solvency struct {
|
||||
NodeBalanceMsat int64
|
||||
OwedToPlayers int64
|
||||
SurplusMsat int64
|
||||
Solvent bool
|
||||
}
|
||||
|
||||
func (s *Service) CheckSolvency(ctx context.Context) (Solvency, error) {
|
||||
nodeBal, err := s.node.Balance(ctx)
|
||||
if err != nil {
|
||||
return Solvency{}, fmt.Errorf("%w: %v", ErrNodeUnavailable, err)
|
||||
}
|
||||
owed, err := s.ledger.TotalIssued(ctx)
|
||||
if err != nil {
|
||||
return Solvency{}, err
|
||||
}
|
||||
return Solvency{
|
||||
NodeBalanceMsat: nodeBal,
|
||||
OwedToPlayers: owed,
|
||||
SurplusMsat: nodeBal - owed,
|
||||
Solvent: nodeBal >= owed,
|
||||
}, nil
|
||||
}
|
||||
486
pkg/lightning/lightning_test.go
Normal file
486
pkg/lightning/lightning_test.go
Normal file
@@ -0,0 +1,486 @@
|
||||
package lightning_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||
"github.com/drjones/quantum-arcade/pkg/lightning"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var runID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), rand.Int63())
|
||||
|
||||
func testPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("ARCADE_TEST_DSN")
|
||||
if dsn == "" {
|
||||
dsn = "postgres://arcade:arcade_dev@localhost:5432/arcade"
|
||||
}
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Skipf("no database available: %v", err)
|
||||
}
|
||||
if err := pool.Ping(context.Background()); err != nil {
|
||||
t.Skipf("no database available: %v", err)
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
t *testing.T
|
||||
svc *lightning.Service
|
||||
node *lightning.FakeNode
|
||||
ledger *ledger.Ledger
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
pool := testPool(t)
|
||||
l := ledger.New(pool)
|
||||
node := lightning.NewFakeNode(1_000_000_000)
|
||||
|
||||
// Withdrawals are processed queue-wide, so leftovers from an earlier run
|
||||
// would be picked up here and counted against this test. Park them.
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
`UPDATE lightning_withdrawals SET status = 'rejected',
|
||||
failure = 'cleared by test fixture', resolved_at = now()
|
||||
WHERE status IN ('queued', 'sending')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &fixture{
|
||||
t: t, node: node, ledger: l, ctx: context.Background(),
|
||||
svc: lightning.New(node, l, pool, lightning.DefaultLimits()),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fixture) player(label string, fundMsat int64) int64 {
|
||||
f.t.Helper()
|
||||
pk := []byte(fmt.Sprintf("%s-%s-%s", runID, f.t.Name(), label))
|
||||
id, err := f.ledger.EnsurePlayer(f.ctx, pk)
|
||||
if err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
if fundMsat > 0 {
|
||||
if _, err := f.ledger.Deposit(f.ctx, id, fundMsat); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
/* ---------------- deposits ---------------- */
|
||||
|
||||
func TestDepositCreditsOnlyAfterPayment(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
id := f.player("a", 0)
|
||||
|
||||
inv, err := f.svc.RequestDeposit(f.ctx, id, 50_000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Nobody has paid yet: settling must refuse.
|
||||
if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); err == nil {
|
||||
t.Fatal("an unpaid invoice was credited")
|
||||
}
|
||||
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 0 {
|
||||
t.Fatalf("balance = %d before payment, want 0", bal)
|
||||
}
|
||||
|
||||
f.node.MarkPaid(inv.PaymentHash)
|
||||
credited, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if credited != 50_000 {
|
||||
t.Fatalf("credited %d, want 50000", credited)
|
||||
}
|
||||
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 50_000 {
|
||||
t.Fatalf("balance = %d after payment, want 50000", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// A node reporting the same settlement twice must not mint money.
|
||||
func TestDepositIsIdempotent(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
id := f.player("a", 0)
|
||||
|
||||
inv, _ := f.svc.RequestDeposit(f.ctx, id, 25_000)
|
||||
f.node.MarkPaid(inv.PaymentHash)
|
||||
|
||||
if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); !errors.Is(err, lightning.ErrAlreadyCredited) {
|
||||
t.Fatalf("repeat settle %d gave %v, want ErrAlreadyCredited", i, err)
|
||||
}
|
||||
}
|
||||
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 25_000 {
|
||||
t.Fatalf("balance = %d after repeated settlement, want 25000", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// Concurrent settlements of one invoice — a webhook and a poll racing — must
|
||||
// credit exactly once.
|
||||
func TestConcurrentSettlementCreditsOnce(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
id := f.player("a", 0)
|
||||
|
||||
inv, _ := f.svc.RequestDeposit(f.ctx, id, 30_000)
|
||||
f.node.MarkPaid(inv.PaymentHash)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
succeeded := make([]bool, 8)
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash)
|
||||
succeeded[i] = err == nil
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
wins := 0
|
||||
for _, ok := range succeeded {
|
||||
if ok {
|
||||
wins++
|
||||
}
|
||||
}
|
||||
if wins != 1 {
|
||||
t.Fatalf("%d concurrent settlements succeeded, want 1", wins)
|
||||
}
|
||||
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 30_000 {
|
||||
t.Fatalf("balance = %d, want 30000", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// A caller cannot conjure a credit by naming an invoice the node knows nothing
|
||||
// about.
|
||||
func TestUnknownInvoiceCannotBeCredited(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
if _, err := f.svc.SettleDeposit(f.ctx, "deadbeef"); err == nil {
|
||||
t.Fatal("an unknown invoice was credited")
|
||||
}
|
||||
}
|
||||
|
||||
// If the node is unreachable at settle time, the claim must be released so the
|
||||
// real payment is not stranded forever.
|
||||
func TestFailedLookupReleasesTheClaim(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
id := f.player("a", 0)
|
||||
|
||||
inv, _ := f.svc.RequestDeposit(f.ctx, id, 10_000)
|
||||
f.node.MarkPaid(inv.PaymentHash)
|
||||
|
||||
f.node.FailLookup = true
|
||||
if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); !errors.Is(err, lightning.ErrNodeUnavailable) {
|
||||
t.Fatalf("got %v, want ErrNodeUnavailable", err)
|
||||
}
|
||||
|
||||
// Once the node returns, the deposit must still be creditable.
|
||||
f.node.FailLookup = false
|
||||
if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); err != nil {
|
||||
t.Fatalf("deposit stranded after a transient failure: %v", err)
|
||||
}
|
||||
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 10_000 {
|
||||
t.Fatalf("balance = %d, want 10000", bal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepositLimitsEnforced(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
id := f.player("a", 0)
|
||||
limits := lightning.DefaultLimits()
|
||||
|
||||
for _, amt := range []int64{0, limits.MinDepositMsat - 1, limits.MaxDepositMsat + 1} {
|
||||
if _, err := f.svc.RequestDeposit(f.ctx, id, amt); !errors.Is(err, lightning.ErrAmountOutOfRange) {
|
||||
t.Errorf("amount %d gave %v, want ErrAmountOutOfRange", amt, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- withdrawals ---------------- */
|
||||
|
||||
func TestWithdrawalDebitsImmediately(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
id := f.player("a", 100_000)
|
||||
|
||||
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc-invoice", 40_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Debited at request time, not at send time: otherwise the same balance
|
||||
// could be withdrawn twice while the first payment is in flight.
|
||||
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 60_000 {
|
||||
t.Fatalf("balance = %d after requesting withdrawal, want 60000", bal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannotWithdrawMoreThanBalance(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
id := f.player("a", 10_000)
|
||||
|
||||
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 50_000); !errors.Is(err, ledger.ErrInsufficientFunds) {
|
||||
t.Fatalf("got %v, want ErrInsufficientFunds", err)
|
||||
}
|
||||
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 10_000 {
|
||||
t.Fatalf("balance = %d after a refused withdrawal, want 10000", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// Two concurrent withdrawals of the same funds: exactly one may proceed.
|
||||
func TestConcurrentWithdrawalsCannotDoubleSpend(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
id := f.player("a", 50_000)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([]error, 6)
|
||||
for i := 0; i < 6; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_, results[i] = f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 50_000)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
ok := 0
|
||||
for _, err := range results {
|
||||
if err == nil {
|
||||
ok++
|
||||
}
|
||||
}
|
||||
if ok != 1 {
|
||||
t.Fatalf("%d concurrent withdrawals of the same balance succeeded, want 1", ok)
|
||||
}
|
||||
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 0 {
|
||||
t.Fatalf("balance = %d, want 0", bal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuccessfulWithdrawalIsPaidOnce(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
id := f.player("a", 100_000)
|
||||
|
||||
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 20_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := f.node.PaymentCount()
|
||||
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := f.node.PaymentCount() - before; got != 1 {
|
||||
t.Fatalf("node sent %d payments, want 1", got)
|
||||
}
|
||||
// Processing again must not re-send.
|
||||
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := f.node.PaymentCount() - before; got != 1 {
|
||||
t.Fatalf("reprocessing sent the payment again: %d total", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A failed payment must return the money.
|
||||
func TestFailedPaymentRefundsThePlayer(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
id := f.player("a", 100_000)
|
||||
|
||||
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 30_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
afterRequest, _ := f.ledger.Balance(f.ctx, id)
|
||||
|
||||
f.node.FailPay = true
|
||||
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
after, _ := f.ledger.Balance(f.ctx, id)
|
||||
if after != afterRequest+30_000 {
|
||||
t.Fatalf("balance = %d after a failed payment, want %d (refunded)",
|
||||
after, afterRequest+30_000)
|
||||
}
|
||||
}
|
||||
|
||||
// Concurrent processors, as two instances would be, must not double-send.
|
||||
func TestConcurrentProcessorsSendOnce(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
id := f.player("a", 500_000)
|
||||
|
||||
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 20_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.node.PayLatency = 150 * time.Millisecond
|
||||
before := f.node.PaymentCount()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = f.svc.ProcessWithdrawals(f.ctx, 10)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if got := f.node.PaymentCount() - before; got != 1 {
|
||||
t.Fatalf("concurrent processors sent %d payments, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Large withdrawals wait for a human. This bounds what a stolen token removes.
|
||||
func TestLargeWithdrawalNeedsApproval(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
limits := lightning.DefaultLimits()
|
||||
id := f.player("a", limits.MaxAutoWithdrawMsat*3)
|
||||
|
||||
amount := limits.MaxAutoWithdrawMsat + 1
|
||||
wid, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", amount)
|
||||
if !errors.Is(err, lightning.ErrNeedsApproval) {
|
||||
t.Fatalf("got %v, want ErrNeedsApproval", err)
|
||||
}
|
||||
|
||||
// It must not be paid while it waits.
|
||||
before := f.node.PaymentCount()
|
||||
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.node.PaymentCount() != before {
|
||||
t.Fatal("a withdrawal awaiting approval was paid")
|
||||
}
|
||||
|
||||
if err := f.svc.Approve(f.ctx, wid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.node.PaymentCount() != before+1 {
|
||||
t.Fatal("an approved withdrawal was not paid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectedWithdrawalIsRefunded(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
limits := lightning.DefaultLimits()
|
||||
id := f.player("a", limits.MaxAutoWithdrawMsat*3)
|
||||
|
||||
before, _ := f.ledger.Balance(f.ctx, id)
|
||||
amount := limits.MaxAutoWithdrawMsat + 1
|
||||
wid, _ := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", amount)
|
||||
|
||||
if err := f.svc.Reject(f.ctx, wid, "suspicious"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := f.ledger.Balance(f.ctx, id)
|
||||
if after != before {
|
||||
t.Fatalf("balance = %d after rejection, want %d (fully refunded)", after, before)
|
||||
}
|
||||
// A rejected withdrawal must never be paid afterwards.
|
||||
count := f.node.PaymentCount()
|
||||
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.node.PaymentCount() != count {
|
||||
t.Fatal("a rejected withdrawal was paid")
|
||||
}
|
||||
}
|
||||
|
||||
// A routing fee above the cap must fail rather than quietly cost the house.
|
||||
func TestExcessiveFeeIsRefused(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
id := f.player("a", 1_000_000)
|
||||
|
||||
f.node.FeeMsat = 500_000 // far above 1% of the amount
|
||||
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 100_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := f.ledger.Balance(f.ctx, id)
|
||||
|
||||
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := f.ledger.Balance(f.ctx, id)
|
||||
if after != before+100_000 {
|
||||
t.Fatalf("balance = %d, want %d — an over-priced payment should refund",
|
||||
after, before+100_000)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- solvency ---------------- */
|
||||
|
||||
func TestSolvencyDetectsShortfall(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
|
||||
owed, err := f.ledger.TotalIssued(f.ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f.node.SetBalance(owed + 1_000_000)
|
||||
s, err := f.svc.CheckSolvency(f.ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.Solvent {
|
||||
t.Fatalf("reported insolvent while holding a surplus: %+v", s)
|
||||
}
|
||||
|
||||
// Now the node holds less than players are owed.
|
||||
f.node.SetBalance(owed - 1)
|
||||
s, err = f.svc.CheckSolvency(f.ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Solvent {
|
||||
t.Fatalf("reported solvent while short: %+v", s)
|
||||
}
|
||||
if s.SurplusMsat >= 0 {
|
||||
t.Fatalf("surplus = %d, want negative", s.SurplusMsat)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- round trip ---------------- */
|
||||
|
||||
// Money in, play, money out — and the books balance at the end.
|
||||
func TestFullDepositWithdrawRoundTrip(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
id := f.player("a", 0)
|
||||
|
||||
inv, err := f.svc.RequestDeposit(f.ctx, id, 80_000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.node.MarkPaid(inv.PaymentHash)
|
||||
if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 80_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if bal, _ := f.ledger.Balance(f.ctx, id); bal != 0 {
|
||||
t.Fatalf("balance = %d after a full round trip, want 0", bal)
|
||||
}
|
||||
total, err := f.ledger.ConservationCheck(f.ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 0 {
|
||||
t.Fatalf("books do not balance after a round trip: %d", total)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user