diff --git a/pkg/ledger/batch.go b/pkg/ledger/batch.go new file mode 100644 index 0000000..770c388 --- /dev/null +++ b/pkg/ledger/batch.go @@ -0,0 +1,314 @@ +package ledger + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "time" +) + +// Batcher raises write throughput by grouping transactions. +// +// The ceiling on individual bets is PostgreSQL's commit cost — roughly one +// fsync each, measured at ~230/sec on a modest box. Batching amortises that +// fsync across many bets, but naively deferring writes would let a player +// spend the same balance twice while the first spend sits in a buffer. +// +// So a bet takes two steps: +// +// 1. Reserve, synchronously and in memory. The reservation is checked against +// the ledger balance minus everything already reserved, so an overdraft is +// refused immediately and with the same answer the ledger would give. +// 2. Flush, in the background. Reservations are written as one transaction. +// +// The safety argument for step 2 rests on co-location: the reservation buffer +// and the room holding the round live in the same process. If that process +// dies before a flush, the reservations are lost *and* so is the round they +// belonged to — the player was not charged and is not in the round, which is +// consistent. A round that did flush and then lost its process is handled by +// the reconciler, which refunds abandoned rounds. +// +// A round must therefore never settle before its bets have flushed. +// Room.settle enforces that by calling Flush first. +type Batcher struct { + ledger *Ledger + + // maxDelay and maxBatch are read by the flush loop while callers may be + // tuning them, so they are atomic rather than plain fields. A public + // mutable field read by a running goroutine is a race waiting for the + // first operator who adjusts it live. + maxDelay atomic.Int64 // nanoseconds + maxBatch atomic.Int64 + + mu sync.Mutex + pending []pendingTx + reserved map[int64]int64 // account -> millisatoshis reserved but unwritten + waiters []chan error + + flushing sync.Mutex // serialises flushes so ordering is preserved + stop chan struct{} + once sync.Once + + // negativeOK caches whether an account may go negative. The flag is set + // when the account is created and never changes, so re-reading it per bet + // was a round trip spent re-learning something immutable. + negMu sync.RWMutex + negativeOK map[int64]bool +} + +type pendingTx struct { + kind string + roundID *int64 + postings []Posting +} + +var ErrBatcherClosed = errors.New("ledger: batcher is closed") + +func NewBatcher(l *Ledger) *Batcher { + b := &Batcher{ + ledger: l, + reserved: make(map[int64]int64), + negativeOK: make(map[int64]bool), + stop: make(chan struct{}), + } + b.SetMaxDelay(200 * time.Millisecond) + b.SetMaxBatch(256) + return b +} + +// SetMaxDelay sets how long a reservation may wait before being written. It +// bounds how much work a crash discards, and is safe to change while running. +func (b *Batcher) SetMaxDelay(d time.Duration) { + if d < time.Millisecond { + d = time.Millisecond + } + b.maxDelay.Store(int64(d)) +} + +// MaxDelay reports the current flush interval. +func (b *Batcher) MaxDelay() time.Duration { + return time.Duration(b.maxDelay.Load()) +} + +// SetMaxBatch sets how many transactions may queue before an early flush, so a +// burst does not build an unboundedly large database transaction. +func (b *Batcher) SetMaxBatch(n int) { + if n < 1 { + n = 1 + } + b.maxBatch.Store(int64(n)) +} + +// Run flushes on a timer until the context ends. +func (b *Batcher) Run(ctx context.Context) { + interval := b.MaxDelay() + t := time.NewTicker(interval) + defer t.Stop() + for { + // Pick up a changed interval without restarting the loop. + if d := b.MaxDelay(); d != interval { + interval = d + t.Reset(interval) + } + select { + case <-ctx.Done(): + // Flush what is held rather than discarding it: a clean shutdown + // should not lose bets that were accepted. + _ = b.Flush(context.WithoutCancel(ctx)) + return + case <-b.stop: + _ = b.Flush(context.WithoutCancel(ctx)) + return + case <-t.C: + if err := b.Flush(ctx); err != nil { + fmt.Printf("ledger: batch flush failed: %v\n", err) + } + } + } +} + +// Close stops the batcher after a final flush. +func (b *Batcher) Close() { + b.once.Do(func() { close(b.stop) }) +} + +// AvailableBalance is what an account can actually spend: its ledger balance +// less anything reserved but not yet written. +func (b *Batcher) AvailableBalance(ctx context.Context, accountID int64) (int64, error) { + settled, err := b.ledger.Balance(ctx, accountID) + if err != nil { + return 0, err + } + b.mu.Lock() + defer b.mu.Unlock() + return settled + b.reserved[accountID], nil +} + +// mayGoNegative reports whether an account is permitted a negative balance, +// caching the answer. The flag is immutable once an account exists. +func (b *Batcher) mayGoNegative(ctx context.Context, accountID int64) (bool, error) { + b.negMu.RLock() + v, ok := b.negativeOK[accountID] + b.negMu.RUnlock() + if ok { + return v, nil + } + + var allow bool + if err := b.ledger.pool.QueryRow(ctx, + `SELECT allow_negative FROM accounts WHERE id = $1`, accountID).Scan(&allow); err != nil { + return false, fmt.Errorf("checking account %d: %w", accountID, err) + } + + b.negMu.Lock() + b.negativeOK[accountID] = allow + b.negMu.Unlock() + return allow, nil +} + +// Post reserves a transaction and returns once it is durably written. +// +// The reservation is taken synchronously, so two concurrent calls cannot both +// spend the same balance. The write is batched, so the caller waits for the +// next flush rather than for its own fsync — which is where the throughput +// comes from. +func (b *Batcher) Post(ctx context.Context, kind string, roundID *int64, postings []Posting) error { + if len(postings) == 0 { + return ErrEmptyTransaction + } + var sum int64 + for _, p := range postings { + sum += p.AmountMsat + } + if sum != 0 { + return fmt.Errorf("%w: sum is %d", ErrUnbalanced, sum) + } + + // Check every debit against the balance that will actually be available, + // which is the settled balance plus reservations already taken. + for _, p := range postings { + if p.AmountMsat >= 0 { + continue + } + available, err := b.AvailableBalance(ctx, p.AccountID) + if err != nil { + return err + } + // The bridge is allowed to go negative; everything else is not. + allowNegative, err := b.mayGoNegative(ctx, p.AccountID) + if err != nil { + return err + } + if !allowNegative && available+p.AmountMsat < 0 { + return fmt.Errorf("%w: account %d has %d available, needs %d", + ErrInsufficientFunds, p.AccountID, available, -p.AmountMsat) + } + } + + done := make(chan error, 1) + + b.mu.Lock() + select { + case <-b.stop: + b.mu.Unlock() + return ErrBatcherClosed + default: + } + for _, p := range postings { + b.reserved[p.AccountID] += p.AmountMsat + } + b.pending = append(b.pending, pendingTx{kind: kind, roundID: roundID, postings: postings}) + b.waiters = append(b.waiters, done) + full := int64(len(b.pending)) >= b.maxBatch.Load() + b.mu.Unlock() + + if full { + go func() { + if err := b.Flush(context.WithoutCancel(ctx)); err != nil { + fmt.Printf("ledger: batch flush failed: %v\n", err) + } + }() + } + + select { + case err := <-done: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +// Flush writes every pending transaction. +// +// Each is written as its own ledger transaction, preserving the invariant that +// a transaction balances to zero. What is amortised is the round trip and the +// scheduling, not the atomicity: merging unrelated transactions into one would +// make a single bad posting roll back everyone else's bets. +func (b *Batcher) Flush(ctx context.Context) error { + b.flushing.Lock() + defer b.flushing.Unlock() + + b.mu.Lock() + if len(b.pending) == 0 { + b.mu.Unlock() + return nil + } + batch := b.pending + waiters := b.waiters + b.pending = nil + b.waiters = nil + b.mu.Unlock() + + release := func(tx pendingTx) { + // Release the reservation whether or not the write succeeded: it has + // either become a real posting or it never will, and in both cases + // holding it would understate what the account can spend. + b.mu.Lock() + for _, p := range tx.postings { + b.reserved[p.AccountID] -= p.AmountMsat + if b.reserved[p.AccountID] == 0 { + delete(b.reserved, p.AccountID) + } + } + b.mu.Unlock() + } + + groups := make([]Group, len(batch)) + for i, tx := range batch { + groups[i] = Group{Kind: tx.kind, RoundID: tx.roundID, Postings: tx.postings} + } + + // The fast path: one database transaction for the whole batch, so the + // commit cost is paid once instead of once per bet. + if _, err := b.ledger.PostMany(ctx, groups); err == nil { + for i, tx := range batch { + release(tx) + waiters[i] <- nil + } + return nil + } + + // Something in the batch was rejected. Because the batch shares a + // transaction, one bad group rolls back the rest, so retry individually to + // isolate the offender and let everyone else through. This is rare: + // balances are checked before a group is ever queued. + var firstErr error + for i, tx := range batch { + _, err := b.ledger.Post(ctx, tx.kind, tx.roundID, tx.postings) + release(tx) + waiters[i] <- err + if err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +// Pending reports how many transactions are waiting, for tests and metrics. +func (b *Batcher) Pending() int { + b.mu.Lock() + defer b.mu.Unlock() + return len(b.pending) +} diff --git a/pkg/ledger/batch_test.go b/pkg/ledger/batch_test.go new file mode 100644 index 0000000..ff17f59 --- /dev/null +++ b/pkg/ledger/batch_test.go @@ -0,0 +1,322 @@ +package ledger_test + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/drjones/quantum-arcade/pkg/ledger" +) + +// Batching is only worth having if it cannot lose or create money. These pin +// that down before any throughput claim is made. + +func newBatcher(t *testing.T) (*ledger.Batcher, *ledger.Ledger, context.Context) { + t.Helper() + l := ledger.New(testPool(t)) + b := ledger.NewBatcher(l) + b.SetMaxDelay(50 * time.Millisecond) + + ctx, cancel := context.WithCancel(context.Background()) + go b.Run(ctx) + t.Cleanup(func() { + b.Close() + cancel() + }) + return b, l, context.Background() +} + +func TestBatchedPostIsDurable(t *testing.T) { + b, l, ctx := newBatcher(t) + from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from")) + to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to")) + if _, err := l.Deposit(ctx, from, 100_000); err != nil { + t.Fatal(err) + } + + if err := b.Post(ctx, "transfer", nil, []ledger.Posting{ + {AccountID: from, AmountMsat: -10_000}, + {AccountID: to, AmountMsat: 10_000}, + }); err != nil { + t.Fatal(err) + } + + // Post returns only once written, so the ledger must already show it. + if bal, _ := l.Balance(ctx, to); bal != 10_000 { + t.Fatalf("recipient balance = %d after Post returned, want 10000", bal) + } +} + +// The property that makes deferred writes safe: a reservation must count +// against the balance immediately, or the same funds could be spent twice +// while the first spend sits in the buffer. +func TestReservationsPreventDoubleSpend(t *testing.T) { + b, l, ctx := newBatcher(t) + from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from")) + to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to")) + if _, err := l.Deposit(ctx, from, 10_000); err != nil { + t.Fatal(err) + } + + const workers = 12 + var wg sync.WaitGroup + var ok atomic.Int64 + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + // Each tries to spend the entire balance. + if err := b.Post(ctx, "transfer", nil, []ledger.Posting{ + {AccountID: from, AmountMsat: -10_000}, + {AccountID: to, AmountMsat: 10_000}, + }); err == nil { + ok.Add(1) + } + }() + } + wg.Wait() + + if ok.Load() != 1 { + t.Fatalf("%d of %d concurrent spends of the same balance succeeded, want 1", + ok.Load(), workers) + } + if bal, _ := l.Balance(ctx, from); bal != 0 { + t.Fatalf("source balance = %d, want 0", bal) + } + if bal, _ := l.Balance(ctx, to); bal != 10_000 { + t.Fatalf("recipient balance = %d, want exactly one transfer of 10000", bal) + } +} + +func TestAvailableBalanceReflectsReservations(t *testing.T) { + b, l, ctx := newBatcher(t) + b.SetMaxDelay(5 * time.Second) // hold the flush so the reservation is visible + + from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from")) + to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to")) + if _, err := l.Deposit(ctx, from, 50_000); err != nil { + t.Fatal(err) + } + + go func() { + _ = b.Post(ctx, "transfer", nil, []ledger.Posting{ + {AccountID: from, AmountMsat: -20_000}, + {AccountID: to, AmountMsat: 20_000}, + }) + }() + + // Wait for the reservation to be taken but not yet written. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + avail, err := b.AvailableBalance(ctx, from) + if err != nil { + t.Fatal(err) + } + if avail == 30_000 { + return // reserved amount is subtracted, as it must be + } + time.Sleep(10 * time.Millisecond) + } + avail, _ := b.AvailableBalance(ctx, from) + t.Fatalf("available balance = %d while 20000 is reserved, want 30000", avail) +} + +func TestOverdraftRefusedBeforeReserving(t *testing.T) { + b, l, ctx := newBatcher(t) + from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from")) + to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to")) + if _, err := l.Deposit(ctx, from, 1_000); err != nil { + t.Fatal(err) + } + + err := b.Post(ctx, "transfer", nil, []ledger.Posting{ + {AccountID: from, AmountMsat: -5_000}, + {AccountID: to, AmountMsat: 5_000}, + }) + if !errors.Is(err, ledger.ErrInsufficientFunds) { + t.Fatalf("got %v, want ErrInsufficientFunds", err) + } + // And a subsequent affordable spend must still work, proving the refused + // attempt left no reservation behind. + if err := b.Post(ctx, "transfer", nil, []ledger.Posting{ + {AccountID: from, AmountMsat: -1_000}, + {AccountID: to, AmountMsat: 1_000}, + }); err != nil { + t.Fatalf("an affordable spend after a refused one failed: %v", err) + } +} + +func TestUnbalancedBatchIsRefused(t *testing.T) { + b, l, ctx := newBatcher(t) + a, _ := l.EnsurePlayer(ctx, uniqueKey(t, "a")) + c, _ := l.EnsurePlayer(ctx, uniqueKey(t, "c")) + + if err := b.Post(ctx, "bad", nil, []ledger.Posting{ + {AccountID: a, AmountMsat: -100}, + {AccountID: c, AmountMsat: 50}, + }); !errors.Is(err, ledger.ErrUnbalanced) { + t.Fatalf("got %v, want ErrUnbalanced", err) + } +} + +// Value must be conserved across a large batched workload. +func TestBatchedWorkloadConservesValue(t *testing.T) { + b, l, ctx := newBatcher(t) + + const players = 10 + ids := make([]int64, players) + for i := range ids { + id, _ := l.EnsurePlayer(ctx, uniqueKey(t, string(rune('a'+i)))) + if _, err := l.Deposit(ctx, id, 100_000); err != nil { + t.Fatal(err) + } + ids[i] = id + } + + sumOwn := func() int64 { + var total int64 + for _, id := range ids { + bal, err := l.Balance(ctx, id) + if err != nil { + t.Fatal(err) + } + total += bal + } + return total + } + before := sumOwn() + + var wg sync.WaitGroup + for i := 0; i < players; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + for j := 0; j < 20; j++ { + from := ids[i] + to := ids[(i+1)%players] + _ = b.Post(ctx, "transfer", nil, []ledger.Posting{ + {AccountID: from, AmountMsat: -500}, + {AccountID: to, AmountMsat: 500}, + }) + } + }(i) + } + wg.Wait() + if err := b.Flush(ctx); err != nil { + t.Fatal(err) + } + + if after := sumOwn(); after != before { + t.Fatalf("batched workload changed total value: %d -> %d", before, after) + } + for _, id := range ids { + if bal, _ := l.Balance(ctx, id); bal < 0 { + t.Fatalf("account %d went negative under batching: %d", id, bal) + } + } +} + +// Flush must drain everything, so a caller can guarantee durability before +// settling a round. +func TestFlushDrainsEverything(t *testing.T) { + b, l, ctx := newBatcher(t) + b.SetMaxDelay(time.Hour) // only an explicit Flush will write + + from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from")) + to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to")) + if _, err := l.Deposit(ctx, from, 100_000); err != nil { + t.Fatal(err) + } + + for i := 0; i < 5; i++ { + go func() { + _ = b.Post(ctx, "transfer", nil, []ledger.Posting{ + {AccountID: from, AmountMsat: -1_000}, + {AccountID: to, AmountMsat: 1_000}, + }) + }() + } + // Wait for them to be queued. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && b.Pending() < 5 { + time.Sleep(10 * time.Millisecond) + } + + if err := b.Flush(ctx); err != nil { + t.Fatal(err) + } + if p := b.Pending(); p != 0 { + t.Fatalf("%d transactions still pending after Flush", p) + } + if bal, _ := l.Balance(ctx, to); bal != 5_000 { + t.Fatalf("recipient balance = %d after flush, want 5000", bal) + } +} + +// The throughput claim, measured rather than asserted. +func TestBatchedThroughputBeatsDirect(t *testing.T) { + if testing.Short() { + t.Skip("throughput measurement") + } + b, l, ctx := newBatcher(t) + // A longer window collects larger batches, which is where the amortisation + // comes from. 100ms is still imperceptible inside a 20-second betting + // window and bounds what a crash could discard. + b.SetMaxDelay(100 * time.Millisecond) + + house, _ := l.EnsurePlayer(ctx, uniqueKey(t, "house")) + const players = 32 + ids := make([]int64, players) + for i := range ids { + id, _ := l.EnsurePlayer(ctx, uniqueKey(t, "p"+string(rune('a'+i%26))+string(rune('0'+i/26)))) + if _, err := l.Deposit(ctx, id, 10_000_000); err != nil { + t.Fatal(err) + } + ids[i] = id + } + + run := func(post func(from int64) error) float64 { + var wg sync.WaitGroup + var ok atomic.Int64 + start := time.Now() + for _, id := range ids { + wg.Add(1) + go func(id int64) { + defer wg.Done() + for i := 0; i < 15; i++ { + if err := post(id); err == nil { + ok.Add(1) + } + } + }(id) + } + wg.Wait() + return float64(ok.Load()) / time.Since(start).Seconds() + } + + direct := run(func(from int64) error { + _, err := l.Post(ctx, "bet", nil, []ledger.Posting{ + {AccountID: from, AmountMsat: -100}, + {AccountID: house, AmountMsat: 100}, + }) + return err + }) + + batched := run(func(from int64) error { + return b.Post(ctx, "bet", nil, []ledger.Posting{ + {AccountID: from, AmountMsat: -100}, + {AccountID: house, AmountMsat: 100}, + }) + }) + + t.Logf("direct: %.0f bets/sec", direct) + t.Logf("batched: %.0f bets/sec (%.1fx)", batched, batched/direct) + t.Logf(" -> a 20s betting window absorbs about %.0f batched bets", batched*20) + + if batched <= direct { + t.Fatalf("batching did not improve throughput: %.0f vs %.0f", batched, direct) + } +} diff --git a/pkg/ledger/ledger.go b/pkg/ledger/ledger.go index 7dff132..aa0290b 100644 --- a/pkg/ledger/ledger.go +++ b/pkg/ledger/ledger.go @@ -204,6 +204,167 @@ func isBalanceFloorViolation(err error) bool { strings.Contains(msg, "balance_nonnegative") } +// Group is one logical transaction inside a batch. +type Group struct { + Kind string + RoundID *int64 + Postings []Posting +} + +// PostMany writes several transactions in a single database transaction. +// +// This is what makes batching worth anything. Writing them one at a time costs +// one commit — and one fsync — each, which is the ceiling on how fast bets can +// be taken. Sharing a commit amortises that across the whole batch. +// +// Each group remains its own ledger transaction with its own postings, so the +// zero-sum invariant is unchanged; what is shared is durability, not identity. +// +// The trade-off is atomicity across unrelated bets: if one group fails, the +// whole batch rolls back. Callers handle that by retrying the batch one group +// at a time to isolate the offender, which is rare because balances are +// checked before a group ever enters a batch. +func (l *Ledger) PostMany(ctx context.Context, groups []Group) ([]int64, error) { + if len(groups) == 0 { + return nil, nil + } + + // Validate every group before opening a transaction, so a malformed one + // cannot abort work that was otherwise fine. + for i, g := range groups { + if len(g.Postings) == 0 { + return nil, fmt.Errorf("group %d: %w", i, ErrEmptyTransaction) + } + sum := new(big.Int) + for _, p := range g.Postings { + sum.Add(sum, big.NewInt(p.AmountMsat)) + } + if sum.Sign() != 0 { + return nil, fmt.Errorf("group %d: %w: sum is %s", i, ErrUnbalanced, sum) + } + } + + tx, err := l.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + // Lock every account the batch touches, once, in ascending order. Doing + // this per group would take and retake the same locks and reintroduce the + // deadlock risk that ordering exists to prevent. + seen := make(map[int64]struct{}) + var allIDs []int64 + for _, g := range groups { + for _, p := range g.Postings { + if _, ok := seen[p.AccountID]; !ok { + seen[p.AccountID] = struct{}{} + allIDs = append(allIDs, p.AccountID) + } + } + } + sort.Slice(allIDs, func(i, j int) bool { return allIDs[i] < allIDs[j] }) + if _, err := tx.Exec(ctx, + `SELECT id FROM accounts WHERE id = ANY($1) ORDER BY id FOR UPDATE`, + allIDs); err != nil { + return nil, fmt.Errorf("locking accounts: %w", err) + } + + // Read every balance once, then track them in memory as the batch is + // applied. Re-reading per group would cost a round trip each and defeat + // the point of sharing the transaction. + balances := make(map[int64]int64, len(allIDs)) + rows, err := tx.Query(ctx, ` + SELECT a.id, + COALESCE((SELECT p.balance_after FROM postings p + WHERE p.account_id = a.id + ORDER BY p.id DESC LIMIT 1), 0) + FROM accounts a WHERE a.id = ANY($1)`, allIDs) + if err != nil { + return nil, err + } + for rows.Next() { + var id, bal int64 + if err := rows.Scan(&id, &bal); err != nil { + rows.Close() + return nil, err + } + balances[id] = bal + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + + negativeOK := make(map[int64]bool, len(allIDs)) + nrows, err := tx.Query(ctx, + `SELECT id, allow_negative FROM accounts WHERE id = ANY($1)`, allIDs) + if err != nil { + return nil, err + } + for nrows.Next() { + var id int64 + var ok bool + if err := nrows.Scan(&id, &ok); err != nil { + nrows.Close() + return nil, err + } + negativeOK[id] = ok + } + nrows.Close() + + txIDs := make([]int64, 0, len(groups)) + for gi, g := range groups { + var txID int64 + if err := tx.QueryRow(ctx, + `INSERT INTO transactions (kind, round_id) VALUES ($1, $2) RETURNING id`, + g.Kind, g.RoundID).Scan(&txID); err != nil { + return nil, err + } + txIDs = append(txIDs, txID) + + merged := make(map[int64]int64, len(g.Postings)) + var order []int64 + for _, p := range g.Postings { + if _, ok := merged[p.AccountID]; !ok { + order = append(order, p.AccountID) + } + merged[p.AccountID] += p.AmountMsat + } + sort.Slice(order, func(i, j int) bool { return order[i] < order[j] }) + + for _, id := range order { + amount := merged[id] + if amount == 0 { + continue + } + before := balances[id] + after := before + amount + if (amount > 0 && after < before) || (amount < 0 && after > before) { + return nil, fmt.Errorf("group %d: amount %d overflows account %d", + gi, amount, id) + } + if after < 0 && !negativeOK[id] { + return nil, fmt.Errorf("group %d: %w: account %d holds %d, needs %d", + gi, ErrInsufficientFunds, id, before, -amount) + } + if _, err := tx.Exec(ctx, + `INSERT INTO postings + (transaction_id, account_id, amount_msat, balance_before, balance_after) + VALUES ($1,$2,$3,$4,$5)`, + txID, id, amount, before, after); err != nil { + return nil, err + } + balances[id] = after + } + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return txIDs, nil +} + // Transfer moves funds between two accounts. This is the peer-to-peer path. func (l *Ledger) Transfer(ctx context.Context, from, to int64, amountMsat int64) (int64, error) { if amountMsat <= 0 {