Skip to content
This repository was archived by the owner on Nov 5, 2025. It is now read-only.

Commit 276857a

Browse files
committed
Merge branch 'main' of github-study:one-zero-eight/hackathon-random-numbers
2 parents 77e36a1 + a2a131c commit 276857a

File tree

12 files changed

+1056
-2
lines changed

12 files changed

+1056
-2
lines changed

backend/contract.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
#!/usr/bin/env python3
2+
import argparse
3+
import base64
4+
import json
5+
import os
6+
7+
import requests
8+
import rfc8785
9+
from dotenv import load_dotenv
10+
from web3 import Web3
11+
12+
ABI = [
13+
{
14+
"anonymous": False,
15+
"inputs": [
16+
{"indexed": True, "internalType": "bytes32", "name": "payloadHash", "type": "bytes32"},
17+
{"indexed": False, "internalType": "string", "name": "cid", "type": "string"},
18+
{"indexed": False, "internalType": "string", "name": "kid", "type": "string"},
19+
{"indexed": True, "internalType": "address", "name": "sender", "type": "address"},
20+
{"indexed": False, "internalType": "uint256", "name": "ts", "type": "uint256"},
21+
],
22+
"name": "GenerationPublished",
23+
"type": "event",
24+
},
25+
{
26+
"inputs": [
27+
{"internalType": "bytes32", "name": "h", "type": "bytes32"},
28+
{"internalType": "string", "name": "cid", "type": "string"},
29+
{"internalType": "string", "name": "kid", "type": "string"},
30+
],
31+
"name": "publish",
32+
"outputs": [],
33+
"stateMutability": "nonpayable",
34+
"type": "function",
35+
},
36+
{
37+
"inputs": [{"internalType": "bytes32", "name": "", "type": "bytes32"}],
38+
"name": "records",
39+
"outputs": [
40+
{"internalType": "bytes32", "name": "payloadHash", "type": "bytes32"},
41+
{"internalType": "string", "name": "cid", "type": "string"},
42+
{"internalType": "string", "name": "kid", "type": "string"},
43+
{"internalType": "uint256", "name": "ts", "type": "uint256"},
44+
{"internalType": "address", "name": "sender", "type": "address"},
45+
],
46+
"stateMutability": "view",
47+
"type": "function",
48+
},
49+
{
50+
"inputs": [{"internalType": "bytes32", "name": "h", "type": "bytes32"}],
51+
"name": "exists",
52+
"outputs": [{"internalType": "bool", "name": "", "type": "bool"}],
53+
"stateMutability": "view",
54+
"type": "function",
55+
},
56+
]
57+
58+
59+
def b64u_dec(s: str) -> bytes:
60+
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
61+
62+
63+
def pin_to_pinata(jwt: str, record: dict) -> str:
64+
r = requests.post(
65+
"https://api.pinata.cloud/pinning/pinJSONToIPFS",
66+
headers={"Authorization": f"Bearer {jwt}", "Content-Type": "application/json"},
67+
data=json.dumps(record, ensure_ascii=False, separators=(",", ":")).encode(),
68+
timeout=60,
69+
)
70+
r.raise_for_status()
71+
return r.json()["IpfsHash"] # CID
72+
73+
74+
def publish_to_chain(
75+
rpc_url: str, registry_addr: str, chain_id: int, private_key: str, h_bytes32: bytes, cid: str, kid: str
76+
) -> str | None:
77+
w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": 60}))
78+
c = w3.eth.contract(address=Web3.to_checksum_address(registry_addr), abi=ABI)
79+
80+
if c.functions.exists(h_bytes32).call():
81+
return None
82+
83+
acct = w3.eth.account.from_key(private_key)
84+
tx = c.functions.publish(h_bytes32, cid, kid).build_transaction(
85+
{
86+
"from": acct.address,
87+
"nonce": w3.eth.get_transaction_count(acct.address),
88+
"chainId": chain_id,
89+
}
90+
)
91+
signed = acct.sign_transaction(tx)
92+
return w3.eth.send_raw_transaction(signed.raw_transaction).hex()
93+
94+
95+
def main():
96+
load_dotenv()
97+
ap = argparse.ArgumentParser(description="Pin RNG record to IPFS (Pinata) and publish to RNGRegistry")
98+
ap.add_argument("--footprint", required=True, help="full ed25519.<kid>.<b64u(payload)>.{sig}")
99+
ap.add_argument("--result", help="optional JSON string for 'result', e.g. '[85]'", default=None)
100+
args = ap.parse_args()
101+
102+
# Parse footprint -> payload, kid, sig
103+
alg, kid, p64, s64 = args.footprint.split(".", 3)
104+
assert alg.lower() == "ed25519", "only Ed25519 supported"
105+
payload = json.loads(b64u_dec(p64))
106+
payload_dumped = rfc8785.dumps(payload) # canonical
107+
h_bytes32 = Web3.keccak(payload_dumped) # bytes32
108+
h_hex = h_bytes32.hex()
109+
110+
# Build record for IPFS
111+
record = {
112+
"schema": "gen.v1",
113+
"footprint": args.footprint,
114+
"payload": json.loads(payload_dumped), # canonical object
115+
"sig_alg": "Ed25519",
116+
"kid": kid,
117+
"sig_b64u": s64,
118+
}
119+
if args.result:
120+
record["result"] = json.loads(args.result)
121+
122+
# Pinata
123+
pinata_jwt = os.environ["PINATA_JWT"]
124+
cid = pin_to_pinata(pinata_jwt, record)
125+
126+
# Publish on-chain
127+
rpc = os.environ["RPC_URL"]
128+
addr = os.environ["REGISTRY_ADDR"]
129+
chain = int(os.getenv("CHAIN_ID", "84532"))
130+
pk = os.environ["PRIVATE_KEY"]
131+
tx_hash = publish_to_chain(rpc, addr, chain, pk, h_bytes32, cid, kid)
132+
133+
explorer = "https://sepolia.basescan.org" if chain == 84532 else "https://basescan.org"
134+
135+
print("payloadHash:", h_hex)
136+
print("cid:", cid)
137+
print("kid:", kid)
138+
139+
if tx_hash:
140+
print("tx:", tx_hash)
141+
print("tx_url:", f"{explorer}/tx/0x{tx_hash}#eventlog")
142+
else:
143+
print("Record already exists on-chain (skipped)")
144+
145+
print("gateway_url:", f"https://ipfs.io/ipfs/{cid}")
146+
print("contract_url:", f"{explorer}/address/{addr}")
147+
print("events_url:", f"{explorer}/address/{addr}#events")
148+
149+
150+
if __name__ == "__main__":
151+
main()

backend/contract.sol

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.24;
3+
4+
/// @title RNGRegistry — on-chain index of off-chain signed RNG payloads
5+
/// @notice Store keccak256(payload_dumped) + CID + KID with a timestamp. Ed25519 is verified off-chain.
6+
contract RNGRegistry {
7+
struct Record {
8+
bytes32 payloadHash; // keccak256(payload_dumped)
9+
string cid; // ipfs://CID or ar://TXID (store raw id, not the scheme)
10+
string kid; // key identifier for Ed25519 public key lookup
11+
uint256 ts; // block.timestamp of publication
12+
address sender; // msg.sender who published
13+
}
14+
15+
/// @dev Emitted on each successful publish
16+
event GenerationPublished(
17+
bytes32 indexed payloadHash,
18+
string cid,
19+
string kid,
20+
address indexed sender,
21+
uint256 ts
22+
);
23+
24+
/// @notice Optional storage mirror for easy RPC reads; key = payloadHash
25+
mapping(bytes32 => Record) public records;
26+
27+
/// @notice Publish a single RNG record
28+
/// @param h keccak256(payload_dumped) as bytes32
29+
/// @param cid IPFS CID (or Arweave TXID) as string
30+
/// @param kid Key ID to resolve Ed25519 public key off-chain
31+
function publish(bytes32 h, string calldata cid, string calldata kid) external {
32+
require(records[h].ts == 0, "exists");
33+
records[h] = Record({
34+
payloadHash: h,
35+
cid: cid,
36+
kid: kid,
37+
ts: block.timestamp,
38+
sender: msg.sender
39+
});
40+
emit GenerationPublished(h, cid, kid, msg.sender, block.timestamp);
41+
}
42+
43+
/// @notice Publish multiple RNG records in one transaction
44+
function publishBatch(
45+
bytes32[] calldata hashes,
46+
string[] calldata cids,
47+
string[] calldata kids
48+
) external {
49+
uint256 n = hashes.length;
50+
require(cids.length == n && kids.length == n, "len mismatch");
51+
for (uint256 i = 0; i < n; i++) {
52+
bytes32 h = hashes[i];
53+
require(records[h].ts == 0, "exists");
54+
records[h] = Record({
55+
payloadHash: h,
56+
cid: cids[i],
57+
kid: kids[i],
58+
ts: block.timestamp,
59+
sender: msg.sender
60+
});
61+
emit GenerationPublished(h, cids[i], kids[i], msg.sender, block.timestamp);
62+
}
63+
}
64+
65+
/// @notice Check existence without reading the full struct
66+
function exists(bytes32 h) external view returns (bool) {
67+
return records[h].ts != 0;
68+
}
69+
}

backend/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ dependencies = [
3535
"rfc8785>=0.1.4",
3636
"av>=16.0.1",
3737
"pillow>=12.0.0",
38+
"web3>=7.14.0",
3839
]
3940

4041
[dependency-groups]

backend/settings.example.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,9 @@ signature_secret_key: |
1616
-----END OPENSSH PRIVATE KEY-----
1717
signature_public_key: |
1818
ssh-ed25519 XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX key-2025-10
19+
# Blockchain and IPFS settings (optional, for publishing to smart contract)
20+
pinata_jwt: null # JWT token for Pinata IPFS service,
21+
rpc_url: https://sepolia.base.org # Blockchain RPC URL (e.g., Base Sepolia Testnet - https://sepolia.base.org)
22+
registry_address: "0xaEa80F09eF923257c97E5cbDF97b42075D7D7bbB" # RNGRegistry smart contract address
23+
chain_id: 84532 # Base Sepolia Testnet
24+
blockchain_private_key: null # Private key for blockchain transactions

backend/settings.schema.yaml

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ properties:
7070
type: string
7171
writeOnly: true
7272
signature_public_key:
73-
description: Public key for verifying API responses, use `` to generate
73+
description: Public key for verifying API responses
7474
format: password
7575
title: Signature Public Key
7676
type: string
@@ -80,6 +80,54 @@ properties:
8080
description: Key ID for signing API responses
8181
title: Signature Kid
8282
type: string
83+
pinata_jwt:
84+
anyOf:
85+
- format: password
86+
type: string
87+
writeOnly: true
88+
- type: 'null'
89+
default: null
90+
description: JWT token for Pinata IPFS service
91+
title: Pinata Jwt
92+
rpc_url:
93+
anyOf:
94+
- type: string
95+
- type: 'null'
96+
default: null
97+
description: Blockchain RPC URL (e.g., Base Sepolia)
98+
examples:
99+
- https://sepolia.base.org
100+
- https://base.org
101+
title: Rpc Url
102+
registry_address:
103+
anyOf:
104+
- type: string
105+
- type: 'null'
106+
default: null
107+
description: RNGRegistry smart contract address
108+
examples:
109+
- '0x0000000000000000000000000000000000000000'
110+
title: Registry Address
111+
chain_id:
112+
default: 84532
113+
description: Blockchain chain ID (84532 for Base Sepolia Testnet, 8453 for Base
114+
Mainnet)
115+
examples:
116+
- 84532
117+
- 8453
118+
title: Chain Id
119+
type: integer
120+
blockchain_private_key:
121+
anyOf:
122+
- format: password
123+
type: string
124+
writeOnly: true
125+
- type: 'null'
126+
default: null
127+
description: Private key for blockchain transactions
128+
examples:
129+
- badsaubtdsaubt
130+
title: Blockchain Private Key
83131
required:
84132
- session_secret_key
85133
- entropy_secret_key

backend/src/config_schema.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ class SettingBaseModel(BaseModel):
1616

1717
class Settings(SettingBaseModel):
1818
"""Settings for the application."""
19-
19+
2020
schema_: str | None = Field(None, alias="$schema")
2121
environment: Environment = Environment.DEVELOPMENT
2222
"App environment flag"
@@ -47,6 +47,16 @@ class Settings(SettingBaseModel):
4747
"Public key for verifying API responses"
4848
signature_kid: str = "key-2025-10"
4949
"Key ID for signing API responses"
50+
pinata_jwt: SecretStr | None = None
51+
"JWT token for Pinata IPFS service"
52+
rpc_url: str | None = Field(None, examples=["https://sepolia.base.org", "https://base.org"])
53+
"Blockchain RPC URL (e.g., Base Sepolia)"
54+
registry_address: str | None = Field(None, examples=["0x0000000000000000000000000000000000000000"])
55+
"RNGRegistry smart contract address"
56+
chain_id: int = Field(84532, examples=[84532, 8453])
57+
"Blockchain chain ID (84532 for Base Sepolia Testnet, 8453 for Base Mainnet)"
58+
blockchain_private_key: SecretStr | None = Field(None, examples=["badsaubtdsaubt"])
59+
"Private key for blockchain transactions"
5060

5161
@classmethod
5262
def from_yaml(cls, path: Path) -> "Settings":

backend/src/modules/blockchain/__init__.py

Whitespace-only changes.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from pydantic import BaseModel
2+
3+
4+
class Web3PublishResult(BaseModel):
5+
payload_hash: str
6+
"Keccak256 hash of the canonical payload"
7+
cid: str
8+
"IPFS CID where the record is pinned"
9+
kid: str
10+
"Key ID used for signing"
11+
tx_hash: str | None = None
12+
"Transaction hash if published to blockchain (None if already exists)"
13+
gateway_url: str
14+
"IPFS gateway URL to access the record"
15+
tx_url: str | None = None
16+
"Blockchain explorer transaction URL"
17+
contract_url: str
18+
"Blockchain explorer contract URL"

0 commit comments

Comments
 (0)