Skip to content

ledger: StoreTransaction posts legs onto accounts belonging to another book #367

Description

@Yaraslaut

What happens

StoreTransaction looks each leg's account up by id alone, with no filter on the ledger the action names:

// examples/ledger/src/models/ledger_model.cpp:694-701
for (const auto& leg : action.legs) {
    auto rows = mapper.Query<db::AccountRecord>()
                    .Where(::Lightweight::FieldNameOf<&db::AccountRecord::id>, "=", *leg.accountId)
                    .All();
    if (rows.empty()) {
        throw NotFound{"StoreTransaction: no such account"};
    }
    legAccounts.push_back(rows.front());
}

Account ids are a table-wide autoincrement, so another book's account id is a perfectly well-formed number naming a real row. The entry is accepted, the journal is filed under the ledger the action named, and the foreign account's balance moves.

Reproduction

Against a real ladder_ledger_server (two seeded ledgers rows):

OpenAccount ledgerId=1 name="B1 wallet" kind=0 currency=1   -> id 1
OpenAccount ledgerId=2 name="B2 spend"  kind=1 currency=1   -> id 2

StoreTransaction ledgerId=1 legs=[{accountId:1, -1.00}, {accountId:2, +1.00}]
  -> ok  {"accounts":[{"id":1,...,"balance":{"num":-100,...}}]}

GetLedger ledgerId=1 -> [{"id":1, balance -100}]           # no mention of account 2
GetLedger ledgerId=2 -> [{"id":2, balance  100}]           # moved, with no journal here

Book one's reply does not mention the account it just moved, because every read is scoped to its ledger. Book two shows a balance change with no journal of its own to explain it. The two books disagree and neither report says so.

A leg naming an id that exists in no book at all is refused (StoreTransaction: no such account), which is what shows the lookup runs — it simply does not ask which book the row is in.

Why this reads as an inconsistency rather than a policy

UndoTransaction does make the check, at ledger_model.cpp:909:

throw NotFound{"UndoTransaction: journal does not belong to this ledger"};

so the scoping is thought about elsewhere in the same file. Nothing in execute(StoreTransaction) or in transaction_dto.hpp records a decision to omit it.

Same shape, second site

ImportLedgerChunk looks its row account up the same way (ledger_model.cpp:1074-1079), so a chunk whose counterAccountId or whose CSV account_id column names another book's account posts there too.

Where it is pinned

scripts/scenario/scenarios/ledger/two-books-are-isolated.scenario asserts the current behaviour, with a comment naming it, so a fix has to come through that file and change it deliberately.

Suggested fix

Add .Where(FieldNameOf<&db::AccountRecord::ledger>, "=", *action.ledgerId) to both lookups, with a distinct message ("StoreTransaction: account does not belong to this ledger") so it is not confused with the not-found path.

Found by the out-of-process scenario corpus; the in-process tests use one book.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: ladderSubsystem: ladderbugSomething isn't workingtriage: validWell-framed; implement as written

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions