Skip to content
Merged
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
496 changes: 421 additions & 75 deletions README.md

Large diffs are not rendered by default.

31 changes: 26 additions & 5 deletions alembic/versions/0002_add_credit_ledger_entries.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from collections.abc import Sequence

import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

from alembic import op

Expand All @@ -31,26 +32,46 @@
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None

# PostgreSQL native ENUM types
transaction_direction = sa.Enum(
# PostgreSQL native ENUM types — use postgresql.ENUM with create_type=False
# to prevent auto-creation during create_table. We manage type lifecycle
# ourselves via _create_enum_if_not_exists / downgrade DROP TYPE.
transaction_direction = postgresql.ENUM(
"CREDIT",
"DEBIT",
name="transaction_direction",
create_type=False,
)
transaction_source = sa.Enum(
transaction_source = postgresql.ENUM(
"STRIPE",
"USAGE",
"ADJUSTMENT",
"REFUND",
name="transaction_source",
create_type=False,
)


def _create_enum_if_not_exists(name: str, values: Sequence[str]) -> None:
"""Create a PostgreSQL ENUM type only if it does not already exist.

Works around SQLAlchemy Enum.create(checkfirst=True) failing with asyncpg,
and PostgreSQL < 16.4 not supporting CREATE TYPE IF NOT EXISTS.
"""
bind = op.get_bind()
result = bind.execute(
sa.text("SELECT 1 FROM pg_type WHERE typname = :name"),
{"name": name},
)
if not result.scalar():
vals = ", ".join(f"'{v}'" for v in values)
bind.execute(sa.text(f"CREATE TYPE {name} AS ENUM ({vals})"))


def upgrade() -> None:
"""Create credit_ledger_entries table with enum types and constraints."""
# -- enum types -----------------------------------------------------------
transaction_direction.create(op.get_bind(), checkfirst=True)
transaction_source.create(op.get_bind(), checkfirst=True)
_create_enum_if_not_exists("transaction_direction", ["CREDIT", "DEBIT"])
_create_enum_if_not_exists("transaction_source", ["STRIPE", "USAGE", "ADJUSTMENT", "REFUND"])

# -- credit_ledger_entries ------------------------------------------------
op.create_table(
Expand Down
4 changes: 1 addition & 3 deletions core/api/v1/certs.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,9 +176,7 @@ async def renew_certificate(
# Verify the current certificate was issued by our CA
try:
current_cert_bytes = payload.current_cert_pem.encode()
if not CertificateAuthority.verify_cert_chain(
current_cert_bytes, ca_cert_pem
):
if not CertificateAuthority.verify_cert_chain(current_cert_bytes, ca_cert_pem):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Current certificate was not issued by this CA",
Expand Down
4 changes: 2 additions & 2 deletions core/api/v1/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ async def readiness_check(
# Check Redis
try:
settings = get_settings()
r: aioredis.Redis = aioredis.from_url(settings.redis_url) # type: ignore[no-untyped-call]
await r.ping()
r = aioredis.from_url(settings.redis_url)
await r.ping() # type: ignore[misc]
await r.aclose()
checks["redis"] = "connected"
except Exception:
Expand Down
4 changes: 2 additions & 2 deletions core/api/v1/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ class AnchorListResponse(BaseModel):
# ---------------------------------------------------------------------------


def _get_anchoring_deps():
def _get_anchoring_deps(): # type: ignore[no-untyped-def]
"""Build the anchoring service and chain client for dependency injection.

Returns a tuple of (AnchoringService, ChainClient).
Expand Down Expand Up @@ -211,7 +211,7 @@ async def verify_entry_proof(
entry_id: int,
current_user: User = Depends(get_current_user), # noqa: ARG001
session: AsyncSession = Depends(get_db_session),
deps: tuple = Depends(_get_anchoring_deps),
deps: tuple = Depends(_get_anchoring_deps), # type: ignore[type-arg]
) -> VerificationResponse:
"""Verify that a ledger entry's Merkle proof matches on-chain data.

Expand Down
4 changes: 3 additions & 1 deletion core/billing/invoice/invoice_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
async def build(account_id: str):
async with async_session() as s:
rows = await s.execute(
text("SELECT created_at, delta_usd FROM ledger_entries WHERE account_id=:a AND date_trunc('month', created_at)=date_trunc('month', now()-interval '1 month')"),
text(
"SELECT created_at, delta_usd FROM ledger_entries WHERE account_id=:a AND date_trunc('month', created_at)=date_trunc('month', now()-interval '1 month')"
),
{"a": account_id},
)
items = [
Expand Down
4 changes: 1 addition & 3 deletions core/billing/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,7 @@ class JobPricing(BaseModel):
"""

__tablename__ = "job_pricing"
__table_args__ = (
Index("ix_job_pricing_job_type", "job_type", unique=True),
)
__table_args__ = (Index("ix_job_pricing_job_type", "job_type", unique=True),)

job_type: Mapped[str] = mapped_column(
String(100),
Expand Down
4 changes: 2 additions & 2 deletions core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ class AppSettings(BaseSettings):
x402_compute_price: str = "$0.01"

# Storage encryption
encryption_master_key: str = "0" * 64 # 32-byte hex key, MUST change in prod # noqa: S105
encryption_master_key: str = "0" * 64 # 32-byte hex key, MUST change in prod

# CORS
cors_origins: list[str] = ["http://localhost:3000", "http://localhost:8000"]
Expand All @@ -100,7 +100,7 @@ def validate_production_secrets(self) -> "AppSettings":
if self.jwt_secret_key == _default_secret:
msg = "JWT_SECRET_KEY must be changed in production"
raise ValueError(msg)
_default_enc_key = "0" * 64 # noqa: S105
_default_enc_key = "0" * 64
if self.encryption_master_key == _default_enc_key:
msg = "ENCRYPTION_MASTER_KEY must be changed in production"
raise ValueError(msg)
Expand Down
2 changes: 2 additions & 0 deletions core/ledger/anchor/bundler_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
# Database access protocol
# ---------------------------------------------------------------------------


class EntryRepository(Protocol):
"""Minimal interface for accessing ledger entries.

Expand Down Expand Up @@ -76,6 +77,7 @@ async def save_anchor(
# Background loop
# ---------------------------------------------------------------------------


async def run_bundler_loop(
service: AnchoringService,
repo: EntryRepository,
Expand Down
30 changes: 8 additions & 22 deletions core/ledger/anchor/chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,15 +89,11 @@ def __init__(self, config: AnchorConfig) -> None:

abi_path = _ABI_PATH
if not abi_path.is_file():
raise FileNotFoundError(
f"LedgerAnchor ABI not found at {abi_path}"
)
raise FileNotFoundError(f"LedgerAnchor ABI not found at {abi_path}")
with abi_path.open() as fh:
abi = json.load(fh)

self._contract = self._w3.eth.contract(
address=config.contract_address, abi=abi
)
self._contract = self._w3.eth.contract(address=config.contract_address, abi=abi)

def submit_root(self, merkle_root_hex: str, entry_count: int) -> ChainReceipt:
"""Build, sign, and send an ``anchorRoot`` transaction."""
Expand All @@ -107,25 +103,17 @@ def submit_root(self, merkle_root_hex: str, entry_count: int) -> ChainReceipt:
# Pad to 32 bytes if necessary.
root_bytes = root_bytes.rjust(32, b"\x00")

tx = self._contract.functions.anchorRoot(
root_bytes, entry_count
).build_transaction(
tx = self._contract.functions.anchorRoot(root_bytes, entry_count).build_transaction(
{
"from": self._account.address,
"nonce": self._w3.eth.get_transaction_count(
self._account.address
),
"nonce": self._w3.eth.get_transaction_count(self._account.address),
"gas": 200_000,
"gasPrice": self._w3.eth.gas_price,
}
)

signed = self._w3.eth.account.sign_transaction(
tx, self._account.key
)
tx_hash = self._w3.eth.send_raw_transaction(
signed.rawTransaction
)
signed = self._w3.eth.account.sign_transaction(tx, self._account.key)
tx_hash = self._w3.eth.send_raw_transaction(signed.rawTransaction)
receipt = self._w3.eth.wait_for_transaction_receipt(tx_hash)

return ChainReceipt(
Expand Down Expand Up @@ -155,8 +143,7 @@ class NoOpChainClient:

def submit_root(self, merkle_root_hex: str, entry_count: int) -> ChainReceipt:
logger.info(
"NoOpChainClient.submit_root called "
"(root=%s, count=%d) -- skipping",
"NoOpChainClient.submit_root called (root=%s, count=%d) -- skipping",
merkle_root_hex[:16],
entry_count,
)
Expand All @@ -167,8 +154,7 @@ def submit_root(self, merkle_root_hex: str, entry_count: int) -> ChainReceipt:

def verify_root(self, tx_hash: str, expected_root_hex: str) -> ChainVerification:
logger.info(
"NoOpChainClient.verify_root called "
"(tx=%s, root=%s) -- returning verified=True",
"NoOpChainClient.verify_root called (tx=%s, root=%s) -- returning verified=True",
tx_hash[:16],
expected_root_hex[:16],
)
Expand Down
4 changes: 1 addition & 3 deletions core/ledger/anchor/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,5 @@ def from_env(cls) -> AnchorConfig:
contract_address=os.environ.get("ANCHOR_CONTRACT_ADDRESS", ""),
private_key=os.environ.get("ANCHOR_PRIVATE_KEY", ""),
batch_size=int(os.environ.get("ANCHOR_BATCH_SIZE", "100")),
interval_seconds=int(
os.environ.get("ANCHOR_INTERVAL_SECONDS", "300")
),
interval_seconds=int(os.environ.get("ANCHOR_INTERVAL_SECONDS", "300")),
)
9 changes: 3 additions & 6 deletions core/ledger/anchor/merkle.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
class StrEnum(str, Enum): # type: ignore[no-redef] # noqa: UP042
"""Polyfill for Python < 3.11."""


if TYPE_CHECKING:
from collections.abc import Sequence

Expand Down Expand Up @@ -87,9 +88,7 @@ def __init__(self, leaves: Sequence[str]) -> None:
if not leaves:
raise ValueError("Cannot build a Merkle tree from an empty list")
self._leaves: list[str] = list(leaves)
self._hashed_leaves: list[bytes] = [
self._hash_leaf(leaf) for leaf in self._leaves
]
self._hashed_leaves: list[bytes] = [self._hash_leaf(leaf) for leaf in self._leaves]
self._levels: list[list[bytes]] = self._build()

# ------------------------------------------------------------------
Expand Down Expand Up @@ -118,9 +117,7 @@ def get_proof(self, index: int) -> MerkleProof:
IndexError: If *index* is out of range.
"""
if index < 0 or index >= len(self._hashed_leaves):
raise IndexError(
f"Leaf index {index} out of range [0, {len(self._hashed_leaves)})"
)
raise IndexError(f"Leaf index {index} out of range [0, {len(self._hashed_leaves)})")

proof_hashes: list[bytes] = []
directions: list[Direction] = []
Expand Down
10 changes: 2 additions & 8 deletions core/ledger/anchor/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,7 @@ class AnchorRecord(Base):
)

def __repr__(self) -> str:
return (
f"<AnchorRecord id={self.id!s} "
f"tx_hash={self.tx_hash!r} "
f"entries={self.entry_count}>"
)
return f"<AnchorRecord id={self.id!s} tx_hash={self.tx_hash!r} entries={self.entry_count}>"


class CreditLedgerEntry(Base):
Expand Down Expand Up @@ -168,7 +164,5 @@ def hash_input(self) -> str:

def __repr__(self) -> str:
return (
f"<CreditLedgerEntry id={self.id} "
f"account={self.account_id!r} "
f"delta={self.delta_usd}>"
f"<CreditLedgerEntry id={self.id} account={self.account_id!r} delta={self.delta_usd}>"
)
12 changes: 3 additions & 9 deletions core/ledger/anchor/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,7 @@ def anchor_batch(
leaves = [entry.hash_input for entry in entries]
tree = MerkleTree(leaves)

receipt: ChainReceipt = self._chain.submit_root(
tree.root_hex, len(entries)
)
receipt: ChainReceipt = self._chain.submit_root(tree.root_hex, len(entries))

record = AnchorRecord(
id=uuid.uuid4(),
Expand Down Expand Up @@ -144,13 +142,9 @@ def get_proof(
leaves = [e.hash_input for e in all_entries]

try:
index = next(
i for i, e in enumerate(all_entries) if e.id == entry.id
)
index = next(i for i, e in enumerate(all_entries) if e.id == entry.id)
except StopIteration:
raise ValueError(
f"Entry id={entry.id} not found in the provided batch"
) from None
raise ValueError(f"Entry id={entry.id} not found in the provided batch") from None

tree = MerkleTree(leaves)
return tree.get_proof(index)
Expand Down
3 changes: 1 addition & 2 deletions core/ledger/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,7 @@ def __init__(self, user_id: uuid.UUID, requested: Decimal, available: Decimal) -
self.requested = requested
self.available = available
super().__init__(
f"Insufficient credits for user {user_id}: "
f"requested {requested}, available {available}"
f"Insufficient credits for user {user_id}: requested {requested}, available {available}"
)


Expand Down
4 changes: 1 addition & 3 deletions core/ledger/vault/token_rotator.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,6 @@ async def rotate(node_id: str):
JWT_SECRET,
algorithm="HS256",
)
VAULT.secrets.kv.v2.create_or_update_secret(
path=f"edge/{node_id}", secret={"token": token}
)
VAULT.secrets.kv.v2.create_or_update_secret(path=f"edge/{node_id}", secret={"token": token})
logger.info("edge_token_rotated", node_id=node_id)
return {"token": token}
8 changes: 2 additions & 6 deletions core/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,7 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Add OWASP-recommended security headers to all responses."""

async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
Expand All @@ -85,9 +83,7 @@ async def dispatch(
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
# HSTS only in production (requires HTTPS)
if get_settings().is_production:
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains"
)
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response


Expand Down
12 changes: 3 additions & 9 deletions core/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,9 +224,7 @@ class PrometheusMiddleware(BaseHTTPMiddleware):
counter and ``http_request_duration_seconds`` histogram.
"""

async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
# Skip /metrics itself to avoid self-referential noise
if request.url.path == "/metrics":
return await call_next(request)
Expand All @@ -240,12 +238,8 @@ async def dispatch(

status_code = str(response.status_code)

http_requests_total.labels(
method=method, endpoint=path, status_code=status_code
).inc()
http_request_duration_seconds.labels(
method=method, endpoint=path
).observe(duration)
http_requests_total.labels(method=method, endpoint=path, status_code=status_code).inc()
http_request_duration_seconds.labels(method=method, endpoint=path).observe(duration)

return response

Expand Down
4 changes: 1 addition & 3 deletions core/middleware/request_id.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,7 @@ class RequestIdMiddleware(BaseHTTPMiddleware):
4. Sets the X-Request-ID response header for client correlation.
"""

async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
# Use client-provided ID if present, otherwise generate
request_id = request.headers.get(_REQUEST_ID_HEADER) or str(uuid.uuid4())

Expand Down
Loading
Loading