auradefi 0.1.1
PyPI GitHub

PyBook 07: Transactions

What this notebook proves: "the rich transaction model + EVM decode: parts[], acts[], fees as siblings, the type derivation, reorg handling. Done when: a reorg fixture produces removed + re-added."

Two models, on purpose:

Rule #4 is the load-bearing one: every movement is a part[], and a fee is a sibling of the parts, never a movement. Fold gas into the ETH outflow and cost basis is wrong forever.

Everything here is in-memory: explorer rows in, decisions out. No network.

from auradefi.decode.models import BorneBy, Direction, TxStatus, TxSubtype, TxType
from auradefi.decode.pipeline import decode_account
from auradefi.sources.evm.txlist import NormalTxRecord, TokenTxRecord

CHAIN, ACCOUNT_ID = "eip155:1", "acct_1"
ME = "0x" + "11" * 20
COUNTERPARTY = "0x" + "44" * 20
USDC_CONTRACT = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
GAS_PRICE = 10**10  # 10 gwei

# One swap, seen twice by the explorer: as a normal tx (ETH out) and as a
# token transfer (USDC in). The decoder must fuse them into ONE transaction.
swap_normal = NormalTxRecord(
    tx_hash="0x" + "cc" * 32, block_number=102, time_stamp=1_700_000_200,
    from_address=ME, to_address=COUNTERPARTY, value_wei=10**18,
    gas_used=120_000, gas_price_wei=GAS_PRICE, is_error=False,
)
swap_token = TokenTxRecord(
    tx_hash="0x" + "cc" * 32, block_number=102, time_stamp=1_700_000_200,
    from_address=COUNTERPARTY, to_address=ME, contract_address=USDC_CONTRACT,
    value_raw=3_000_000_000, token_decimal=6, token_symbol="USDC",
    gas_used=120_000, gas_price_wei=GAS_PRICE,
)

(swap,) = decode_account(CHAIN, ACCOUNT_ID, ME, [swap_normal], [swap_token])

assert swap.id == "txn_557113c18fb02870"      # sha256("chain|hash|account")[:16]
assert swap.status is TxStatus.CONFIRMED
assert swap.type is TxType.TRADE              # DERIVED from part directions
assert swap.subtype is TxSubtype.SWAP
assert swap.initiated_at == 1_700_000_200_000  # seconds in, ms epoch out
print(swap.id, swap.type, swap.subtype, "block", swap.block_number)
txn_557113c18fb02870 trade swap block 102

parts[]: one row per movement, both legs visible

An in and an out in the same transaction is what makes it a trade; type is derived from the part directions rather than guessed from the to address (decode.models.derive_tx_type). Each part carries its own from/to, so a UI can render the counterparty without re-reading chain state.

assert [(part.direction, part.asset_id, str(part.quantity)) for part in swap.parts] == [
    (Direction.OUT, "eip155:1/slip44:60", "1"),
    (Direction.IN, f"eip155:1/erc20:{USDC_CONTRACT}", "3000"),
]
assert all(part.act_id == "act_0" for part in swap.parts)
assert swap.parts[0].from_address == ME and swap.parts[0].to_address == COUNTERPARTY

for part in swap.parts:
    print(f"{part.direction.value:>3}  {str(part.quantity):>6}  {part.asset_id}")
out       1  eip155:1/slip44:60
 in    3000  eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48

acts[]: the grouping key inside one transaction

An act is a sub-operation; every part and every fee back-references its act_id. The EVM decoder emits exactly one act (act_0) per transaction, and multi-act decomposition (multicalls, ERC-4337 bundles) arrives with protocol decoders, so the shape is right today even though the depth is not. data_quality says so out loud instead of pretending.

assert [act.act_id for act in swap.acts] == ["act_0"]
assert swap.acts[0].subtype is TxSubtype.SWAP
assert swap.acts[0].protocol is None            # no protocol decoder yet: stated, not faked
assert swap.data_quality.incomplete == ("fiat_value",)
assert swap.data_quality.sources == ("etherscan",)
assert swap.data_quality.decoder_version == 1
print(swap.acts[0])
print(swap.data_quality)
Act(act_id='act_0', subtype=<TxSubtype.SWAP: 'swap'>, protocol=None)
DataQuality(incomplete=('fiat_value',), confidence=1.0, decoder_version=1, sources=('etherscan',))

Fees are siblings: and they say who paid

Fee lives beside parts[], never inside it, and carries borne_by:

Getting this wrong is invisible: the balance still reconciles, the cost basis is just quietly wrong.

from auradefi.money.quantity import Quantity

# 120,000 gas x 10 gwei = 0.0012 ETH, paid by us.
(fee,) = swap.fees
assert fee.quantity == Quantity(1_200_000_000_000_000, 18)
assert str(fee.quantity) == "0.0012"
assert fee.borne_by is BorneBy.SELF
assert fee.asset_id == "eip155:1/slip44:60"
assert fee.act_id == "act_0"
# ...and it is NOT among the movements:
assert all(part.quantity != fee.quantity for part in swap.parts)

# An INCOMING transfer: the sender paid, so the fee is theirs, not ours.
incoming = NormalTxRecord(
    tx_hash="0x" + "aa" * 32, block_number=100, time_stamp=1_700_000_000,
    from_address="0x" + "99" * 20, to_address=ME, value_wei=10**18,
    gas_used=21_000, gas_price_wei=GAS_PRICE, is_error=False,
)
(received,) = decode_account(CHAIN, ACCOUNT_ID, ME, [incoming], [])
assert received.type is TxType.RECEIVE
assert received.fees[0].borne_by is BorneBy.COUNTERPARTY

print(f"swap fee     {fee.quantity} ETH  borne_by={fee.borne_by.value}")
print(f"received fee {received.fees[0].quantity} ETH  borne_by={received.fees[0].borne_by.value}")
swap fee     0.0012 ETH  borne_by=self
received fee 0.00021 ETH  borne_by=counterparty

A failed transaction still cost money

is_error=1 means nothing moved, but the gas was burned. So: status failed, zero parts, one fee. This is the case that makes "a fee is not a movement" pay for itself.

failed_row = NormalTxRecord(
    tx_hash="0x" + "dd" * 32, block_number=103, time_stamp=1_700_000_300,
    from_address=ME, to_address="0x" + "55" * 20, value_wei=5 * 10**17,
    gas_used=21_000, gas_price_wei=GAS_PRICE, is_error=True,
)
(failed,) = decode_account(CHAIN, ACCOUNT_ID, ME, [failed_row], [])

assert failed.status is TxStatus.FAILED
assert failed.parts == ()                       # 0.5 ETH did NOT move
assert failed.fees[0].quantity == Quantity(210_000_000_000_000, 18)
assert failed.type is TxType.INTERACTION
print(f"{failed.id}  status={failed.status.value}  parts={len(failed.parts)}  fee={failed.fees[0].quantity} ETH")
txn_a30f49051566e03d  status=failed  parts=0  fee=0.00021 ETH

The bridge: rich in, ledger out

ledger.bridge.to_ledger_transaction keeps the movements as entries[] and drops the fees: the ledger answers "what did this account hold", and gas is answered by the rich model. A failed transaction therefore bridges to a row with zero entries: present in the ledger (it happened, it has a hash) but moving nothing.

from auradefi.ledger.bridge import to_ledger_transaction
from auradefi.ledger.models import Direction as LedgerDirection

bridged_swap = to_ledger_transaction(swap)
assert [(entry.direction, entry.asset_id) for entry in bridged_swap.entries] == [
    (LedgerDirection.OUT, "eip155:1/slip44:60"),
    (LedgerDirection.IN, f"eip155:1/erc20:{USDC_CONTRACT}"),
]
assert bridged_swap.id == swap.id and bridged_swap.tx_hash == swap.tx_hash
assert to_ledger_transaction(failed).entries == ()   # fee survives ONLY at the rich level
assert not hasattr(bridged_swap, "fees")
print(bridged_swap.id, [str(entry.quantity) for entry in bridged_swap.entries])
print("failed tx bridges to", len(to_ledger_transaction(failed).entries), "entries")
txn_557113c18fb02870 ['1', '3000']
failed tx bridges to 0 entries

Reorg: removed + re-added

Blocks get orphaned. The contract is Plaid's: a client that only ever replays the cursor stream must converge on the truth, so a reorg emits a removed event for what is gone and an added event for what came back at its new height. Both bump the last-modified sequence, so the cursor stays strictly monotonic and a client that was mid-page loses nothing.

Below: four transactions land, we sync them, then a reorg from block 101 orphans C and re-mines B at block 105.

from auradefi.ledger.backends.memory import MemoryLedger
from auradefi.ledger.models import SyncEventKind
from auradefi.ledger.reorg import plan_reorg

TENANT = "tenant-a"


def normal(tx_hash, block, ts, sender, to, value, gas, is_error=False):
    return NormalTxRecord(
        tx_hash=tx_hash, block_number=block, time_stamp=ts, from_address=sender,
        to_address=to, value_wei=value, gas_used=gas, gas_price_wei=GAS_PRICE,
        is_error=is_error,
    )


def token(tx_hash, block, ts, sender, to, raw, gas):
    return TokenTxRecord(
        tx_hash=tx_hash, block_number=block, time_stamp=ts, from_address=sender,
        to_address=to, contract_address=USDC_CONTRACT, value_raw=raw,
        token_decimal=6, token_symbol="USDC", gas_used=gas, gas_price_wei=GAS_PRICE,
    )


def bridge(normals, tokens):
    return [to_ledger_transaction(rich)
            for rich in decode_account(CHAIN, ACCOUNT_ID, ME, normals, tokens)]


b_normal = normal("0x" + "bb" * 32, 101, 1_700_000_100, ME, USDC_CONTRACT, 0, 50_000)
b_token = token("0x" + "bb" * 32, 101, 1_700_000_100, ME, "0x" + "33" * 20, 25_000_000, 50_000)
fixture = bridge([incoming, b_normal, swap_normal, failed_row], [b_token, swap_token])

ledger = MemoryLedger()
events = ledger.upsert(TENANT, fixture)
assert [event.kind for event in events] == [SyncEventKind.ADDED] * 4
assert [event.transaction.last_modified_seq for event in events] == [1, 2, 3, 4]

first_page = ledger.sync(TENANT, None)
assert first_page.next_cursor == "00000000000000000004"   # f"{seq:020d}", pinned
assert first_page.has_more is False
print("initial sync:", [event.transaction.id for event in first_page.events])
print("cursor:", first_page.next_cursor)
initial sync: ['txn_f7e3f7aba9d6775a', 'txn_e5e727672fb4ada6', 'txn_557113c18fb02870', 'txn_a30f49051566e03d']
cursor: 00000000000000000004
TXN_B, TXN_C = "txn_e5e727672fb4ada6", "txn_557113c18fb02870"

# The canonical chain from block 101 onwards, re-read after the reorg:
# B is back (at block 105), C is gone, D is unchanged.
b_prime = bridge(
    [normal("0x" + "bb" * 32, 105, 1_700_000_500, ME, USDC_CONTRACT, 0, 50_000)],
    [token("0x" + "bb" * 32, 105, 1_700_000_500, ME, "0x" + "33" * 20, 25_000_000, 50_000)],
)[0]
stored = [ledger.get(TENANT, txn.id) for txn in fixture]
plan = plan_reorg(stored, [b_prime, fixture[3]], from_block=101)

assert plan.remove_ids == (TXN_C,)      # orphaned: absent from the canonical set
assert plan.add == (b_prime,)           # re-mined: payload changed, so re-added

reorg_events = ledger.apply_reorg(TENANT, plan)
assert [(event.kind, event.transaction.id) for event in reorg_events] == [
    (SyncEventKind.REMOVED, TXN_C),
    (SyncEventKind.ADDED, TXN_B),
]
assert reorg_events[0].transaction.removed is True
assert reorg_events[1].transaction.block_number == 105

# The client that only replays the cursor sees exactly the delta:
page = ledger.sync(TENANT, "00000000000000000004")
assert [(event.kind.value, event.transaction.id) for event in page.events] == [
    ("removed", TXN_C), ("added", TXN_B),
]
assert page.next_cursor == "00000000000000000006"
assert "00000000000000000004" < page.next_cursor  # lexicographic == numeric, by design
for event in page.events:
    print(f"{event.kind.value:>7}  {event.transaction.id}  block={event.transaction.block_number}")
removed  txn_557113c18fb02870  block=102
  added  txn_e5e727672fb4ada6  block=105

Resurrection

An orphaned transaction that resurfaces in a later block is not a new row: same derived id, removed flips back to False, and the sequence bumps again so the cursor stream carries it. Ids are derived from (chain, hash, account), which is exactly what makes this expressible.

c_prime = bridge(
    [normal("0x" + "cc" * 32, 106, 1_700_000_600, ME, COUNTERPARTY, 10**18, 120_000)],
    [token("0x" + "cc" * 32, 106, 1_700_000_600, COUNTERPARTY, ME, 3_000_000_000, 120_000)],
)[0]
(resurrected,) = ledger.upsert(TENANT, [c_prime])

assert resurrected.kind is SyncEventKind.ADDED
assert resurrected.transaction.id == TXN_C          # the SAME id, not a new row
assert resurrected.transaction.removed is False
assert resurrected.transaction.block_number == 106

final = ledger.sync(TENANT, "00000000000000000006")
assert [event.transaction.id for event in final.events] == [TXN_C]
assert final.next_cursor == "00000000000000000007"
print(f"{TXN_C} is back at block {resurrected.transaction.block_number}; cursor {final.next_cursor}")
txn_557113c18fb02870 is back at block 106; cursor 00000000000000000007

Honest edges

Next: 08_positions. What the balances mean.