Skip to content

Commit c13354b

Browse files
feat(oracle): implement commit-reveal integration
- Add submitCommitment and submitReveal to TxSubmitterService - Activate TODOs in CommitRevealWorker (processCommit/processReveal) - Wire RaffleCreated → commit and DrawTriggered → reveal in EventListenerService - Rewrite COMMIT_REVEAL.md with contract interface, event flow, and config docs
1 parent 2e29fe8 commit c13354b

4 files changed

Lines changed: 192 additions & 81 deletions

File tree

oracle/COMMIT_REVEAL.md

Lines changed: 64 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -2,96 +2,93 @@
22

33
## Overview
44

5-
Commit-reveal prevents oracle front-running by requiring the oracle to commit to randomness before the raffle ends, then reveal after draw is triggered.
5+
Commit-reveal prevents oracle front-running by requiring the oracle to commit to randomness before the raffle ends, then reveal after the draw is triggered. The contract verifies `SHA-256(secret || nonce) == stored_commitment`.
66

7-
## How It Works
7+
## Event Flow
88

9-
### Commit Phase (Before end_time)
10-
1. Oracle generates random `secret` and `nonce`
11-
2. Computes `commitment = SHA-256(secret || nonce)`
12-
3. Submits commitment to contract via `commit_randomness(raffleId, commitment)`
13-
4. Stores secret/nonce locally for later reveal
14-
15-
### Reveal Phase (After draw triggered)
16-
1. Oracle retrieves stored secret/nonce
17-
2. Submits to contract via `reveal_randomness(raffleId, secret, nonce)`
18-
3. Contract verifies: `SHA-256(secret || nonce) == stored_commitment`
19-
4. If valid, uses secret as randomness seed for winner selection
20-
21-
## Security Properties
22-
23-
- **Front-running prevention**: Oracle cannot observe ticket purchases after committing
24-
- **Unpredictability**: Secret is cryptographically random
25-
- **Verifiability**: Anyone can verify the reveal matches the commitment
26-
- **Non-manipulability**: Oracle cannot change commitment after submission
27-
28-
## Usage
29-
30-
```typescript
31-
// Commit phase (before raffle ends)
32-
await commitRevealWorker.processCommit({
33-
raffleId: 1,
34-
endTime: Date.now() + 86400000,
35-
});
36-
37-
// Reveal phase (after draw triggered)
38-
await commitRevealWorker.processReveal({
39-
raffleId: 1,
40-
requestId: 'req-123',
41-
});
9+
```
10+
RaffleCreated event
11+
12+
CommitRevealWorker.processCommit()
13+
→ CommitmentService.commit(raffleId) — generates secret + nonce, stores locally
14+
→ TxSubmitterService.submitCommitment() — calls commit_randomness(raffle_id, commitment)
15+
16+
DrawTriggered event
17+
18+
CommitRevealWorker.processReveal()
19+
→ CommitmentService.reveal(raffleId) — retrieves stored secret + nonce
20+
→ TxSubmitterService.submitReveal() — calls reveal_randomness(raffle_id, secret, nonce)
21+
→ CommitmentService.clearCommitment() — removes local state
4222
```
4323

44-
## Contract Interface
24+
## Contract Interface (Required)
4525

46-
The contract must implement:
26+
The Soroban contract must implement both functions:
4727

4828
```rust
49-
// Commit phase
29+
/// Commit phase — oracle submits hash before raffle ends
5030
pub fn commit_randomness(env: Env, raffle_id: u32, commitment: BytesN<32>)
5131

52-
// Reveal phase
32+
/// Reveal phase — oracle reveals preimage after draw is triggered
5333
pub fn reveal_randomness(env: Env, raffle_id: u32, secret: BytesN<32>, nonce: BytesN<16>)
5434
```
5535

56-
Contract verification:
36+
Contract verification logic:
5737
```rust
58-
let computed = sha256(secret || nonce);
59-
assert_eq!(computed, stored_commitment);
38+
let computed: BytesN<32> = env.crypto().sha256(&Bytes::from_slice(&env, &[secret.as_ref(), nonce.as_ref()].concat()));
39+
assert_eq!(computed, stored_commitment, "commitment mismatch");
6040
```
6141

62-
## Implementation Status
42+
For low-stakes raffles that use single-shot randomness, the contract must also implement:
43+
```rust
44+
pub fn receive_randomness(env: Env, raffle_id: u32, seed: BytesN<32>, proof: BytesN<64>)
45+
```
6346

64-
✅ CommitmentService - Generate and store commitments
65-
✅ CommitRevealWorker - Process commit and reveal phases
66-
✅ Unit tests for commitment verification
67-
⏳ Contract integration (waiting for contract support)
68-
⏳ TxSubmitter methods for commit/reveal transactions
47+
## Oracle Events Consumed
6948

70-
## Configuration
49+
| Event name | Trigger | Oracle action |
50+
|---|---|---|
51+
| `RaffleCreated` | New raffle created | `commit_randomness` |
52+
| `DrawTriggered` | Draw initiated | `reveal_randomness` |
53+
| `RandomnessRequested` | Low-stakes draw | `receive_randomness` (direct) |
7154

72-
Set threshold for commit-reveal vs direct randomness:
55+
Event payload (XDR map):
56+
- `RaffleCreated`: `{ raffle_id: u32, end_time: u64 }`
57+
- `DrawTriggered`: `{ raffle_id: u32, request_id: string|u64|bytes }`
58+
- `RandomnessRequested`: `{ raffle_id: u32, request_id: string|u64|bytes }`
7359

74-
```typescript
75-
// In worker configuration
76-
const USE_COMMIT_REVEAL = prizeAmount >= 500; // XLM
60+
## Security Properties
7761

78-
if (USE_COMMIT_REVEAL) {
79-
await commitRevealWorker.processCommit(...);
80-
// Later...
81-
await commitRevealWorker.processReveal(...);
82-
} else {
83-
await randomnessWorker.processRequest(...);
84-
}
85-
```
62+
- **Front-running prevention**: Oracle commits before ticket sales close; cannot observe final ticket set before choosing randomness
63+
- **Unpredictability**: Secret is 32 cryptographically random bytes
64+
- **Verifiability**: Anyone can verify `SHA-256(secret || nonce) == commitment`
65+
- **Non-manipulability**: Commitment is on-chain before the draw; oracle cannot change it
8666

87-
## Testing
67+
## Configuration
8868

89-
```bash
90-
npm test commitment.service.spec.ts
69+
```env
70+
# Enable commit-reveal for high-stakes raffles (prize >= threshold)
71+
COMMIT_REVEAL_ENABLED=true # default: true when contract supports it
72+
HIGH_STAKES_THRESHOLD_XLM=500 # raffles above this use commit-reveal + VRF
9173
```
9274

75+
Low-stakes raffles (prize < threshold) skip commit-reveal and call `receive_randomness` directly via `RandomnessWorker`.
76+
77+
## Implementation Status
78+
79+
| Component | Status |
80+
|---|---|
81+
| `CommitmentService` — generate/store/verify commitments ||
82+
| `CommitRevealWorker` — processCommit / processReveal ||
83+
| `TxSubmitterService.submitCommitment` ||
84+
| `TxSubmitterService.submitReveal` ||
85+
| `EventListenerService` — `RaffleCreated` → commit ||
86+
| `EventListenerService` — `DrawTriggered` → reveal ||
87+
| Contract `commit_randomness` / `reveal_randomness` |Pending contract deployment |
88+
| Persistent commitment storage (Redis/DB) |Currently in-memory |
89+
9390
## Notes
9491

95-
- Commitment storage is in-memory; consider persistent storage for production
96-
- Ensure commit happens before end_time to prevent front-running
97-
- Monitor for failed reveals and implement retry logic
92+
- Commitment storage is in-memory. A process restart before reveal will lose pending commitments. For production, persist to Redis or a database keyed by `raffle_id`.
93+
- Ensure `commit_randomness` is called before `end_time` to prevent front-running.
94+
- If a reveal fails after max retries, the job is retained in the Bull dead-letter queue and an `[ALERT]` log is emitted.

oracle/src/listener/event-listener.service.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
33
import * as StellarSdk from 'stellar-sdk';
44
import { Subject, Subscription } from 'rxjs';
55
import { RandomnessWorker } from '../queue/randomness.worker';
6+
import { CommitRevealWorker } from '../queue/commit-reveal.worker';
67
import { RandomnessRequest } from '../queue/queue.types';
78
import { HealthService } from '../health/health.service';
89
import { LagMonitorService } from '../health/lag-monitor.service';
@@ -31,6 +32,7 @@ export class EventListenerService implements OnModuleInit, OnModuleDestroy {
3132
private readonly healthService: HealthService,
3233
private readonly lagMonitor: LagMonitorService,
3334
private readonly randomnessWorker: RandomnessWorker,
35+
private readonly commitRevealWorker: CommitRevealWorker,
3436
@Optional() @InjectQueue(RANDOMNESS_QUEUE) private readonly randomnessQueue?: Queue<RandomnessJobPayload>,
3537
) {
3638
// Config parsing
@@ -118,6 +120,48 @@ export class EventListenerService implements OnModuleInit, OnModuleDestroy {
118120
if (primaryTopic.switch() === StellarSdk.xdr.ScValType.scvSymbol()) {
119121
const eventName = primaryTopic.sym().toString();
120122

123+
if (eventName === 'RaffleCreated') {
124+
const scVal = (eventXdr as any).body().v0().data();
125+
let raffleId: number | undefined;
126+
let endTime: number | undefined;
127+
128+
if (scVal.switch() === StellarSdk.xdr.ScValType.scvMap()) {
129+
for (const entry of scVal.map() ?? []) {
130+
const key = entry.key().sym().toString();
131+
if (key === 'raffle_id') raffleId = entry.val().u32();
132+
else if (key === 'end_time') endTime = Number(entry.val().u64().toString());
133+
}
134+
}
135+
136+
if (raffleId !== undefined) {
137+
this.logger.log(`RaffleCreated: raffle=${raffleId}, scheduling commit`);
138+
this.commitRevealWorker.processCommit({ raffleId, endTime: endTime ?? 0 }).catch(err =>
139+
this.logger.error(`Commit failed for raffle ${raffleId}: ${err.message}`),
140+
);
141+
}
142+
}
143+
144+
if (eventName === 'DrawTriggered') {
145+
const scVal = (eventXdr as any).body().v0().data();
146+
let raffleId: number | undefined;
147+
let requestId: string | undefined;
148+
149+
if (scVal.switch() === StellarSdk.xdr.ScValType.scvMap()) {
150+
for (const entry of scVal.map() ?? []) {
151+
const key = entry.key().sym().toString();
152+
if (key === 'raffle_id') raffleId = entry.val().u32();
153+
else if (key === 'request_id') requestId = this.parseRequestId(entry.val());
154+
}
155+
}
156+
157+
if (raffleId !== undefined && requestId !== undefined) {
158+
this.logger.log(`DrawTriggered: raffle=${raffleId}, scheduling reveal`);
159+
this.commitRevealWorker.processReveal({ raffleId, requestId }).catch(err =>
160+
this.logger.error(`Reveal failed for raffle ${raffleId}: ${err.message}`),
161+
);
162+
}
163+
}
164+
121165
if (eventName === 'RandomnessRequested') {
122166
this.logger.log(`Received RandomnessRequested event for contract ${this.raffleContractId}`);
123167

oracle/src/queue/commit-reveal.worker.ts

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,9 @@ export class CommitRevealWorker {
3333
this.logger.log(`Processing commit for raffle ${raffleId}`);
3434

3535
try {
36-
// Generate commitment
3736
const commitment = this.commitmentService.commit(raffleId);
38-
39-
// Submit commitment to contract
40-
// TODO: Implement contract call to commit_randomness(raffleId, commitment)
4137
this.logger.log(`Commitment for raffle ${raffleId}: ${commitment}`);
42-
43-
// When contract supports commit_randomness:
44-
// await this.txSubmitter.submitCommitment(raffleId, commitment);
38+
await this.txSubmitter.submitCommitment(raffleId, commitment);
4539

4640
} catch (error) {
4741
this.logger.error(
@@ -70,14 +64,8 @@ export class CommitRevealWorker {
7064
}
7165

7266
const { secret, nonce } = reveal;
73-
74-
// Submit reveal to contract
75-
// Contract will verify: SHA-256(secret || nonce) == stored commitment
76-
// TODO: Implement contract call to reveal_randomness(raffleId, secret, nonce)
7767
this.logger.log(`Revealing for raffle ${raffleId}`);
78-
79-
// When contract supports reveal_randomness:
80-
// await this.txSubmitter.submitReveal(raffleId, secret, nonce);
68+
await this.txSubmitter.submitReveal(raffleId, secret, nonce);
8169

8270
// Clear commitment after successful reveal
8371
this.commitmentService.clearCommitment(raffleId);

oracle/src/submitter/tx-submitter.service.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,88 @@ export class TxSubmitterService {
7575
);
7676
}
7777

78+
async submitCommitment(raffleId: number, commitment: string): Promise<SubmitResult> {
79+
if (!this.contractId || !this.oracleSecret) {
80+
this.logger.error('Missing configuration for TxSubmitter.');
81+
return { txHash: '', ledger: 0, success: false };
82+
}
83+
const kp = (StellarSdk as any).Keypair.fromSecret(this.oracleSecret);
84+
return this.submitContractCall(kp, 'commit_randomness', [
85+
(StellarSdk as any).xdr.ScVal.scvU32(raffleId >>> 0),
86+
(StellarSdk as any).xdr.ScVal.scvBytes(this.parseToBytes(commitment, 32)),
87+
]);
88+
}
89+
90+
async submitReveal(raffleId: number, secret: string, nonce: string): Promise<SubmitResult> {
91+
if (!this.contractId || !this.oracleSecret) {
92+
this.logger.error('Missing configuration for TxSubmitter.');
93+
return { txHash: '', ledger: 0, success: false };
94+
}
95+
const kp = (StellarSdk as any).Keypair.fromSecret(this.oracleSecret);
96+
return this.submitContractCall(kp, 'reveal_randomness', [
97+
(StellarSdk as any).xdr.ScVal.scvU32(raffleId >>> 0),
98+
(StellarSdk as any).xdr.ScVal.scvBytes(this.parseToBytes(secret, 32)),
99+
(StellarSdk as any).xdr.ScVal.scvBytes(this.parseToBytes(nonce, 16)),
100+
]);
101+
}
102+
103+
private async submitContractCall(kp: any, method: string, args: any[]): Promise<SubmitResult> {
104+
const publicKey = kp.publicKey();
105+
let feeBump = 1;
106+
let attempt = 0;
107+
let lastError: any = null;
108+
109+
while (attempt < this.MAX_RETRIES) {
110+
attempt++;
111+
try {
112+
const account = await this.rpcServer.getAccount(publicKey);
113+
const fee = (Number((StellarSdk as any).BASE_FEE || 100) * feeBump).toString();
114+
const contract = new (StellarSdk as any).Contract(this.contractId);
115+
const tx = new (StellarSdk as any).TransactionBuilder(account, {
116+
fee,
117+
networkPassphrase: this.networkPassphrase,
118+
})
119+
.addOperation(contract.call(method, ...args))
120+
.setTimeout(30)
121+
.build();
122+
123+
const prepared = await this.rpcServer.prepareTransaction(tx);
124+
prepared.sign(kp);
125+
126+
const sendRes = await this.rpcServer.sendTransaction(prepared);
127+
const txHash = sendRes.hash || sendRes?.transactionHash || '';
128+
if (!txHash) {
129+
if (this.isInsufficientFeeError(JSON.stringify(sendRes))) {
130+
feeBump = Math.max(feeBump * 2, feeBump + 1);
131+
}
132+
lastError = new Error('sendTransaction returned no hash');
133+
await this.delay(this.backoff(attempt));
134+
continue;
135+
}
136+
137+
const confirm = await this.pollForConfirmation(txHash);
138+
if (confirm?.status === 'SUCCESS') {
139+
return { txHash, ledger: (confirm.ledger as number) || 0, success: true };
140+
}
141+
if (this.isInsufficientFeeError(JSON.stringify(confirm))) {
142+
feeBump = Math.max(feeBump * 2, feeBump + 1);
143+
}
144+
lastError = new Error(`${method} failed (status=${confirm?.status || 'UNKNOWN'})`);
145+
await this.delay(this.backoff(attempt));
146+
} catch (e: any) {
147+
const msg = e?.message || String(e);
148+
if (this.isRpcError(msg)) this.failoverRpc();
149+
else if (this.isInsufficientFeeError(msg)) feeBump = Math.max(feeBump * 2, feeBump + 1);
150+
this.logger.error(`Error calling ${method} (attempt ${attempt}/${this.MAX_RETRIES}): ${msg}`);
151+
lastError = e;
152+
await this.delay(this.backoff(attempt));
153+
}
154+
}
155+
156+
this.logger.error(`Persistent failure calling ${method} after ${this.MAX_RETRIES} attempts.`);
157+
return { txHash: '', ledger: 0, success: false };
158+
}
159+
78160
async submitRandomness(raffleId: number, randomness: RandomnessResult): Promise<SubmitResult> {
79161
if (!this.contractId || !this.oracleSecret) {
80162
this.logger.error('Missing configuration for TxSubmitter (contract id or oracle secret).');

0 commit comments

Comments
 (0)