Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 47 additions & 7 deletions cashu/wallet/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
get_bolt11_mint_quotes,
get_reserved_proofs,
get_seed_and_mnemonic,
get_transactions,
)
from ...wallet.wallet import Wallet as Wallet
from ..auth.auth import WalletAuth
Expand Down Expand Up @@ -369,9 +370,9 @@ async def pay(
f"Sending token via POST to {url}...", end="", flush=True)

token_obj = deserialize_token_from_string(token)
assert isinstance(
token_obj, TokenV4
), "Only TokenV4 supported for POST transport"
assert isinstance(token_obj, TokenV4), (
"Only TokenV4 supported for POST transport"
)

proofs = token_obj.proofs

Expand Down Expand Up @@ -735,8 +736,7 @@ async def balance(ctx: Context, verbose):
print("")
for i, (k, v) in enumerate(unit_balances.items()):
unit = k
print(
f"Unit {i+1} ({unit}) - Balance: {unit.str(int(v['available']))}")
print(f"Unit {i + 1} ({unit}) - Balance: {unit.str(int(v['available']))}")
print("")
if verbose:
# show balances per keyset
Expand All @@ -748,7 +748,7 @@ async def balance(ctx: Context, verbose):
unit = Unit[str(v["unit"])]
print(
f"Keyset: {k} - Balance: {unit.str(int(v['available']))} (pending:"
f" {unit.str(int(v['balance'])-int(v['available']))})"
f" {unit.str(int(v['balance']) - int(v['available']))})"
)
print("")

Expand All @@ -757,7 +757,7 @@ async def balance(ctx: Context, verbose):
if verbose:
print(
f"Balance: {wallet.available_balance} (pending:"
f" {wallet.balance-wallet.available_balance}) in"
f" {wallet.balance - wallet.available_balance}) in"
f" {len([p for p in wallet.proofs if not p.reserved])} tokens"
)
else:
Expand Down Expand Up @@ -1674,3 +1674,43 @@ async def lnurl_mint(ctx: Context):
print("No tokens minted.")
except Exception as e:
print(f"Error minting quotes: {e}")


@cli.command("history", help="Show transaction history.")
@click.option(
"--detailed",
"-d",
default=False,
help="Show detailed information on transactions.",
is_flag=True,
)
@click.option(
"--number", "-n", default=None, help="Show only last n transactions.", type=int
)
@click.pass_context
@coro
async def history(ctx: Context, detailed: bool, number: int):
wallet: Wallet = ctx.obj["WALLET"]
txs = await get_transactions(wallet.db, limit=number)
if not txs:
print("No transaction history.")
return
print("-------------- Transaction history --------------")
print(f"{'Type':<10} {'Amount':>14} {'State':<10} Time")
print("-------------------------------------------------")
for tx in txs:
sign = "+" if tx.tx_type in ("mint", "receive") else "-"
amount_str = f"{sign}{tx.amount} {tx.unit}"
time_str = datetime.fromtimestamp(tx.created_time, tz=timezone.utc).strftime(
"%Y-%m-%d %H:%M"
)
output = f"{tx.tx_type:<10} {amount_str:>14} {tx.state:<10} {time_str}"
if detailed:
output += f"\n Mint: {tx.mint}"
if tx.quote_id:
output += f"\n Quote: {tx.quote_id}"
if tx.fee is not None:
output += f"\n Fee: {tx.fee} {tx.unit}"
if tx.preimage:
output += f"\n Preimage: {tx.preimage}"
print(output)
100 changes: 99 additions & 1 deletion cashu/wallet/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
WalletMint,
)
from ..core.db import Connection, Database
from .wallet_transaction import WalletTransaction


class _UnsetType:
Expand Down Expand Up @@ -390,7 +391,9 @@ async def store_bolt11_melt_quote(
"payment_preimage": quote.payment_preimage,
"expiry": quote.expiry,
"change": (
json.dumps([c.model_dump() for c in quote.change]) if quote.change else ""
json.dumps([c.model_dump() for c in quote.change])
if quote.change
else ""
),
},
)
Expand Down Expand Up @@ -618,3 +621,98 @@ async def get_mint_by_url(
{"url": url},
)
return WalletMint.model_validate(dict(row)) if row else None


async def store_transaction(
db: Database,
*,
tx_type: str,
amount: int,
unit: str,
mint: str,
state: str,
quote_id: Optional[str] = None,
fee: Optional[int] = None,
preimage: Optional[str] = None,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).execute(
"""
INSERT INTO transactions
(type, amount, unit, mint, state, quote_id, fee, preimage, created_time)
VALUES (:type, :amount, :unit, :mint, :state, :quote_id, :fee, :preimage, :created_time)
""",
{
"type": tx_type,
"amount": amount,
"unit": unit,
"mint": mint,
"state": state,
"quote_id": quote_id,
"fee": fee,
"preimage": preimage,
"created_time": int(time.time()),
},
)


async def update_transaction_state(
db: Database,
*,
quote_id: str,
state: str,
fee: Optional[int] = None,
preimage: Optional[str] = None,
conn: Optional[Connection] = None,
) -> None:
clauses = ["state = :state"]
values: Dict[str, Any] = {"state": state, "quote_id": quote_id}

if fee is not None:
clauses.append("fee = :fee")
values["fee"] = fee
if preimage is not None:
clauses.append("preimage = :preimage")
values["preimage"] = preimage

await (conn or db).execute(
f"UPDATE transactions SET {', '.join(clauses)} WHERE quote_id = :quote_id",
values,
)


async def get_transactions(
db: Database,
*,
tx_type: Optional[str] = None,
mint: Optional[str] = None,
limit: Optional[int] = None,
conn: Optional[Connection] = None,
) -> List[WalletTransaction]:
clauses = []
values: Dict[str, Any] = {}

if tx_type:
clauses.append("type = :type")
values["type"] = tx_type
if mint:
clauses.append("mint = :mint")
values["mint"] = mint

where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
limit_clause = "LIMIT :limit" if limit is not None else ""
if limit is not None:
if limit < 1:
raise ValueError("limit must be a positive integer")
values["limit"] = limit

rows = await (conn or db).fetchall(
f"""
SELECT * FROM transactions
{where}
ORDER BY created_time DESC
{limit_clause}
""",
values,
)
return [WalletTransaction.from_row(r) for r in rows] if rows else []
37 changes: 30 additions & 7 deletions cashu/wallet/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
from ..core.secret import Tags
from ..core.settings import settings
from ..wallet import migrations
from ..wallet.crud import get_keysets
from ..wallet.crud import get_keysets, store_transaction
from ..wallet.wallet import Wallet
from .wallet_transaction import WalletTransactionState


async def migrate_wallet_db(db: Database):
Expand Down Expand Up @@ -45,7 +46,11 @@ async def redeem_TokenV3(wallet: Wallet, token: TokenV3) -> Wallet:
# load unit from wallet keyset db
proof_keyset_id = token.token[0].proofs[0].id
keysets = await get_keysets(id=proof_keyset_id, db=wallet.db)
if not keysets and proof_keyset_id.startswith("01") and len(proof_keyset_id) == 16:
if (
not keysets
and proof_keyset_id.startswith("01")
and len(proof_keyset_id) == 16
):
# This might be a v2 short ID, try to find a matching full ID
all_keysets = await get_keysets(db=wallet.db)
keysets = [k for k in all_keysets if k.id.startswith(proof_keyset_id)]
Expand All @@ -64,11 +69,11 @@ async def redeem_TokenV3(wallet: Wallet, token: TokenV3) -> Wallet:
keyset_ids = mint_wallet._get_proofs_keyset_ids(t.proofs)
logger.trace(f"Keysets in tokens: {' '.join(set(keyset_ids))}")
await mint_wallet.load_mint()

# Expand short keyset IDs to full IDs
# This is a no-op in the case of base64 keysets and v1 keysets
await mint_wallet._expand_short_keyset_ids(t.proofs)

proofs_to_keep, _ = await mint_wallet.redeem(t.proofs)
print(f"Received {mint_wallet.unit.str(sum_proofs(proofs_to_keep))}")

Expand All @@ -81,13 +86,13 @@ async def redeem_TokenV4(wallet: Wallet, token: TokenV4) -> Wallet:
Redeem a token with a single mint.
"""
await wallet.load_mint()

# Get proofs from token (these will have short keyset IDs)
proofs = token.proofs

# Expand v2 short keyset IDs to full IDs in-place
await wallet._expand_short_keyset_ids(proofs)

# Use the expanded proofs for redemption
proofs_to_keep, _ = await wallet.redeem(proofs)
print(f"Received {wallet.unit.str(sum_proofs(proofs_to_keep))}")
Expand Down Expand Up @@ -126,6 +131,14 @@ async def receive(
mint_wallet = await redeem_universal(wallet, token)
# reload main wallet so the balance updates
await wallet.load_proofs(reload=True)
await store_transaction(
db=wallet.db,
tx_type=WalletTransactionState.receive.name,
amount=token.amount,
unit=token.unit,
mint=mint_wallet.url,
state="completed",
)
return mint_wallet


Expand Down Expand Up @@ -198,6 +211,16 @@ async def send(
print(token)

await wallet.set_reserved_for_send(send_proofs, reserved=True)

await store_transaction(
db=wallet.db,
tx_type=WalletTransactionState.send.name,
amount=amount,
unit=wallet.unit.name,
mint=wallet.url,
state="completed",
)

return wallet.available_balance, token


Expand Down
23 changes: 23 additions & 0 deletions cashu/wallet/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,3 +334,26 @@ async def m016_remove_nostr_table(db: Database):
DROP TABLE IF EXISTS nostr;
"""
)


async def m017_create_transactions_table(db: Database):
"""
Creates the transaction table used to track transaction history.
"""
async with db.connect() as conn:
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL, -- 'mint' | 'melt' | 'send' | 'receive'
amount INTEGER NOT NULL, -- always positive; direction implied by type
unit TEXT NOT NULL, -- e.g. 'sat'
mint TEXT NOT NULL, -- mint URL
state TEXT NOT NULL, -- 'pending' | 'completed' | 'failed'
quote_id TEXT, -- bolt11 quote id for mint/melt; NULL for ecash
fee INTEGER, -- melt only: actual fee paid
preimage TEXT, -- melt only: payment preimage
created_time INTEGER NOT NULL -- unix timestamp
);
"""
)
Loading
Loading