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
154 changes: 143 additions & 11 deletions node/bcos_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,21 @@ def _get_admin_key():
return os.environ.get("RC_ADMIN_KEY", "")


def _node_epoch_slot():
"""Current (epoch, slot) from the node's own /epoch endpoint; (0, 0) on
error. The main module is not importable (its filename contains dots) and
rip_200_round_robin_1cpu1vote has no current_slot, so we ask the running
node over localhost rather than duplicate its genesis/time constants."""
try:
import urllib.request
with urllib.request.urlopen(
"http://127.0.0.1:8099/epoch", timeout=5) as r:
ej = json.loads(r.read().decode())
return int(ej.get("epoch", 0)), int(ej.get("slot", 0))
except Exception:
return 0, 0


def _bcos_public_url(path: str) -> str:
"""Build certificate-valid public BCOS URLs for API responses."""
base_url = (
Expand Down Expand Up @@ -176,7 +191,9 @@ def init_bcos_table(conn):
report_json TEXT NOT NULL,
signature TEXT,
signer_pubkey TEXT,
attested_epoch INTEGER,
anchored_epoch INTEGER,
anchor_tx TEXT,
created_at INTEGER NOT NULL
)
""")
Expand Down Expand Up @@ -333,25 +350,22 @@ def bcos_attest():
now = int(time.time())
try:
with sqlite3.connect(_DB_PATH) as conn:
# Calculate current epoch for anchoring
epoch = None
try:
from rip_200_round_robin_1cpu1vote import current_slot
epoch = current_slot()
except Exception:
pass
# Record the epoch this attestation was created in. anchored_epoch
# stays NULL here: it is set only when the cert is written to the
# ledger via /api/v1/bcos/anchor.
attested_epoch, _slot = _node_epoch_slot()

conn.execute("""
INSERT OR REPLACE INTO bcos_attestations
(cert_id, commitment, repo, commit_sha, tier, trust_score,
reviewer, report_json, signature, signer_pubkey,
anchored_epoch, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
attested_epoch, anchored_epoch, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
cert_id, commitment, repo, commit_sha, tier, trust_score,
reviewer, report_json_str,
signature or None, signer_pubkey or None,
epoch, now,
attested_epoch, None, now,
))
conn.commit()

Expand All @@ -362,7 +376,8 @@ def bcos_attest():
"repo": repo,
"tier": tier,
"trust_score": trust_score,
"anchored_epoch": epoch,
"attested_epoch": attested_epoch,
"anchored_epoch": None,
"verify_url": _bcos_public_url(f"/bcos/verify/{cert_id}"),
"badge_url": _bcos_public_url(f"/bcos/badge/{cert_id}.svg"),
})
Expand Down Expand Up @@ -421,7 +436,9 @@ def bcos_verify(cert_id):
"trust_score": row["trust_score"],
"tier_met": row["trust_score"] >= {"L0": 40, "L1": 60, "L2": 80}.get(row["tier"], 60),
"reviewer": row["reviewer"],
"attested_epoch": (row["attested_epoch"] if "attested_epoch" in row.keys() else None),
"anchored_epoch": row["anchored_epoch"],
"anchor_tx": (row["anchor_tx"] if "anchor_tx" in row.keys() else None),
"created_at": row["created_at"],
"score_breakdown": report.get("score_breakdown", {}),
"checks": report.get("checks", {}),
Expand Down Expand Up @@ -574,10 +591,125 @@ def bcos_directory():
return jsonify({"error": "internal_error"}), 500


@bcos_bp.route("/api/v1/bcos/anchor", methods=["POST"])
def bcos_anchor():
"""Anchor an existing BCOS attestation to the RustChain ledger.

Records the certificate commitment as a zero-value memo entry in the
RustChain ledger (the chain's transaction history the explorer reads) and
stamps the attestation with the resulting tx hash and epoch. No RTC moves;
delta is zero. Admin-gated, and idempotent: a cert that is already anchored
returns its existing tx hash rather than writing a second ledger row.
"""
admin_key = request.headers.get("X-Admin-Key", "")
if not (admin_key and hmac.compare_digest(admin_key, _get_admin_key() or "")):
return jsonify({
"error": "unauthorized",
"hint": "X-Admin-Key header matching RC_ADMIN_KEY required",
}), 401

data = request.get_json(silent=True) or {}
cert_id = data.get("cert_id", "")
if not cert_id or not isinstance(cert_id, str) or len(cert_id) > MAX_CERT_ID_LENGTH:
return jsonify({"error": "cert_id required"}), 400

try:
with sqlite3.connect(_DB_PATH) as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT cert_id, commitment, repo, commit_sha, tier, trust_score, "
"anchored_epoch, anchor_tx FROM bcos_attestations WHERE cert_id = ?",
(cert_id,),
).fetchone()
if not row:
return jsonify({"error": "unknown_cert", "cert_id": cert_id}), 404

if row["anchor_tx"]:
return jsonify({
"ok": True,
"already_anchored": True,
"cert_id": cert_id,
"commitment": row["commitment"],
"tx_hash": row["anchor_tx"],
"anchored_epoch": row["anchored_epoch"],
"ledger": "rustchain",
})

commitment = row["commitment"]
repo = row["repo"]
now = int(time.time())
# Authoritative epoch/slot from the node's own /epoch computation.
epoch, slot = _node_epoch_slot()

reason = json.dumps({
"kind": "bcos_anchor",
"cert_id": cert_id,
"commitment": commitment,
"repo": repo,
"commit_sha": row["commit_sha"],
"tier": row["tier"],
"trust_score": row["trust_score"],
"epoch": epoch,
}, separators=(",", ":"))
tx_hash = blake2b(
("bcos_anchor|" + cert_id + "|" + commitment + "|"
+ str(epoch) + "|" + str(now)).encode(),
digest_size=32,
).hexdigest()

# Zero-value memo entry: records the anchor in the ledger without
# moving RTC or affecting any balance (delta_i64 = 0). The ledger
# epoch column stores the slot, matching the other ledger rows.
conn.execute(
"INSERT INTO ledger (ts, epoch, miner_id, delta_i64, reason) "
"VALUES (?, ?, ?, ?, ?)",
(now, slot, "bcos_registry", 0, reason),
)
conn.execute(
"UPDATE bcos_attestations SET anchored_epoch = ?, anchor_tx = ? "
"WHERE cert_id = ?",
(epoch, tx_hash, cert_id),
)
conn.commit()

return jsonify({
"ok": True,
"cert_id": cert_id,
"commitment": commitment,
"repo": repo,
"tx_hash": tx_hash,
"anchored_epoch": epoch,
"amount_rtc": 0,
"ledger": "rustchain",
"verify_url": _bcos_public_url(f"/bcos/verify/{cert_id}"),
})
except Exception:
import logging
logging.exception("bcos_anchor failed")
return jsonify({"error": "internal_error"}), 500


# ── Registration ──────────────────────────────────────────────────

def _ensure_columns(db_path: str):
"""Add newer bcos_attestations columns if an existing table predates them.
Each ALTER is idempotent: a duplicate-column error (or a not-yet-created
table, which init_bcos_table handles) is ignored."""
for ddl in (
"ALTER TABLE bcos_attestations ADD COLUMN attested_epoch INTEGER",
"ALTER TABLE bcos_attestations ADD COLUMN anchor_tx TEXT",
):
try:
with sqlite3.connect(db_path) as conn:
conn.execute(ddl)
conn.commit()
except sqlite3.OperationalError:
pass


def register_bcos_routes(app, db_path: str):
"""Register BCOS blueprint with the Flask app."""
global _DB_PATH
_DB_PATH = db_path
_ensure_columns(db_path)
app.register_blueprint(bcos_bp)
18 changes: 18 additions & 0 deletions site/nginx-rustchain-org.conf
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,24 @@ server {



# BCOS v2 attestation registry (verify, attest, directory, cert, badge).
# ^~ so the badge SVG is proxied here and not captured by the static
# "\.svg$" regex location above (regex locations outrank plain prefixes).
# Without this block the routes 404 on rustchain.org even though the node
# serves them, because the catch-all below falls through to try_files.
location ^~ /bcos/ {
proxy_pass http://127.0.0.1:8099;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}

# BCOS on-chain anchor API (/api/v1/bcos/anchor and friends).
location /api/v1/bcos/ {
proxy_pass http://127.0.0.1:8099;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}

location / {
try_files $uri $uri/ =404;
}
Expand Down
Loading
Loading