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
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,43 @@
# Game-of-Life challenge

## Architecture overview

See also https://www.notion.so/Game-of-Life-Challenge-24ebf4cdba9c80c98a1ccb9fabf0db04

```mermaid
flowchart LR
user1("web user")
user2("admin user") --> admin("admin CLI wallet")
service("GoL service (private GraphQL API)")
external("external service")
user2 -- Registers puzzles --> service
style user1 fill:#f9f,stroke:#333,stroke-width:1px,color:#000
style user2 fill:#f9f,stroke:#333,stroke-width:1px,color:#000
style external fill:#aff,stroke:#333,stroke-width:1px,color:#000

subgraph "Linera network"
chain1["user chain"]
chain2["user chain"]
chain3["GoL scoring chain"]
style chain1 fill:#bbf,stroke:#333,stroke-width:1px,color:#000
style chain2 fill:#bbf,stroke:#333,stroke-width:1px,color:#000
end

user1 -- submits solution --> chain1
chain2 -- creates chain --> chain3
chain1 -- notifies solution --> chain3

admin -- publishes puzzle as blob --> chain2
admin -- creates GoL scoring chain --> chain2
admin -- creates GoL application --> chain2
service -- processes inbox --> chain3
service -- registers puzzle --> chain3
external -- replicates scores --> chain3
portal("portal backend") -- queries --> external
```

## Quickstart (backend)

```
cargo install linera-storage-service@0.15.3 linera-service@0.15.3

Expand Down
92 changes: 88 additions & 4 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ linera_spawn linera net up --with-faucet --faucet-port $FAUCET_PORT
# FAUCET_URL=https://faucet.testnet-XXX.linera.net # for some value XXX
```

Create the user wallets and add chains to them:
Create the user wallet and add a chain to it:

```bash
export LINERA_WALLET="$LINERA_TMP_DIR/wallet.json"
Expand All @@ -43,6 +43,22 @@ CHAIN="${INFO[0]}"
OWNER="${INFO[1]}"
```

### Creating the scoring chain

Let's create another wallet and the scoring chain. The app wallet will be accessible with `linera -w1`.

```bash
export LINERA_WALLET_1="$LINERA_TMP_DIR/wallet_1.json"
export LINERA_KEYSTORE_1="$LINERA_TMP_DIR/keystore_1.json"
export LINERA_STORAGE_1="rocksdb:$LINERA_TMP_DIR/client_1.db"

linera -w1 wallet init --faucet $FAUCET_URL

INFO_1=($(linera -w1 wallet request-chain --faucet $FAUCET_URL))
CHAIN_1="${INFO_1[0]}"
OWNER_1="${INFO_1[1]}"
```

### Creating the GoL challenge application

We use the default chain of the first wallet to create the application on it and start the
Expand All @@ -51,7 +67,7 @@ node service.
```bash
APP_ID=$(linera --wait-for-outgoing-messages \
project publish-and-create backend gol_challenge $CHAIN \
--json-argument "null")
--json-parameters "{ \"scoring_chain_id\": \"$CHAIN_1\" }")
```

### Creating a new puzzle
Expand All @@ -64,17 +80,30 @@ BLOB_ID=$(linera publish-data-blob "$LINERA_TMP_DIR/02_beehive_pattern_puzzle.bc

### Publishing puzzles and running code-generation

Run the node service for the scoring chain.
```bash
./publish-puzzles.sh
linera -w1 service --port 8081 &
sleep 1
```

### Testing the GraphQL APIs
The following script creates puzzles with the `gol` tool, then it uses the user wallet to
publish them. At the same time, it also sends GraphQL queries to the scoring chain to register
the puzzles.

```bash
./publish-puzzles.sh http://localhost:8081/chains/$CHAIN_1/applications/$APP_ID
```

Note that we never unregister puzzles.

### Testing the user's GraphQL APIs

In this section, we are using the GraphQL service of the native client to show examples of
GraphQL queries. Note that Web frontends have their own GraphQL endpoint.

```bash
linera service --port 8080 &
PID=$!
sleep 1
```

Expand All @@ -98,3 +127,58 @@ mutation {
})
}
```

### Testing the scoring chain's GraphQL APIs

To debug GraphQL APIs, uncomment the line with `read` and run `bash -x -e <(linera extract-script-from-markdown backend/README.md)`.
```bash
echo http://localhost:8081/chains/$CHAIN_1/applications/$APP_ID
# read
```

```gql,uri=http://localhost:8081/chains/$CHAIN_1/applications/$APP_ID
query {
reportedSolutions {
entry(key: "$OWNER") {
key
value {
entries(input: {}) {
key
value
}
}
}
}
}
```

### Testing the scoring chain's GraphQL APIs from another wallet

We re-use the user wallet for simplicity.

Restart the service with the scoring chain followed in read-only:
```bash
kill $PID

linera wallet follow-chain "$CHAIN_1"

linera service --port 8080 &
```

```gql,uri=http://localhost:8080/chains/$CHAIN_1/applications/$APP_ID
query {
reportedSolutions {
entry(key: "$OWNER") {
key
value {
entries(input: {}) {
key
value
}
}
}
}
}
```

The error "kill: ???: No such process" at the end is expected.
102 changes: 86 additions & 16 deletions backend/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ mod state;
use async_graphql::ComplexObject;
use gol_challenge::{GolChallengeAbi, Operation};
use linera_sdk::{
linera_base_types::WithContractAbi,
linera_base_types::{AccountOwner, ChainId, DataBlobHash, Timestamp, WithContractAbi},
views::{RootView, View},
Contract, ContractRuntime,
};
Expand All @@ -22,14 +22,30 @@ pub struct GolChallengeContract {

linera_sdk::contract!(GolChallengeContract);

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Parameters {
/// Where to report puzzles for scoring.
scoring_chain_id: ChainId,
}

impl WithContractAbi for GolChallengeContract {
type Abi = GolChallengeAbi;
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Message {
/// The ID of the puzzle that was solved.
pub puzzle_id: DataBlobHash,
/// The timestamp of the solution.
pub timestamp: Timestamp,
/// The user credited for the solution.
pub owner: AccountOwner,
}

impl Contract for GolChallengeContract {
type Message = Message;
type InstantiationArgument = ();
type Parameters = ();
type Parameters = Parameters;
type EventValue = ();

async fn load(runtime: ContractRuntime<Self>) -> Self {
Expand All @@ -41,35 +57,89 @@ impl Contract for GolChallengeContract {

async fn instantiate(&mut self, _arg: ()) {
log::trace!("Instantiating");
self.runtime.application_parameters(); // Verifies that these are empty.
// Verify that the parameters are correct.
self.runtime.application_parameters();
}

async fn execute_operation(&mut self, operation: Operation) {
log::trace!("Handling operation {:?}", operation);
let Operation::SubmitSolution { puzzle_id, board } = operation;
let puzzle_bytes = self.runtime.read_data_blob(puzzle_id);
let puzzle = bcs::from_bytes(&puzzle_bytes).expect("Deserialize puzzle");
board.check_puzzle(&puzzle).expect("Invalid solution");
let timestamp = self.runtime.system_time();
let solution = Solution { board, timestamp };
self.state
.solutions
.insert(&puzzle_id, solution)
.expect("Store solution");
match operation {
Operation::SubmitSolution {
puzzle_id,
board,
owner,
} => {
let owner = owner.unwrap_or_else(|| {
self.runtime
.authenticated_signer()
.expect("Operation must have an owner or be authenticated.")
});
let puzzle_bytes = self.runtime.read_data_blob(puzzle_id);
let puzzle = bcs::from_bytes(&puzzle_bytes).expect("Deserialize puzzle");
board.check_puzzle(&puzzle).expect("Invalid solution");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(not from this PR but): I'd expect this API to be the other way around– puzzle.check_solution(&board). A board is a solution to a specific puzzle (a puzzle can have many different solutions/boards but a board can be a solution to one puzzle).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's fair. Let me look

let timestamp = self.runtime.system_time();
let solution = Solution {
board,
timestamp,
owner,
};
self.state
.solutions
.insert(&puzzle_id, solution)
.expect("Store solution");

let Parameters { scoring_chain_id } = self.runtime.application_parameters();
let message = Message {
puzzle_id,
timestamp,
owner,
};
self.runtime
.prepare_message(message)
.send_to(scoring_chain_id);
}
Operation::RegisterPuzzle { puzzle_id } => {
self.assert_scoring_chain("Puzzles are only registered on the scoring chain.");
self.state.registered_puzzles.insert(&puzzle_id).unwrap();
}
}
}

async fn execute_message(&mut self, message: Message) {
log::trace!("Handling message {:?}", message);
unreachable!();
let Message {
puzzle_id,
timestamp,
owner,
} = message;
self.assert_scoring_chain("Messages are sent to the scoring chain.");
let is_registered = self
.state
.registered_puzzles
.contains(&puzzle_id)
.await
.unwrap();
assert!(is_registered, "Puzzle must be registered");
let map = self
.state
.reported_solutions
.load_entry_mut(&owner)
.await
.unwrap();
map.insert(&puzzle_id, timestamp).unwrap();
}

async fn store(mut self) {
self.state.save().await.expect("Failed to save state");
}
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Message;
impl GolChallengeContract {
fn assert_scoring_chain(&mut self, error: &str) {
let Parameters { scoring_chain_id } = self.runtime.application_parameters();
assert_eq!(self.runtime.chain_id(), scoring_chain_id, "{}", error);
}
}

/// This implementation is only nonempty in the service.
#[ComplexObject]
Expand Down
10 changes: 9 additions & 1 deletion backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ pub mod game;
use async_graphql::{Request, Response};
use linera_sdk::{
graphql::GraphQLMutationRoot,
linera_base_types::{ContractAbi, DataBlobHash, ServiceAbi},
linera_base_types::{AccountOwner, ContractAbi, DataBlobHash, ServiceAbi},
};
use serde::{Deserialize, Serialize};

Expand All @@ -29,6 +29,14 @@ pub enum Operation {
puzzle_id: DataBlobHash,
/// The board of the solution.
board: Board,
/// Optional owner to credit instead of the current authenticated owner.
owner: Option<AccountOwner>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of introducing more branching in the contract, might be easier to just make it a required field and set the authenticated owner when constructing the operation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No actually, web frontends don't want to figure out who the owner is. This will be used by AI agents.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's nothing to figure out – web frontend is connected to a wallet (signer) with that information. It needs to sign the block proposal (mutation) anyway.

Copy link
Contributor Author

@ma2bd ma2bd Sep 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Frontends don't sign anything but perhaps you just mean that they could easily ask the wallet who's the current user, which I agree.

},
// Scoring appchain only
/// Register a puzzle to activate scoring for it.
RegisterPuzzle {
/// The ID of the puzzle to register.
puzzle_id: DataBlobHash,
},
}

Expand Down
16 changes: 13 additions & 3 deletions backend/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
use async_graphql::{InputObject, SimpleObject};
use gol_challenge::game::Board;
use linera_sdk::{
linera_base_types::{DataBlobHash, Timestamp},
views::{linera_views, MapView, RootView, ViewStorageContext},
linera_base_types::{AccountOwner, DataBlobHash, Timestamp},
views::{linera_views, CollectionView, MapView, RootView, SetView, ViewStorageContext},
};
use serde::{Deserialize, Serialize};

Expand All @@ -14,8 +14,16 @@ use serde::{Deserialize, Serialize};
#[graphql(complex)]
#[view(context = ViewStorageContext)]
pub struct GolChallengeState {
/// The local solutions previously submitted by an owner of the chain.
/// The local solutions previously submitted by an owner of the chain. Puzzles do not
/// need to be registered.
pub solutions: MapView<DataBlobHash, Solution>,

// Scoring chain only.
/// The set of registered puzzles.
pub registered_puzzles: SetView<DataBlobHash>,
/// The set of all solutions reported to us, indexed by owner, then by puzzle_id. We only track
/// registered puzzles.
pub reported_solutions: CollectionView<AccountOwner, MapView<DataBlobHash, Timestamp>>,
}

/// A verified solution to a GoL puzzle.
Expand All @@ -25,4 +33,6 @@ pub struct Solution {
pub board: Board,
/// Timestamp of the submission.
pub timestamp: Timestamp,
/// The user credited for the solution.
pub owner: AccountOwner,
}
Loading