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
35 changes: 31 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,40 @@ jobs:
# (contractimport!), so it must exist before anything below this
# touches demo-consumer: clippy and test both compile it.
- name: Build tholos wasm
run: cargo build -p tholos --target wasm32v1-none --release
run: cargo build -p tholos --target wasm32v1-none --release --locked

- name: Run clippy
run: cargo clippy --workspace --all-targets -- -D warnings
run: cargo clippy --workspace --all-targets --locked -- -D warnings

- name: Run tests
run: cargo test --workspace
run: cargo test --workspace --locked

- name: Build contract wasm
run: cargo build --workspace --target wasm32v1-none --release
run: cargo build --workspace --target wasm32v1-none --release --locked

demo:
runs-on: ubuntu-latest
defaults:
run:
working-directory: demos/freelance-escrow
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 10

- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
cache-dependency-path: demos/freelance-escrow/pnpm-lock.yaml

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Lint
run: pnpm lint

- name: Build
run: pnpm build
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
resolver = "2"
members = ["contracts/tholos", "contracts/demo-consumer", "contracts/asserter-consumer"]

[workspace.lints.rust]
warnings = "deny"

[profile.release]
opt-level = "z"
overflow-checks = true
Expand Down
3 changes: 3 additions & 0 deletions contracts/asserter-consumer/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ publish = false
[lib]
crate-type = ["cdylib", "rlib"]

[lints]
workspace = true

[dependencies]
soroban-sdk = "26.1.0"

Expand Down

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions contracts/demo-consumer/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ publish = false
[lib]
crate-type = ["cdylib", "rlib"]

[lints]
workspace = true

[dependencies]
soroban-sdk = "26.1.0"

Expand Down

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions contracts/tholos/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ publish = false
[lib]
crate-type = ["cdylib", "rlib"]

[lints]
workspace = true

[dependencies]
soroban-sdk = "26.1.0"

Expand Down
14 changes: 11 additions & 3 deletions contracts/tholos/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,7 @@ impl Tholos {
}

let n = committee.len();
let majority = (n / 2) + 1;
let majority = Self::majority_threshold(n);

if proposal.yes.len() >= majority {
// Execute: swap old -> new in the live committee. The proposal is
Expand Down Expand Up @@ -519,7 +519,7 @@ impl Tholos {
// longer pass. Otherwise a non-proposer touching a still-passable
// proposal is rejected.
let n = committee.len();
let majority = (n / 2) + 1;
let majority = Self::majority_threshold(n);
let remaining = n - proposal.yes.len() - proposal.no.len();
let can_cancel =
resolver == proposal.proposed_by || proposal.yes.len() + remaining < majority;
Expand Down Expand Up @@ -763,7 +763,7 @@ impl Tholos {
assertion.votes_against_outcome += 1;
}

let majority = (assertion.resolvers.len() / 2) + 1;
let majority = Self::majority_threshold(assertion.resolvers.len());
let winner_is_asserter = if assertion.votes_for_outcome >= majority {
Some(true)
} else if assertion.votes_against_outcome >= majority {
Expand Down Expand Up @@ -847,6 +847,14 @@ impl Tholos {
.ok_or(Error::NotInitialized)
}

/// The number of matching votes needed for a strict majority of `n`, i.e.
/// `(n / 2) + 1`. Shared by `resolve`, `vote_rotation`, and
/// `cancel_rotation`'s deadlock guard, which all decide against this same
/// threshold and are exactly what `proptest_vote_counting` exercises.
fn majority_threshold(n: u32) -> u32 {
(n / 2) + 1
}

/// Rejects a resolver committee containing duplicate addresses.
///
/// Called from `initialize` and `update_resolvers` to preserve the
Expand Down
14 changes: 13 additions & 1 deletion demos/freelance-escrow/src/state/JobsContext.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { jobs as seedJobs, type Job, type Milestone, type MilestoneStatus } from "../data/jobs";
import { assertOutcome, disputeAssertion, finalizeAssertion, resolveAssertion } from "../lib/tholos";
import { JobsContext, type JobsContextValue, type NewJobInput } from "./jobs-context";

/**
* lib/tholos.ts pulls in the full Stellar SDK. Importing it dynamically, only
* at the point a contract call actually happens, keeps that weight out of the
* initial bundle for the read-only job-browsing path most visits never leave.
*/
function loadTholosClient() {
return import("../lib/tholos");
}

function updateMilestone(
jobs: Job[],
jobId: string,
Expand Down Expand Up @@ -48,6 +56,7 @@ export function JobsProvider({ children }: { children: ReactNode }) {
}, []);

const submitMilestone = useCallback(async (jobId: string, milestoneId: string, signerAddress: string) => {
const { assertOutcome } = await loadTholosClient();
const assertionId = (await assertOutcome(signerAddress, true)).toString();
setJobs((current) =>
updateMilestone(current, jobId, milestoneId, {
Expand All @@ -63,6 +72,7 @@ export function JobsProvider({ children }: { children: ReactNode }) {
if (!milestone?.assertionId) {
return;
}
const { disputeAssertion } = await loadTholosClient();
await disputeAssertion(signerAddress, BigInt(milestone.assertionId));
setJobs((current) => updateMilestone(current, jobId, milestoneId, { status: "disputed" }));
}, [jobs]);
Expand All @@ -73,6 +83,7 @@ export function JobsProvider({ children }: { children: ReactNode }) {
if (!milestone?.assertionId) {
return;
}
const { resolveAssertion } = await loadTholosClient();
const decided = await resolveAssertion(resolverAddress, BigInt(milestone.assertionId), agreesWithFreelancer);
if (decided === null) {
return;
Expand All @@ -91,6 +102,7 @@ export function JobsProvider({ children }: { children: ReactNode }) {
if (!milestone?.assertionId) {
return;
}
const { finalizeAssertion } = await loadTholosClient();
await finalizeAssertion(callerAddress, BigInt(milestone.assertionId));
setJobs((current) => updateMilestone(current, jobId, milestoneId, { status: "released" }));
}, [jobs]);
Expand Down
15 changes: 15 additions & 0 deletions docs/src/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,21 @@ All notable changes to this project are documented here. Format follows
strings, matching the main contract's convention. Test-only, no behavior
change. Closes #6.

- The repeated `(committee_len / 2) + 1` majority-threshold calculation in
`vote_rotation`, `cancel_rotation`, and `resolve` is now a single
`Self::majority_threshold` helper. No behavior change; `proptest_vote_counting`
already exercises exactly this formula.

- CI now passes `--locked` to every `cargo build/test/clippy` invocation, so a
`Cargo.lock` that's drifted from what `Cargo.toml` would currently resolve to
fails the build loudly instead of Cargo silently re-resolving and using an
unreviewed dependency graph.

- Added a `[workspace.lints.rust] warnings = "deny"` table (with each crate
opting in via `[lints] workspace = true`), so a local `cargo build` enforces
the same warnings-as-errors bar CI's `-D warnings` flag does, instead of only
CI catching it.

### Fixed

- `finalize` is now blocked while paused, alongside `assert_outcome`, `dispute`,
Expand Down
Loading