Skip to content

Commit 217241b

Browse files
authored
Merge pull request #72 from ssvlabs/bapp-example-page
Bapp full example flow
2 parents d0ca630 + 4e0b5a6 commit 217241b

File tree

2 files changed

+253
-58
lines changed

2 files changed

+253
-58
lines changed

docs/based-applications/developers/bapp-example.md

Lines changed: 253 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@ sidebar_position: 2
55

66
# Based App Example
77

8-
9-
The example discussed in this page can be found at this repository:
8+
The code referenced in this page can be found at this repository:
109

1110
<a href="https://github.com/ssvlabs/examples">
1211
<img
@@ -16,86 +15,282 @@ The example discussed in this page can be found at this repository:
1615
/>
1716
</a>
1817

19-
It has been built to execute one task: fetch the most recent block and reach a majority vote on the slot number.
2018

21-
The simple example implemented [here](https://github.com/ssvlabs/examples) and used as reference in this page does not currently use the SDK as it breaks down the steps further, with verbose logging, for illustrative purposes, as this is the most important thing that BApp client developers need to familiarize with.
19+
This page demonstrates the core mechanics of building a Based Application and integrating it with the Based Applications Framework.
20+
21+
The following topics are covered:
22+
23+
- Creating a bApp contract
24+
- Implementing business logic for a bApp
25+
- Registering a bApp to the protocol
26+
- Strategy registration and opt-in process
27+
- Client implementation for task listening and response handling
28+
29+
30+
:::info
31+
This tutorial demonstrates one specific use case of bApps, implementing logic for tasks and how the contract/client handles execution and validation of tasks. This structure can be replaced with any logic necessary for a Based Application.
32+
33+
A Based Application does not require following this task/response structure, [any use case listed here can be implemented.](../learn/based-applications/use-cases)
34+
:::
35+
36+
## 1. Creating a bApp contract
37+
38+
The contract used in this tutorial is a price oracle contract for retrieving the current price of ETH.
39+
40+
The contract inherits from a base bApp contract, providing the necessary functionality for network operation. Additional business logic for creating and handling tasks is also implemented.
41+
42+
This bApp example implements two functions:
43+
44+
- ```createNewTask()```
45+
- ```respondToTask()```
46+
47+
These functions handle the initial task request from users (```createNewTask()```), triggering an event. The client listens for this event to begin task execution. Upon completion, the client pushes data back on-chain (```respondToTask()```)
48+
49+
This example uses ECDSA for verification (any verification method can be used), where strategy owners sign messages containing the task number and ETH price for their vote on the client. Signatures are stored in an array and sent in the ```respondToTask()``` function along with public keys. The contract verifies signatures and confirms each address has opted into the bApp before saving the price.
50+
51+
:::info
52+
Steps to deploy and verify the contract are included in the README of the repo.
53+
:::
2254

23-
When launched, the application the first action it takes is to fetch on-chain data for the given Based Application, in order to calculate the Strategy weights.
55+
The full contract code can be found [here](https://github.com/ssvlabs/examples/bapp-contracts/middleware/examples/ethPriceOracle.sol)
2456

25-
## Strategy Weights
2657

27-
In Based Applications, the **obligated token balance and delegated validator balance** are used to attribute a weight to each Strategy, which is then **used to vote** on whatever task the client should be accomplishing.
58+
The create and respond functions are as follows:
2859

29-
For an overview of these steps as well as a thorough explanation on the calculations, [please refer to this page in the Learn section](../learn/based-applications/strategy-weights.md).
60+
``` javascript
61+
function createNewTask() external returns (bytes32) {
62+
// Create task hash from block number and caller address
63+
bytes32 taskHash = keccak256(abi.encodePacked(block.number, msg.sender));
3064

31-
Developers should, however, not worry too much about it, as all of this can be accomplished thanks to high-level SDK functions: [`getStrategyTokenWeights()`](./BA-SDK/module-reference/api-module.md#getstrategytokenweights) and [`calcArithmeticStrategyWeights()`](./BA-SDK/module-reference/utils-module.md#calcarithmeticstrategyweights) (as well as its other variants).
65+
// store hash of task on-chain, emit event, and increase taskNum
66+
allTaskHashes[latestTaskNum] = taskHash;
67+
emit NewTaskCreated(latestTaskNum, taskHash);
68+
latestTaskNum = latestTaskNum + 1;
3269

33-
The vote calculation follows these steps:
70+
return taskHash;
71+
}
3472

35-
1. Collect strategies opted-in to the bapp
36-
2. Collect total validator balance delegated to all opted-in strategy owners
37-
3. Collect total obligated token balances
38-
4. Get "significance" of tokens and validator balance from config
39-
5. Calculate risk-adjusted weights for each token, for each strategy
40-
6. Normalize the obtained weights
41-
7. Combine strategy-token weights into a final weight for each strategy
73+
function respondToTask(
74+
bytes32 taskHash,
75+
uint32 taskNumber,
76+
uint256 ethPrice,
77+
bytes[] calldata signatures,
78+
address[] calldata signers
79+
) external {
80+
// check that the task is valid and hasn't been responded to yet
81+
if (taskHash != allTaskHashes[taskNumber]) { revert TaskMismatch(); }
82+
if (allTaskResponses[msg.sender][taskNumber].length != 0) { revert AlreadyResponded(); }
83+
if (ethPrice <= 0) { revert InvalidPrice(); }
84+
if (signatures.length != signers.length) { revert InvalidSignature(); }
4285

43-
![Vote Calculation Flow Chart](../../../static/img/example-flow-chart.png)
86+
// Create the message that was signed (task num + price)
87+
bytes32 messageHash = keccak256(abi.encodePacked(taskNumber, ethPrice));
88+
89+
// Verify each signature
90+
for (uint i = 0; i < signatures.length; i++) {
91+
// Recover the signer address from the signature
92+
address recoveredSigner = messageHash.recover(signatures[i]);
93+
94+
// Verify the recovered signer matches the expected signer
95+
if (recoveredSigner != signers[i]) {
96+
revert InvalidSigner();
97+
}
4498

45-
Here's an example of the result of these steps, referencing the output of the application:
99+
// Check if the signer has opted into this bApp
100+
uint32 strategyId = IAccountBAppStrategy(address(ssvBasedApps)).accountBAppStrategy(recoveredSigner, address(this));
101+
if (strategyId == 0) {
102+
revert NotOptedIn();
103+
}
104+
}
46105

106+
// Store the response
107+
allTaskResponses[msg.sender][taskNumber] = abi.encode(ethPrice);
108+
109+
// Emit event with the ETH price
110+
emit TaskResponded(taskNumber, taskHash, msg.sender, ethPrice);
111+
}
47112
```
48-
📊 Normalized Final Weights
49-
┌──────────┬────────────┬──────────────┬────────────┐
50-
│ Strategy │ Raw Weight │ Norm. Weight │ Weight (%) │
51-
├──────────┼────────────┼──────────────┼────────────┤
52-
│ 4 │ 1.28e-2 │ 1.35e-1 │ 13.54% │
53-
│ 5 │ 8.18e-2 │ 8.65e-1 │ 86.46% │
54-
└──────────┴────────────┴──────────────┴────────────┘
113+
114+
## 2. Registering a bApp to the network
115+
116+
After contract deployment, the register function becomes available.
117+
118+
The contract inherits ```OwnableBasedApp```, providing the register function.
119+
120+
With the contract deployed and verified, navigate to Etherscan and access the contract page. Under ```Write Contract```, locate the ```registerBapp``` function.
121+
122+
![Register in Etherscan](/img/bapp-example-1.jpeg)
123+
124+
Sign this transaction with the required tokens, shared risk level for each token, and the [bApp metadata URL.](./smart-contracts/metadata-schema)
125+
126+
## 3. Strategy creation and bApp opt-in process
127+
128+
After on-chain deployment and network registration, strategies can be created and opt into the bApp. Once opted in, participants can deposit any supported tokens.
129+
130+
For detailed guidance on this process, [follow this guide.](../user-guides/create-strategy.md)
131+
132+
## 4. Client implementation for the bApp
133+
134+
Each Based Application requires a client implementation, to be run by each strategy.
135+
136+
In this example, the strategy client will:
137+
138+
**4.1** Listen for tasks to process, monitoring events emitted from ```createNewTask()```
139+
140+
**4.2** Execute tasks off-chain, fetching the current ETH price
141+
142+
**4.3** Cast votes on the correct price, signing messages containing the task number and fetched price
143+
144+
**4.4** After majority vote determination, the last voting strategy signs the ```respondToTask()``` function and publishes the price on-chain
145+
146+
147+
### Code Snippets
148+
149+
All of these code snippets and the working implementation can be found [here](https://github.com/ssvlabs/examples/eth-price-oracle-client)
150+
151+
#### 4.1 Task listening implementation
152+
153+
The viem client used to instantiate the bApps SDK also handles listening for ```createNewTask()``` events.
154+
155+
Task listening is implemented using ```watchEvent()```, with task data passed to ```handleNewTask``` for execution:
156+
157+
```typescript
158+
export async function startEventListener() {
159+
try {
160+
161+
// use viem to listen for event
162+
const unwatch = publicClient.watchEvent({
163+
address: CONTRACT_ADDRESS,
164+
event: NEW_TASK_CREATED_EVENT,
165+
onLogs: (logs) => {
166+
logs.forEach((log) => {
167+
const { taskIndex, taskHash } = log.args;
168+
if (taskIndex !== undefined && taskHash) {
169+
// start task execution
170+
handleNewTask(BigInt(taskIndex), taskHash);
171+
}
172+
});
173+
},
174+
});
175+
176+
return unwatch;
177+
} catch (error) {
178+
await writeToClient(`Error starting event listener: ${error}`, 'error', false);
179+
throw error;
180+
}
181+
}
55182
```
56183

57-
## BApp task execution
184+
#### 4.2 Task execution
58185

59-
Once the application has finalized the Strategy Weights, it then starts the execution of a simple task (providing the next block slot number). The task is effectively executed, but the interaction between two independent strategy is simulated, for simplicity.
186+
When a user initiates a task, the client fetches the current ETH price:
60187

61-
1. Strategies retrieve the slot number. In real world scenario, multiple instances of a client would do this independently, whereas the example only does this once.
188+
```typescript
189+
export async function getCurrentEthPrice(): Promise<number> {
190+
try {
191+
// use CoinGecko API to get current ETH price
192+
const response = await axios.get(
193+
'https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd'
194+
);
195+
const price = response.data.ethereum.usd;
62196

63-
2. The first Strategy attempts to complete the task, signing and broadcasting the voted slot number.
197+
return Math.round(price);
198+
} catch (error) {
199+
console.error('Error fetching ETH price:', error);
200+
throw error;
201+
}
202+
}
203+
```
204+
205+
#### 4.3 Task outcome voting
64206

65-
3. The vote is processed, but the first Strategy only has 13.54% of the total weight, the majority is not reached, so it is not enough to complete the task.
207+
With task details and execution complete, the client casts its vote by signing a message containing the price and task hash:
66208

67-
4. The second Strategy attempts to complete the task, signing and broadcasting the voted slot number.
209+
```typescript
210+
export async function signTaskResponse(taskNumber: number, ethPrice: number): Promise<string> {
211+
// Create the message that will be signed (task num + price)
212+
// Match exactly what the contract does: keccak256(abi.encodePacked(taskNumber, ethPrice))
213+
const messageHash = keccak256(
214+
encodePacked(['uint32', 'uint256'], [taskNumber, BigInt(ethPrice)])
215+
);
68216

69-
5. The vote is processed, and the second Strategy has 86.46% of the total weight, reaching the majority threshold.
217+
// Sign the raw message hash directly without any prefix
218+
const signature = await account.sign({
219+
hash: messageHash,
220+
});
70221

71-
6. Since 100% of the total weight has now voted, the task is verified as complete. The system acknowledges that the task is fully verified.
222+
return signature;
223+
}
224+
```
72225

73-
You can visualize the task execution flow using the chart in the picture below
226+
With the message signed, the client will now vote on the task outcome:
74227

75-
![Simple Block Agreement Example Flow Chart](../../../static/img/simulated-flow.png)
228+
```typescript
229+
async function handleNewTask(taskIndex: bigint, taskHash: string) {
230+
try {
231+
const task = await createTaskFromEvent(taskIndex, taskHash);
232+
if (currentStrategy) {
233+
await voteOnTask(task, currentStrategy, currentCalculationType, currentVerboseMode);
234+
}
235+
} catch (error) {
236+
await writeToClient(`Error handling new task: ${error}`, 'error', false);
237+
}
238+
}
239+
```
76240

77-
Below is the output of the example application pertaining to the steps just described:
241+
The voting power (weight) is determined using the bApps SDK:
78242

243+
```typescript
244+
// Calculate strategy weights
245+
const weights = await calculateParticipantsWeightSDK(strategy, calculationType, verboseMode);
246+
const strategyWeight = weights.get(strategy) || 0;
247+
248+
// Add vote to task
249+
task.votes.set(strategy, strategyWeight);
79250
```
80-
🚀 Simulate Blockchain Agreement Process for Slot 11059006
81-
[🧍strategy 4] 📦 Handling new block with slot 11059006.
82-
[🧍strategy 4] 📤 Broadcasting vote
83-
[🧍strategy 4] 🗳️ Received vote from participant 4 with slot 11059006
84-
[🧍strategy 4] 📄 Checking majority for slot 11059006
85-
[🧍strategy 4] 🔢 Total weight: 13.54%. Decomposition: 13.54% (from P4)
86-
[🧍strategy 4] ❌ Majority not yet reached for slot: 11059006
87-
[🧍strategy 5] 🗳️ Received vote from participant 4 with slot 11059006
88-
[🧍strategy 5] 📄 Checking majority for slot 11059006
89-
[🧍strategy 5] 🔢 Total weight: 13.54%. Decomposition: 13.54% (from P4)
90-
[🧍strategy 5] ❌ Majority not yet reached for slot: 11059006
91-
[🧍strategy 5] 📦 Handling new block with slot 11059006.
92-
[🧍strategy 5] 📤 Broadcasting vote
93-
[🧍strategy 4] 🗳️ Received vote from participant 5 with slot 11059006
94-
[🧍strategy 4] 📄 Checking majority for slot 11059006
95-
[🧍strategy 4] 🔢 Total weight: 100.00%. Decomposition: 13.54% (from P4) + 86.46% (from P5)
96-
[🧍strategy 4] ✅ Majority found for slot: 11059006. Updating last decided slot.
97-
[🧍strategy 5] 🗳️ Received vote from participant 5 with slot 11059006
98-
[🧍strategy 5] 📄 Checking majority for slot 11059006
99-
[🧍strategy 5] 🔢 Total weight: 100.00%. Decomposition: 13.54% (from P4) + 86.46% (from P5)
100-
[🧍strategy 5] ✅ Majority found for slot: 11059006. Updating last decided slot.
251+
252+
#### 4.4 Majority vote submission
253+
254+
After strategies reach a majority vote and agree on a price (50% in this example),
255+
the client submits the ```respondToTask()``` function to the bApp contract, including task details,
256+
ETH price, and client signatures. The contract verifies the submission comes from opted-in strategies.
257+
258+
```typescript
259+
// Check if we have a majority (more than 50% of total weight)
260+
if (strategyWeight > totalWeight / 2) {
261+
// Get all signatures and signers from votes
262+
const signatures: string[] = [];
263+
const signers: string[] = [];
264+
265+
// Use the account's address derived from the private key
266+
if (task.signature) {
267+
signatures.push(task.signature);
268+
signers.push(account.address);
269+
}
270+
271+
// Submit the task response on-chain
272+
const txHash = await submitTaskResponse(task, task.taskNumber, signatures, signers);
273+
}
101274
```
275+
276+
The transaction is sent and the ETH price is published on-chain:
277+
278+
```typescript
279+
// Prepare the transaction
280+
const { request } = await publicClient.simulateContract({
281+
address: CONTRACT_ADDRESS as `0x${string}`,
282+
abi: respondToTaskABI,
283+
functionName: 'respondToTask',
284+
args: [
285+
task.id as `0x${string}`,
286+
taskNumber,
287+
BigInt(task.ethPrice),
288+
signatures as `0x${string}`[],
289+
signers as `0x${string}`[],
290+
],
291+
account: walletClient.account,
292+
});
293+
294+
// Send the transaction
295+
const hash = await walletClient.writeContract(request);
296+
```

static/img/bapp-example-1.jpeg

60.2 KB
Loading

0 commit comments

Comments
 (0)