Skip to content

Commit 076dcec

Browse files
authored
Merge pull request #358 from olawaleakanbi035-maker/feat/commit-reveal-integration
feat(oracle): implement commit-reveal integration
2 parents f6bfa49 + c13354b commit 076dcec

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
@@ -78,6 +78,88 @@ export class TxSubmitterService {
7878
);
7979
}
8080

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

0 commit comments

Comments
 (0)