diff --git a/src/commands/airc/send/browser/AircSendBrowserCommand.ts b/src/commands/airc/send/browser/AircSendBrowserCommand.ts index 76d80d595..1a10d30e8 100644 --- a/src/commands/airc/send/browser/AircSendBrowserCommand.ts +++ b/src/commands/airc/send/browser/AircSendBrowserCommand.ts @@ -5,10 +5,13 @@ */ import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; -import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { CommandScope, JTAGContext } from '@system/core/types/JTAGTypes'; import type { AircSendParams, AircSendResult } from '../shared/AircSendTypes'; export class AircSendBrowserCommand extends CommandBase { + protected static override get naturalScope(): CommandScope { + return { type: 'room' }; + } constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { super('airc/send', context, subpath, commander); diff --git a/src/commands/airc/send/server/AircSendServerCommand.ts b/src/commands/airc/send/server/AircSendServerCommand.ts index 35b42a08e..a2267e290 100644 --- a/src/commands/airc/send/server/AircSendServerCommand.ts +++ b/src/commands/airc/send/server/AircSendServerCommand.ts @@ -33,12 +33,15 @@ import { spawn } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import * as path from 'node:path'; import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; -import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { CommandScope, JTAGContext } from '@system/core/types/JTAGTypes'; import { ValidationError } from '@system/core/types/ErrorTypes'; import type { AircSendParams, AircSendResult } from '../shared/AircSendTypes'; import { createAircSendResultFromParams } from '../shared/AircSendTypes'; export class AircSendServerCommand extends CommandBase { + protected static override get naturalScope(): CommandScope { + return { type: 'room' }; + } constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { super('airc/send', context, subpath, commander); diff --git a/src/commands/collaboration/decision/propose/server/DecisionProposeServerCommand.ts b/src/commands/collaboration/decision/propose/server/DecisionProposeServerCommand.ts index 1e7fa103a..8b5cbfa49 100644 --- a/src/commands/collaboration/decision/propose/server/DecisionProposeServerCommand.ts +++ b/src/commands/collaboration/decision/propose/server/DecisionProposeServerCommand.ts @@ -305,7 +305,7 @@ export class DecisionProposeServerCommand extends DecisionProposeCommand { const proposerId: UUID = params.userId; const proposerName: string = proposerResult.data.displayName; - const scope = params.scope || 'all'; + const scope = params.proposalScope || 'all'; const significanceLevel = params.significanceLevel || 'medium'; const proposalId = generateUUID(); diff --git a/src/commands/collaboration/decision/propose/shared/DecisionProposeTypes.ts b/src/commands/collaboration/decision/propose/shared/DecisionProposeTypes.ts index 7e75c6968..f211cdf59 100644 --- a/src/commands/collaboration/decision/propose/shared/DecisionProposeTypes.ts +++ b/src/commands/collaboration/decision/propose/shared/DecisionProposeTypes.ts @@ -35,7 +35,7 @@ export interface DecisionProposeParams extends CommandParams { }>; /** Who should vote on this? */ - scope?: ProposalScope; // Default: 'all' + proposalScope?: ProposalScope; // Default: 'all' /** How urgent is this? Determines response window */ significanceLevel?: SignificanceLevel; // Default: 'medium' @@ -102,4 +102,3 @@ export const createCollaborationDecisionProposeResultFromParams = ( params: DecisionProposeParams, differences: Omit ): DecisionProposeResult => transformPayload(params, differences); - diff --git a/src/commands/grid/send/browser/GridSendBrowserCommand.ts b/src/commands/grid/send/browser/GridSendBrowserCommand.ts index 0ae36c7cf..ce849d39f 100644 --- a/src/commands/grid/send/browser/GridSendBrowserCommand.ts +++ b/src/commands/grid/send/browser/GridSendBrowserCommand.ts @@ -5,10 +5,14 @@ */ import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; -import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { CommandScope, JTAGContext } from '@system/core/types/JTAGTypes'; import type { GridSendParams, GridSendResult } from '../shared/GridSendTypes'; export class GridSendBrowserCommand extends CommandBase { + protected static override get naturalScope(): CommandScope { + return { type: 'grid' }; + } + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { super('grid/send', context, subpath, commander); } diff --git a/src/commands/grid/send/server/GridSendServerCommand.ts b/src/commands/grid/send/server/GridSendServerCommand.ts index 1685f40f1..2a848bfea 100644 --- a/src/commands/grid/send/server/GridSendServerCommand.ts +++ b/src/commands/grid/send/server/GridSendServerCommand.ts @@ -7,13 +7,17 @@ */ import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase'; -import type { JTAGContext } from '@system/core/types/JTAGTypes'; +import type { CommandScope, JTAGContext } from '@system/core/types/JTAGTypes'; import type { GridSendParams, GridSendResult } from '../shared/GridSendTypes'; import { RustCoreIPCClient, getContinuumCoreSocketPath } from '../../../../workers/continuum-core/bindings/RustCoreIPC'; export class GridSendServerCommand extends CommandBase { private rustClient: RustCoreIPCClient; + protected static override get naturalScope(): CommandScope { + return { type: 'grid' }; + } + constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { super('grid/send', context, subpath, commander); this.rustClient = new RustCoreIPCClient(getContinuumCoreSocketPath()); diff --git a/src/commands/skill/list/server/SkillListServerCommand.ts b/src/commands/skill/list/server/SkillListServerCommand.ts index 35240fb82..8d91f9cdb 100644 --- a/src/commands/skill/list/server/SkillListServerCommand.ts +++ b/src/commands/skill/list/server/SkillListServerCommand.ts @@ -27,8 +27,8 @@ export class SkillListServerCommand extends CommandBase createPayload(context, sessionId, { userId: SYSTEM_SCOPES.SYSTEM, status: data.status ?? '', - scope: data.scope ?? '', + skillScope: data.skillScope ?? '', createdById: data.createdById ?? '', limit: data.limit ?? 0, ...data diff --git a/src/commands/skill/propose/server/SkillProposeServerCommand.ts b/src/commands/skill/propose/server/SkillProposeServerCommand.ts index 0a87ba91d..1d0c3af0e 100644 --- a/src/commands/skill/propose/server/SkillProposeServerCommand.ts +++ b/src/commands/skill/propose/server/SkillProposeServerCommand.ts @@ -25,7 +25,7 @@ export class SkillProposeServerCommand extends CommandBase { const { name, description, implementation, personaId } = params; - const scope: SkillScope = (params.scope === 'team' ? 'team' : 'personal'); + const scope: SkillScope = (params.skillScope === 'team' ? 'team' : 'personal'); if (!name?.trim()) { throw new ValidationError('name', "Missing required parameter 'name'. Provide the command name (e.g., 'analysis/complexity')."); @@ -99,7 +99,7 @@ export class SkillProposeServerCommand extends CommandBase[]; // AI persona proposing this skill @@ -51,7 +51,7 @@ export const createSkillProposeParams = ( // Natural language description of the implementation logic implementation: string; // Who can use it: 'personal' (default) or 'team' (requires approval) - scope?: string; + skillScope?: string; // Usage examples array [{description, command, expectedResult?}] examples?: Record[]; // AI persona proposing this skill @@ -59,7 +59,7 @@ export const createSkillProposeParams = ( } ): SkillProposeParams => createPayload(context, sessionId, { userId: SYSTEM_SCOPES.SYSTEM, - scope: data.scope ?? '', + skillScope: data.skillScope ?? '', examples: data.examples ?? undefined, ...data }); diff --git a/src/daemons/command-daemon/shared/CommandBase.ts b/src/daemons/command-daemon/shared/CommandBase.ts index d565e10bf..ae3f6ab89 100644 --- a/src/daemons/command-daemon/shared/CommandBase.ts +++ b/src/daemons/command-daemon/shared/CommandBase.ts @@ -6,7 +6,7 @@ */ import { JTAGModule } from '../../../system/core/shared/JTAGModule'; -import type { JTAGContext, CommandParams, CommandResult } from '../../../system/core/types/JTAGTypes'; +import type { CommandScope, JTAGContext, CommandParams, CommandResult } from '../../../system/core/types/JTAGTypes'; import { JTAG_ENVIRONMENTS, JTAGMessageFactory } from '../../../system/core/types/JTAGTypes'; import { type UUID } from '../../../system/core/types/CrossPlatformUUID'; import { SYSTEM_SCOPES } from '../../../system/core/types/SystemScopes'; @@ -82,6 +82,17 @@ export abstract class CommandBase, + requestSessionId, + requestContext, + ); + + // Check if timeout is specified in command params + const timeout = scopedParams.timeout; + // Grid routing: check if this command should execute on a remote node. // Uses the same interceptor registered on Commands (server-side only). // Skip for grid/* commands to avoid infinite recursion. if (!commandName.startsWith('grid/')) { const interceptor = (Commands as unknown as { _gridInterceptor: { tryRouteRemote: (cmd: string, params: unknown) => Promise } | null })._gridInterceptor; if (interceptor) { - const remoteResult = await interceptor.tryRouteRemote(commandName, message.payload); + const remoteResult = await interceptor.tryRouteRemote(commandName, scopedParams); if (remoteResult !== null) { return createCommandSuccessResponse(remoteResult as CommandResult, requestContext, undefined, requestSessionId); } @@ -166,7 +172,7 @@ export abstract class CommandDaemon extends DaemonBase { // Execute command with session context for dual logging const executionPromise = globalSessionContext.withSession(requestSessionId, async () => { - return await command.execute({ userId: resolvedUserId, ...message.payload } as CommandParams); + return await command.execute(scopedParams); }); // Apply timeout if specified @@ -302,4 +308,3 @@ export abstract class CommandDaemon extends DaemonBase { }); } } - diff --git a/src/shared/generated/contracts/ContractAcceptedPayload.ts b/src/shared/generated/contracts/ContractAcceptedPayload.ts new file mode 100644 index 000000000..c84ec8758 --- /dev/null +++ b/src/shared/generated/contracts/ContractAcceptedPayload.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `contract:accepted` — proposer's signed selection of one bidder. + */ +export type ContractAcceptedPayload = { contractId: string, proposerId: string, acceptedBidderId: string, +/** + * Hash of the accepted bid envelope — pins exactly which bid was + * taken (defense against bid-rewrite attacks where two bids share + * a contract_id). + */ +acceptedBidHash: string, }; diff --git a/src/shared/generated/contracts/ContractBidPayload.ts b/src/shared/generated/contracts/ContractBidPayload.ts new file mode 100644 index 000000000..c1a4f4626 --- /dev/null +++ b/src/shared/generated/contracts/ContractBidPayload.ts @@ -0,0 +1,16 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `contract:bid` — an executor's offer to take on a proposed contract. + */ +export type ContractBidPayload = { contractId: string, bidderId: string, bidAmount: bigint, +/** + * Bidder's promised SLA (max latency in ms). Proposer uses this + * in the bid-selection policy (lower latency + lower bid wins, + * per the policy engine). + */ +maxLatencyMs: number, +/** + * Bidder's expiry — how long this bid is honored if accepted. + */ +bidExpiryUnixMs: bigint, }; diff --git a/src/shared/generated/contracts/ContractDeliveredPayload.ts b/src/shared/generated/contracts/ContractDeliveredPayload.ts new file mode 100644 index 000000000..6a999f418 --- /dev/null +++ b/src/shared/generated/contracts/ContractDeliveredPayload.ts @@ -0,0 +1,21 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `contract:delivered` — executor's signed assertion that the work is + * done. Carries the alloy_hash of the actual artifact (which the + * proposer compares against the originally-proposed alloy_hash to + * detect bait-and-switch). + */ +export type ContractDeliveredPayload = { contractId: string, executorId: string, +/** + * Hash of the delivered artifact (may differ from the proposed + * alloy_hash if the executor produced a SPECIFIC output that + * satisfies the proposed CONTRACT). + */ +deliveredAlloyHash: string, +/** + * Optional location pointer (URL, IPFS CID, etc.) for fetching + * the artifact bytes. The hash is the canonical reference; this + * is convenience. + */ +artifactUrl?: string, }; diff --git a/src/shared/generated/contracts/ContractDisputedPayload.ts b/src/shared/generated/contracts/ContractDisputedPayload.ts new file mode 100644 index 000000000..fda56af00 --- /dev/null +++ b/src/shared/generated/contracts/ContractDisputedPayload.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `contract:disputed` — any signer can file. Replay reproduces every + * disputed contract for auditor review. + */ +export type ContractDisputedPayload = { contractId: string, disputerId: string, reason: string, +/** + * Optional reference to the specific prior event being disputed + * (e.g. the verified-hash if the disputer claims wrong verdict). + */ +disputedEventHash?: string, }; diff --git a/src/shared/generated/contracts/ContractExecutingPayload.ts b/src/shared/generated/contracts/ContractExecutingPayload.ts new file mode 100644 index 000000000..00cbd1799 --- /dev/null +++ b/src/shared/generated/contracts/ContractExecutingPayload.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `contract:executing` — executor's signed "work started" beacon. + * Optional event (the chain stays valid without it) but used by the + * router daemon to mark a routing slot as in-use. + */ +export type ContractExecutingPayload = { contractId: string, executorId: string, startedAtUnixMs: bigint, }; diff --git a/src/shared/generated/contracts/ContractPaidPayload.ts b/src/shared/generated/contracts/ContractPaidPayload.ts new file mode 100644 index 000000000..65c31b55c --- /dev/null +++ b/src/shared/generated/contracts/ContractPaidPayload.ts @@ -0,0 +1,13 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `contract:paid` — payer's signed settlement record. For the + * zero-cost household tier this is still emitted (audit completeness) + * with `amount: 0`. + */ +export type ContractPaidPayload = { contractId: string, payerId: string, payeeId: string, amount: bigint, currency: string, +/** + * Optional settlement reference (chain tx hash, internal ledger + * entry id, etc.). Not load-bearing for replay; just provenance. + */ +settlementRef?: string, }; diff --git a/src/shared/generated/contracts/ContractProposedPayload.ts b/src/shared/generated/contracts/ContractProposedPayload.ts new file mode 100644 index 000000000..97d37a8cb --- /dev/null +++ b/src/shared/generated/contracts/ContractProposedPayload.ts @@ -0,0 +1,32 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `contract:proposed` — initiator publishes a contract for bidding. + * + * `alloy_hash` references the substance of what's being contracted — + * matches the proof-contract layer in + * `docs/grid/FORGE-ALLOY-PROOF-CONTRACTS.md`. For pre-alloy use cases + * (e.g. a `ping` dispatch with no proof bundle) the hash references + * a synthetic "ping contract" alloy with no proof suite. + */ +export type ContractProposedPayload = { contractId: string, proposerId: string, +/** + * SHA-256 reference to the alloy bundle describing the work. + * Hex-encoded for human readability + ts-rs `string` mapping. + */ +alloyHash: string, +/** + * Currency/escrow terms. Zero-cost ("household") tier = empty + * `bid_currency` + zero `max_bid`. + */ +bidCurrency: string, maxBid: bigint, +/** + * Expiry (Unix ms). After this point the proposal is dead even + * if no `:accepted` was ever emitted. + */ +expiryUnixMs: bigint, +/** + * Required executor capability tag — matches the L1-4 + * `presence:peer-manifest` capability index format. + */ +requiredCapability: string, }; diff --git a/src/shared/generated/contracts/ContractVerifiedPayload.ts b/src/shared/generated/contracts/ContractVerifiedPayload.ts new file mode 100644 index 000000000..b801d174b --- /dev/null +++ b/src/shared/generated/contracts/ContractVerifiedPayload.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `contract:verified` — proposer (or auditor) signs the verification + * verdict. Carries the result of running the alloy proof suite + * against the delivered artifact. + */ +export type ContractVerifiedPayload = { contractId: string, verifierId: string, +/** + * `passed: true` ⇒ proof suite ran clean; `false` ⇒ at least one + * TDD assertion failed or a VDD metric was outside the tolerance + * band. Verifier signs either way — disputes happen via + * `contract:disputed`, not by withholding `:verified`. + */ +passed: boolean, +/** + * Concise reason string for the verdict — full details belong in + * a separate report referenced by alloy_hash. + */ +verdictReason: string, }; diff --git a/src/shared/generated/contracts/index.ts b/src/shared/generated/contracts/index.ts new file mode 100644 index 000000000..a40cd0dd1 --- /dev/null +++ b/src/shared/generated/contracts/index.ts @@ -0,0 +1,12 @@ +// Auto-generated barrel export — do not edit manually +// Source: generator/generate-rust-bindings.ts +// Re-generate: npx tsx generator/generate-rust-bindings.ts + +export type { ContractAcceptedPayload } from './ContractAcceptedPayload'; +export type { ContractBidPayload } from './ContractBidPayload'; +export type { ContractDeliveredPayload } from './ContractDeliveredPayload'; +export type { ContractDisputedPayload } from './ContractDisputedPayload'; +export type { ContractExecutingPayload } from './ContractExecutingPayload'; +export type { ContractPaidPayload } from './ContractPaidPayload'; +export type { ContractProposedPayload } from './ContractProposedPayload'; +export type { ContractVerifiedPayload } from './ContractVerifiedPayload'; diff --git a/src/shared/generated/index.ts b/src/shared/generated/index.ts index 27e190319..491d1f202 100644 --- a/src/shared/generated/index.ts +++ b/src/shared/generated/index.ts @@ -129,6 +129,7 @@ export type { VisionDescribeOptions } from './cognition'; export type { VisionDescribeRequest } from './cognition'; export type { VisionDescription } from './cognition'; export * from './comms'; +export * from './contracts'; export * from './dataset'; export * from './events'; // forge: explicit exports (has duplicate types) diff --git a/src/system/core/types/JTAGTypes.ts b/src/system/core/types/JTAGTypes.ts index 4177f1473..0a75ad808 100644 --- a/src/system/core/types/JTAGTypes.ts +++ b/src/system/core/types/JTAGTypes.ts @@ -184,6 +184,35 @@ export interface JTAGPayload { readonly sessionId: UUID; } +/** + * Command execution scope. + * + * Scope is the typed routing/audit boundary for commands. It lets callers and + * command infrastructure describe where work belongs without parsing command + * names, stdout, or ad-hoc params. Recipe rooms, project workspaces, persona + * turns, and grid nodes can all map to this shape. + */ +export type CommandScopeType = + | 'system' + | 'user' + | 'session' + | 'room' + | 'project' + | 'persona' + | 'grid' + | 'resource'; + +export interface CommandScope { + /** Scope class used by routers/projections for partitioning. */ + readonly type: CommandScopeType; + + /** Stable scope identifier, such as room id, repo slug, persona id, or node id. */ + readonly id?: string; + + /** Human-readable label for diagnostics and UI projections. */ + readonly label?: string; +} + /** * Functional factory for creating payloads - eliminates constructor complexity * Rust-like inheritance: creates payload from source + differences @@ -548,6 +577,13 @@ export interface CommandParams extends JTAGPayload { */ readonly userId: UUID; + /** + * Typed execution scope for routing, event projection, audit, and work + * alignment. CommandBase injects the command's natural scope when callers + * don't provide one; explicit caller scope wins. + */ + readonly scope?: CommandScope; + /** * Optional execution timeout in milliseconds. * If command execution exceeds this timeout, behavior is controlled by onTimeout. @@ -609,4 +645,4 @@ export type CommandMessage = JTAGMessag /** * Session and context propagation through explicit payload parameters * No global state - everything flows through payload chain - */ \ No newline at end of file + */ diff --git a/src/workers/Cargo.lock b/src/workers/Cargo.lock index 6eab75e9d..0d551dcb5 100644 --- a/src/workers/Cargo.lock +++ b/src/workers/Cargo.lock @@ -2152,6 +2152,7 @@ dependencies = [ "deadpool-postgres", "dirs 5.0.1", "earshot", + "ed25519-dalek", "fastembed", "futures", "futures-util", @@ -2955,6 +2956,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ "pkcs8", + "serde", "signature", ] @@ -2966,6 +2968,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", + "rand_core 0.6.4", "serde", "sha2", "subtle", diff --git a/src/workers/continuum-core/Cargo.toml b/src/workers/continuum-core/Cargo.toml index 7501c41a5..2f112e1fd 100644 --- a/src/workers/continuum-core/Cargo.toml +++ b/src/workers/continuum-core/Cargo.toml @@ -39,7 +39,8 @@ tikv-jemallocator = "0.6" # jemalloc: returns memory to OS aggressively, reduce libc = "0.2" # Process group management (setsid, kill -pgid) toml = "0.8" # Avatar model manifest parsing base64 = "0.22" # Base64 encoding for audio data -sha2 = "0.10" # SHA-256 for OAuth 2.0 PKCE code challenges (RFC 7636) +sha2 = "0.10" # SHA-256 for OAuth 2.0 PKCE code challenges (RFC 7636) + L1-6 contract canonical hash +ed25519-dalek = { version = "2", features = ["rand_core", "serde"] } # L1-6 contract event signatures (matches airc-protocol's pinned version) async-trait.workspace = true chrono.workspace = true diff --git a/src/workers/continuum-core/src/contracts/chain_tests.rs b/src/workers/continuum-core/src/contracts/chain_tests.rs new file mode 100644 index 000000000..61ef543ac --- /dev/null +++ b/src/workers/continuum-core/src/contracts/chain_tests.rs @@ -0,0 +1,215 @@ +//! End-to-end L1-6 contract chain integration tests. +//! +//! Walks the full 8-event chain (proposed → bid → accepted → executing +//! → delivered → verified → paid → disputed) for a synthetic "ping +//! grid dispatch with zero-LP household terms" — the worked example +//! the roadmap names as the L1-6 done-criterion. +//! +//! No airc transport yet — these tests sign + verify in-memory and +//! prove the envelopes round-trip bit-equivalently through JSON. The +//! airc-cursor replay variant lands in Phase B once L1-4 +//! (`presence:peer-manifest`) provides the per-peer pubkey index. + +#![cfg(test)] + +use crate::contracts::{ + envelope::SignedContractEvent, + event_classes::{ + ContractAcceptedPayload, ContractBidPayload, ContractDeliveredPayload, + ContractDisputedPayload, ContractExecutingPayload, ContractPaidPayload, + ContractProposedPayload, ContractVerifiedPayload, EVENT_CONTRACT_ACCEPTED, + EVENT_CONTRACT_BID, EVENT_CONTRACT_DELIVERED, EVENT_CONTRACT_DISPUTED, + EVENT_CONTRACT_EXECUTING, EVENT_CONTRACT_PAID, EVENT_CONTRACT_PROPOSED, + EVENT_CONTRACT_VERIFIED, + }, + signing::ContractSigningKey, +}; + +/// Synthetic clock — the test fixes signed_at_unix_ms so the JSON +/// round-trip is bit-exact reproducible. +const T0: i64 = 1_779_800_000_000; + +/// Two-peer worked example: peer-a proposes, peer-b bids + executes. +struct Peers { + proposer: ContractSigningKey, + executor: ContractSigningKey, +} + +fn make_peers() -> Peers { + Peers { + proposer: ContractSigningKey::generate(), + executor: ContractSigningKey::generate(), + } +} + +#[test] +fn full_chain_proposed_to_paid_verifies_end_to_end() { + let peers = make_peers(); + let contract_id = "c-ping-001".to_string(); + let alloy_hash = "sha256:ping-contract-alloy-stub".to_string(); + + // 1. proposer publishes + let proposed = SignedContractEvent::sign( + EVENT_CONTRACT_PROPOSED, + ContractProposedPayload { + contract_id: contract_id.clone(), + proposer_id: "peer-a".into(), + alloy_hash: alloy_hash.clone(), + bid_currency: String::new(), + max_bid: 0, + expiry_unix_ms: T0 + 60_000, + required_capability: "inference:ping".into(), + }, + &peers.proposer, + T0, + ) + .unwrap(); + proposed.verify().expect("proposed must verify"); + + // 2. executor bids + let bid = SignedContractEvent::sign( + EVENT_CONTRACT_BID, + ContractBidPayload { + contract_id: contract_id.clone(), + bidder_id: "peer-b".into(), + bid_amount: 0, + max_latency_ms: 50, + bid_expiry_unix_ms: T0 + 30_000, + }, + &peers.executor, + T0 + 100, + ) + .unwrap(); + bid.verify().expect("bid must verify"); + + // 3. proposer accepts (pins the bid hash so the chain is unambiguous) + let bid_hash_hex = bid.signature_hex.clone(); // bid sig serves as a stable bid identifier + let accepted = SignedContractEvent::sign( + EVENT_CONTRACT_ACCEPTED, + ContractAcceptedPayload { + contract_id: contract_id.clone(), + proposer_id: "peer-a".into(), + accepted_bidder_id: "peer-b".into(), + accepted_bid_hash: bid_hash_hex, + }, + &peers.proposer, + T0 + 200, + ) + .unwrap(); + accepted.verify().expect("accepted must verify"); + + // 4. executor signs "started" + let executing = SignedContractEvent::sign( + EVENT_CONTRACT_EXECUTING, + ContractExecutingPayload { + contract_id: contract_id.clone(), + executor_id: "peer-b".into(), + started_at_unix_ms: T0 + 300, + }, + &peers.executor, + T0 + 300, + ) + .unwrap(); + executing.verify().expect("executing must verify"); + + // 5. executor signs delivered artifact + let delivered = SignedContractEvent::sign( + EVENT_CONTRACT_DELIVERED, + ContractDeliveredPayload { + contract_id: contract_id.clone(), + executor_id: "peer-b".into(), + delivered_alloy_hash: alloy_hash.clone(), + artifact_url: Some("pong".into()), + }, + &peers.executor, + T0 + 400, + ) + .unwrap(); + delivered.verify().expect("delivered must verify"); + + // 6. proposer (acting as verifier) signs verdict + let verified = SignedContractEvent::sign( + EVENT_CONTRACT_VERIFIED, + ContractVerifiedPayload { + contract_id: contract_id.clone(), + verifier_id: "peer-a".into(), + passed: true, + verdict_reason: "ping matched expected pong".into(), + }, + &peers.proposer, + T0 + 500, + ) + .unwrap(); + verified.verify().expect("verified must verify"); + + // 7. proposer signs the settlement (zero-LP household — amount 0) + let paid = SignedContractEvent::sign( + EVENT_CONTRACT_PAID, + ContractPaidPayload { + contract_id: contract_id.clone(), + payer_id: "peer-a".into(), + payee_id: "peer-b".into(), + amount: 0, + currency: String::new(), + settlement_ref: None, + }, + &peers.proposer, + T0 + 600, + ) + .unwrap(); + paid.verify().expect("paid must verify"); +} + +#[test] +fn disputed_event_signs_and_verifies() { + let peers = make_peers(); + + let disputed = SignedContractEvent::sign( + EVENT_CONTRACT_DISPUTED, + ContractDisputedPayload { + contract_id: "c-ping-002".into(), + disputer_id: "peer-b".into(), + reason: "verifier marked failed but artifact matched alloy_hash".into(), + disputed_event_hash: Some("verified-event-hex-stub".into()), + }, + &peers.executor, + T0 + 700, + ) + .unwrap(); + + let pubkey = disputed.verify().unwrap(); + assert_eq!(pubkey.to_bytes(), peers.executor.verifying_key().to_bytes()); +} + +#[test] +fn full_chain_round_trips_through_json_bit_exact() { + // Each event's JSON serialization must round-trip identical bytes — + // this is what makes airc-cursor replay reproducible across peers. + let peers = make_peers(); + + let proposed = SignedContractEvent::sign( + EVENT_CONTRACT_PROPOSED, + ContractProposedPayload { + contract_id: "c-bitexact-001".into(), + proposer_id: "peer-a".into(), + alloy_hash: "sha256:any".into(), + bid_currency: String::new(), + max_bid: 0, + expiry_unix_ms: T0 + 60_000, + required_capability: "inference:ping".into(), + }, + &peers.proposer, + T0, + ) + .unwrap(); + + let json_a = serde_json::to_string(&proposed).unwrap(); + let restored: SignedContractEvent = + serde_json::from_str(&json_a).unwrap(); + let json_b = serde_json::to_string(&restored).unwrap(); + assert_eq!(json_a, json_b, "JSON round-trip must be bit-exact"); + + // And the restored envelope's signature still verifies — proves the + // wire form lossless-round-trips the canonical bytes. + restored.verify().unwrap(); +} diff --git a/src/workers/continuum-core/src/contracts/envelope.rs b/src/workers/continuum-core/src/contracts/envelope.rs new file mode 100644 index 000000000..d971851b1 --- /dev/null +++ b/src/workers/continuum-core/src/contracts/envelope.rs @@ -0,0 +1,357 @@ +//! Signed contract event envelope wrapper. +//! +//! Roadmap item L1-6 (see docs/grid/GRID-MIGRATION-ROADMAP.md). +//! Spec: GRID-BUS-ARCHITECTURE §4.4 + MULTI-PEER-COMMANDS §7. +//! +//! Every contract event on the wire is a `SignedContractEvent

` where +//! `P` is one of the 8 payload types from `event_classes.rs`. The +//! envelope carries: +//! - `event_name`: which class (`contract:proposed`, etc.) — pinned +//! into the signed bytes so an envelope can't be relabeled. +//! - `payload`: the typed event-specific fields. +//! - `signer_pubkey`: the 32-byte ed25519 public key (hex-encoded on +//! the wire). Verifies the signature. +//! - `signature`: 64-byte ed25519 signature (hex-encoded on the wire) +//! over `canonical_hash(event_name, payload)`. +//! - `signed_at_unix_ms`: signer's wall-clock at sign time (audit-only; +//! replay does NOT consult clock skew between peers). +//! +//! The signed bytes pin `event_name` + `payload` together so a +//! malicious replay can't take a valid `bid` signature and present it +//! as a `proposed`. The envelope itself carries the signature; verify +//! recomputes the canonical hash from `(event_name, payload)` and +//! checks against the signer's pubkey. + +use crate::contracts::signing::{ + canonical_hash, ContractSigningKey, ContractVerifyingKey, SigningError, +}; +use serde::{Deserialize, Serialize}; + +/// Canonical "what gets signed" intermediate. Carries `event_name` +/// alongside the payload so the signature pins both — relabeling +/// attacks (taking a bid sig and presenting it as a proposed) fail +/// signature verification. +/// +/// Private to this module — callers go through `SignedContractEvent::sign` +/// + `::verify`, not by constructing this directly. +#[derive(Debug, Serialize)] +struct SignedBody<'a, P: Serialize> { + event_name: &'a str, + payload: &'a P, +} + +/// A typed, signed contract event envelope. +/// +/// Generic over the payload type `P` so each of the 8 event classes +/// gets its own concrete type at the use site — no `Vec` opaque +/// payloads, no `serde_json::Value` runtime-type dispatch. +/// +/// Wire format (camelCase JSON): +/// ```json +/// { +/// "eventName": "contract:proposed", +/// "payload": { ... payload fields ... }, +/// "signerPubkeyHex": "ab12...", +/// "signatureHex": "cd34...", +/// "signedAtUnixMs": 1779800000000 +/// } +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SignedContractEvent

{ + pub event_name: String, + pub payload: P, + /// Hex-encoded 32-byte ed25519 public key. ts-rs sees this as + /// `string` via the host envelope module's manual mapping — + /// signing keys never cross the wire, only pubkeys. + pub signer_pubkey_hex: String, + /// Hex-encoded 64-byte ed25519 signature over the canonical + /// (event_name, payload) hash. + pub signature_hex: String, + /// Wall-clock at sign time. Audit-only; verify does NOT consult. + pub signed_at_unix_ms: i64, +} + +impl

SignedContractEvent

+where + P: Serialize, +{ + /// Build a fresh signed envelope. Computes the canonical hash of + /// `(event_name, payload)`, signs it with `signing_key`, and + /// returns the populated envelope. + pub fn sign( + event_name: impl Into, + payload: P, + signing_key: &ContractSigningKey, + signed_at_unix_ms: i64, + ) -> Result { + let event_name = event_name.into(); + let body = SignedBody { + event_name: &event_name, + payload: &payload, + }; + let hash = canonical_hash(&body)?; + let signature = signing_key.sign(&hash); + let pubkey = signing_key.verifying_key(); + Ok(Self { + event_name, + payload, + signer_pubkey_hex: hex_encode(&pubkey.to_bytes()), + signature_hex: hex_encode(&signature), + signed_at_unix_ms, + }) + } +} + +impl

SignedContractEvent

+where + P: Serialize + for<'de> Deserialize<'de>, +{ + /// Verify the envelope's signature. + /// + /// Recomputes `canonical_hash(event_name, payload)` from THIS + /// envelope's fields — does NOT trust any cached digest. Decodes + /// the embedded pubkey + signature, checks the ed25519 verify. + /// + /// Returns `Ok(verified_pubkey)` on success — the caller then + /// cross-checks the verified pubkey against the L1-4 + /// `presence:peer-manifest` index to confirm the signer's identity + /// matches what they claim in the payload (`proposer_id`, + /// `bidder_id`, etc.). That cross-check is L1-6 Phase B and lives + /// in a downstream replay handler — this layer just gives back + /// "yes, this 32-byte pubkey signed these bytes." + pub fn verify(&self) -> Result { + let pubkey_bytes = hex_decode(&self.signer_pubkey_hex)?; + let signature_bytes = hex_decode(&self.signature_hex)?; + let pubkey = ContractVerifyingKey::from_bytes(&pubkey_bytes)?; + + // Reconstruct the SAME body shape that sign() hashed. + let body = SignedBody { + event_name: &self.event_name, + payload: &self.payload, + }; + let hash = canonical_hash(&body)?; + + pubkey.verify(&hash, &signature_bytes)?; + Ok(pubkey) + } +} + +// ─── Hex encoding helpers ───────────────────────────────────────────────── +// +// Keep tiny + local rather than pulling in the `hex` crate just for this. +// 32-byte pubkeys + 64-byte signatures both round-trip exactly. + +fn hex_encode(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push(nibble(b >> 4)); + s.push(nibble(b & 0x0F)); + } + s +} + +fn hex_decode(s: &str) -> Result, SigningError> { + if !s.len().is_multiple_of(2) { + return Err(SigningError::PayloadSerialization(format!( + "hex string length {} is not even", + s.len(), + ))); + } + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(s.len() / 2); + for chunk in bytes.chunks(2) { + let hi = un_nibble(chunk[0])?; + let lo = un_nibble(chunk[1])?; + out.push((hi << 4) | lo); + } + Ok(out) +} + +fn nibble(n: u8) -> char { + match n { + 0..=9 => (b'0' + n) as char, + 10..=15 => (b'a' + n - 10) as char, + _ => unreachable!("nibble fits in 4 bits"), + } +} + +fn un_nibble(c: u8) -> Result { + match c { + b'0'..=b'9' => Ok(c - b'0'), + b'a'..=b'f' => Ok(c - b'a' + 10), + b'A'..=b'F' => Ok(c - b'A' + 10), + _ => Err(SigningError::PayloadSerialization(format!( + "invalid hex char: 0x{c:02x}", + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::contracts::event_classes::{ + ContractBidPayload, ContractProposedPayload, EVENT_CONTRACT_BID, EVENT_CONTRACT_PROPOSED, + }; + + fn sample_proposed() -> ContractProposedPayload { + ContractProposedPayload { + contract_id: "c-l1-6-test-001".into(), + proposer_id: "peer-a".into(), + alloy_hash: "sha256:dead...beef".into(), + bid_currency: "".into(), + max_bid: 0, + expiry_unix_ms: 1_779_800_000_000, + required_capability: "inference:ping".into(), + } + } + + fn sample_bid() -> ContractBidPayload { + ContractBidPayload { + contract_id: "c-l1-6-test-001".into(), + bidder_id: "peer-b".into(), + bid_amount: 0, + max_latency_ms: 100, + bid_expiry_unix_ms: 1_779_810_000_000, + } + } + + #[test] + fn sign_then_verify_roundtrips() { + + let sk = ContractSigningKey::generate(); + + let envelope = SignedContractEvent::sign( + EVENT_CONTRACT_PROPOSED, + sample_proposed(), + &sk, + 1_779_800_000_000, + ) + .unwrap(); + + let verified_pubkey = envelope.verify().expect("fresh envelope must verify"); + assert_eq!(verified_pubkey.to_bytes(), sk.verifying_key().to_bytes()); + } + + #[test] + fn relabeling_attack_fails() { + // Sign a payload as `contract:bid`, then relabel the envelope + // to `contract:proposed` and try to verify — must fail. + + let sk = ContractSigningKey::generate(); + + let envelope = SignedContractEvent::sign( + EVENT_CONTRACT_BID, + sample_bid(), + &sk, + 1_779_800_000_000, + ) + .unwrap(); + + let mut tampered = envelope.clone(); + tampered.event_name = EVENT_CONTRACT_PROPOSED.into(); + + let err = tampered.verify().unwrap_err(); + assert!(matches!(err, SigningError::VerificationFailed { .. })); + } + + #[test] + fn payload_mutation_fails_verify() { + + let sk = ContractSigningKey::generate(); + + let envelope = SignedContractEvent::sign( + EVENT_CONTRACT_PROPOSED, + sample_proposed(), + &sk, + 1_779_800_000_000, + ) + .unwrap(); + + let mut tampered = envelope.clone(); + tampered.payload.max_bid = 9999; + + let err = tampered.verify().unwrap_err(); + assert!(matches!(err, SigningError::VerificationFailed { .. })); + } + + #[test] + fn signature_mutation_fails_verify() { + + let sk = ContractSigningKey::generate(); + + let envelope = SignedContractEvent::sign( + EVENT_CONTRACT_PROPOSED, + sample_proposed(), + &sk, + 1_779_800_000_000, + ) + .unwrap(); + + let mut tampered = envelope.clone(); + // Flip the LAST hex char so the byte mutates without changing length. + let last = tampered.signature_hex.pop().unwrap(); + let flipped = if last == '0' { '1' } else { '0' }; + tampered.signature_hex.push(flipped); + + let err = tampered.verify().unwrap_err(); + assert!(matches!(err, SigningError::VerificationFailed { .. })); + } + + #[test] + fn pubkey_swap_fails_verify() { + + let sk_a = ContractSigningKey::generate(); + let sk_b = ContractSigningKey::generate(); + + let envelope = SignedContractEvent::sign( + EVENT_CONTRACT_PROPOSED, + sample_proposed(), + &sk_a, + 1_779_800_000_000, + ) + .unwrap(); + + let mut tampered = envelope.clone(); + tampered.signer_pubkey_hex = hex_encode(&sk_b.verifying_key().to_bytes()); + + let err = tampered.verify().unwrap_err(); + assert!(matches!(err, SigningError::VerificationFailed { .. })); + } + + #[test] + fn envelope_round_trips_through_json() { + + let sk = ContractSigningKey::generate(); + + let envelope = SignedContractEvent::sign( + EVENT_CONTRACT_PROPOSED, + sample_proposed(), + &sk, + 1_779_800_000_000, + ) + .unwrap(); + + let json = serde_json::to_string(&envelope).unwrap(); + let restored: SignedContractEvent = + serde_json::from_str(&json).unwrap(); + + // Restored envelope still verifies — wire round-trip is bit-exact. + let verified_pubkey = restored.verify().unwrap(); + assert_eq!(verified_pubkey.to_bytes(), sk.verifying_key().to_bytes()); + } + + #[test] + fn hex_helpers_round_trip() { + let original: Vec = (0u8..=255u8).collect(); + let encoded = hex_encode(&original); + let decoded = hex_decode(&encoded).unwrap(); + assert_eq!(original, decoded); + } + + #[test] + fn hex_decode_rejects_bad_input() { + assert!(hex_decode("abc").is_err()); // odd length + assert!(hex_decode("xy").is_err()); // non-hex chars + } +} diff --git a/src/workers/continuum-core/src/contracts/event_classes.rs b/src/workers/continuum-core/src/contracts/event_classes.rs new file mode 100644 index 000000000..8a81d197d --- /dev/null +++ b/src/workers/continuum-core/src/contracts/event_classes.rs @@ -0,0 +1,302 @@ +//! The 8 contract event class names + their payload types. +//! +//! Roadmap item L1-6 (see docs/grid/GRID-MIGRATION-ROADMAP.md). +//! Spec: GRID-BUS-ARCHITECTURE §4.4 + MULTI-PEER-COMMANDS §7. +//! +//! These are the on-the-wire event class names that `declare_contract_event_classes` +//! registers with the L1-1 `EventClassRegistry` at startup. Once declared, +//! `Events.emit('contract:proposed', payload)` (TS side) or +//! `event_class_registry().resolve_channel('contract:proposed', payload)` +//! (Rust side) route the event onto the appropriate airc channel. +//! +//! ## Chain shape +//! +//! ```text +//! contract:proposed — proposer publishes terms + signs +//! │ +//! ▼ +//! contract:bid — interested executor publishes their bid, signs +//! │ +//! ▼ +//! contract:accepted — proposer picks one bid, signs the acceptance +//! │ +//! ▼ +//! contract:executing — executor signs "started work" (optional, observability) +//! │ +//! ▼ +//! contract:delivered — executor signs the delivered artifact + alloy_hash +//! │ +//! ▼ +//! contract:verified — proposer (or auditor) signs verification result +//! │ +//! ▼ +//! contract:paid — payer signs the settlement (zero-LP household = OK) +//! │ +//! ▼ (only when a participant disputes) +//! contract:disputed — any signer can file with reason + sig +//! ``` +//! +//! Every event carries the same `contract_id` so the airc cursor replay +//! can stitch the chain together from a single-channel scan. + +use crate::events::EventClassConfig; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +// ─── Event class names (constants — string-typed, used as keys into L1-1) ── + +pub const EVENT_CONTRACT_PROPOSED: &str = "contract:proposed"; +pub const EVENT_CONTRACT_BID: &str = "contract:bid"; +pub const EVENT_CONTRACT_ACCEPTED: &str = "contract:accepted"; +pub const EVENT_CONTRACT_EXECUTING: &str = "contract:executing"; +pub const EVENT_CONTRACT_DELIVERED: &str = "contract:delivered"; +pub const EVENT_CONTRACT_VERIFIED: &str = "contract:verified"; +pub const EVENT_CONTRACT_PAID: &str = "contract:paid"; +pub const EVENT_CONTRACT_DISPUTED: &str = "contract:disputed"; + +/// All 8 names in canonical order. Used by `declare_contract_event_classes` +/// to batch-register and by tests to verify completeness. +pub const ALL_CONTRACT_EVENT_NAMES: &[&str] = &[ + EVENT_CONTRACT_PROPOSED, + EVENT_CONTRACT_BID, + EVENT_CONTRACT_ACCEPTED, + EVENT_CONTRACT_EXECUTING, + EVENT_CONTRACT_DELIVERED, + EVENT_CONTRACT_VERIFIED, + EVENT_CONTRACT_PAID, + EVENT_CONTRACT_DISPUTED, +]; + +/// Wire-format schema version for the contract event chain. Bump when +/// any payload shape changes incompatibly; subscribers honor the +/// L1-1 `onUnknownSchema: Fail` default, so a bump that isn't rolled +/// out to all peers will trip a visible error rather than silently +/// drop events. +pub const CONTRACT_SCHEMA_VERSION: &str = "v1"; + +// ─── Payload types ──────────────────────────────────────────────────────── +// +// Each payload carries `contract_id` (string — chain-correlation key) +// plus its event-specific fields. The payload is what +// `signing::canonical_hash` runs over to produce the bytes that get +// signed; the signature lives in the surrounding `SignedContractEvent` +// envelope (see `envelope.rs`). + +/// `contract:proposed` — initiator publishes a contract for bidding. +/// +/// `alloy_hash` references the substance of what's being contracted — +/// matches the proof-contract layer in +/// `docs/grid/FORGE-ALLOY-PROOF-CONTRACTS.md`. For pre-alloy use cases +/// (e.g. a `ping` dispatch with no proof bundle) the hash references +/// a synthetic "ping contract" alloy with no proof suite. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../../shared/generated/contracts/ContractProposedPayload.ts")] +pub struct ContractProposedPayload { + pub contract_id: String, + pub proposer_id: String, + /// SHA-256 reference to the alloy bundle describing the work. + /// Hex-encoded for human readability + ts-rs `string` mapping. + pub alloy_hash: String, + /// Currency/escrow terms. Zero-cost ("household") tier = empty + /// `bid_currency` + zero `max_bid`. + pub bid_currency: String, + pub max_bid: u64, + /// Expiry (Unix ms). After this point the proposal is dead even + /// if no `:accepted` was ever emitted. + pub expiry_unix_ms: i64, + /// Required executor capability tag — matches the L1-4 + /// `presence:peer-manifest` capability index format. + pub required_capability: String, +} + +/// `contract:bid` — an executor's offer to take on a proposed contract. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../../shared/generated/contracts/ContractBidPayload.ts")] +pub struct ContractBidPayload { + pub contract_id: String, + pub bidder_id: String, + pub bid_amount: u64, + /// Bidder's promised SLA (max latency in ms). Proposer uses this + /// in the bid-selection policy (lower latency + lower bid wins, + /// per the policy engine). + pub max_latency_ms: u32, + /// Bidder's expiry — how long this bid is honored if accepted. + pub bid_expiry_unix_ms: i64, +} + +/// `contract:accepted` — proposer's signed selection of one bidder. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../../shared/generated/contracts/ContractAcceptedPayload.ts")] +pub struct ContractAcceptedPayload { + pub contract_id: String, + pub proposer_id: String, + pub accepted_bidder_id: String, + /// Hash of the accepted bid envelope — pins exactly which bid was + /// taken (defense against bid-rewrite attacks where two bids share + /// a contract_id). + pub accepted_bid_hash: String, +} + +/// `contract:executing` — executor's signed "work started" beacon. +/// Optional event (the chain stays valid without it) but used by the +/// router daemon to mark a routing slot as in-use. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../../shared/generated/contracts/ContractExecutingPayload.ts")] +pub struct ContractExecutingPayload { + pub contract_id: String, + pub executor_id: String, + pub started_at_unix_ms: i64, +} + +/// `contract:delivered` — executor's signed assertion that the work is +/// done. Carries the alloy_hash of the actual artifact (which the +/// proposer compares against the originally-proposed alloy_hash to +/// detect bait-and-switch). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../../shared/generated/contracts/ContractDeliveredPayload.ts")] +pub struct ContractDeliveredPayload { + pub contract_id: String, + pub executor_id: String, + /// Hash of the delivered artifact (may differ from the proposed + /// alloy_hash if the executor produced a SPECIFIC output that + /// satisfies the proposed CONTRACT). + pub delivered_alloy_hash: String, + /// Optional location pointer (URL, IPFS CID, etc.) for fetching + /// the artifact bytes. The hash is the canonical reference; this + /// is convenience. + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub artifact_url: Option, +} + +/// `contract:verified` — proposer (or auditor) signs the verification +/// verdict. Carries the result of running the alloy proof suite +/// against the delivered artifact. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../../shared/generated/contracts/ContractVerifiedPayload.ts")] +pub struct ContractVerifiedPayload { + pub contract_id: String, + pub verifier_id: String, + /// `passed: true` ⇒ proof suite ran clean; `false` ⇒ at least one + /// TDD assertion failed or a VDD metric was outside the tolerance + /// band. Verifier signs either way — disputes happen via + /// `contract:disputed`, not by withholding `:verified`. + pub passed: bool, + /// Concise reason string for the verdict — full details belong in + /// a separate report referenced by alloy_hash. + pub verdict_reason: String, +} + +/// `contract:paid` — payer's signed settlement record. For the +/// zero-cost household tier this is still emitted (audit completeness) +/// with `amount: 0`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../../shared/generated/contracts/ContractPaidPayload.ts")] +pub struct ContractPaidPayload { + pub contract_id: String, + pub payer_id: String, + pub payee_id: String, + pub amount: u64, + pub currency: String, + /// Optional settlement reference (chain tx hash, internal ledger + /// entry id, etc.). Not load-bearing for replay; just provenance. + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub settlement_ref: Option, +} + +/// `contract:disputed` — any signer can file. Replay reproduces every +/// disputed contract for auditor review. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../../shared/generated/contracts/ContractDisputedPayload.ts")] +pub struct ContractDisputedPayload { + pub contract_id: String, + pub disputer_id: String, + pub reason: String, + /// Optional reference to the specific prior event being disputed + /// (e.g. the verified-hash if the disputer claims wrong verdict). + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub disputed_event_hash: Option, +} + +// ─── EventClass registration helper ─────────────────────────────────────── + +/// Register all 8 contract event classes with the L1-1 registry. +/// +/// Idempotent: safe to call from multiple init paths; conflicting +/// re-declarations throw per the L1-1 contract-integrity rule. +/// +/// Channel choice: all 8 use `Global` — contract events are +/// mesh-visible by design (the trust substrate REQUIRES that everyone +/// can audit-replay the chain). Future tiered contracts (private to a +/// circle, e.g. trusted-orgs) could shift to a private channel via a +/// separate event-class declaration; that's an L4-Phase-C decision, +/// not L1-6. +pub fn declare_contract_event_classes() -> Result { + use crate::events::declare_event_class; + use crate::events::EventClassChannelStrategy; + + let mut declared = 0; + for name in ALL_CONTRACT_EVENT_NAMES { + let cfg = EventClassConfig { + broadcast: true, + channel: Some(EventClassChannelStrategy::Global), + schema_version: CONTRACT_SCHEMA_VERSION.to_string(), + on_unknown_schema: None, // defaults to Fail + description: Some(format!("L1-6 contract event chain — {name}")), + }; + declare_event_class(name, &cfg).map_err(|e| { + format!("L1-6: failed to declare event class '{name}': {e}") + })?; + declared += 1; + } + Ok(declared) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::events::lookup_event_class; + + #[test] + fn all_8_names_are_distinct() { + let mut seen = std::collections::HashSet::new(); + for name in ALL_CONTRACT_EVENT_NAMES { + assert!(seen.insert(*name), "duplicate name: {name}"); + } + assert_eq!(seen.len(), 8); + } + + #[test] + fn all_names_use_contract_prefix() { + for name in ALL_CONTRACT_EVENT_NAMES { + assert!(name.starts_with("contract:"), "bad name: {name}"); + } + } + + #[test] + fn declare_registers_all_eight() { + // Note: registry is process-global — if another test in this + // crate already declared with the same names + same config, + // declare_contract_event_classes is idempotent and still passes. + let count = declare_contract_event_classes().expect("declare must succeed"); + assert_eq!(count, 8); + + for name in ALL_CONTRACT_EVENT_NAMES { + let cfg = lookup_event_class(name).unwrap_or_else(|| { + panic!("class '{name}' was declared but lookup returned None") + }); + assert!(cfg.broadcast, "{name} must be broadcast"); + assert_eq!(cfg.schema_version, CONTRACT_SCHEMA_VERSION); + } + } +} diff --git a/src/workers/continuum-core/src/contracts/mod.rs b/src/workers/continuum-core/src/contracts/mod.rs new file mode 100644 index 000000000..a7da221f6 --- /dev/null +++ b/src/workers/continuum-core/src/contracts/mod.rs @@ -0,0 +1,43 @@ +//! L1-6 contract event chain + ed25519 signing. +//! +//! Roadmap item L1-6 (see docs/grid/GRID-MIGRATION-ROADMAP.md). +//! Spec: GRID-BUS-ARCHITECTURE §4.4 + MULTI-PEER-COMMANDS §7. +//! +//! Three layers, native-truth-thin-SDK pattern: +//! +//! 1. `signing` — ed25519 primitives (matches `airc-protocol = "2"`). +//! Keypair generation, sign, verify, canonical SHA-256 hashing. +//! 2. `event_classes` — the 8 contract event class names + payloads, +//! plus `declare_contract_event_classes()` that registers them +//! with the L1-1 `EventClassRegistry`. +//! 3. `envelope` — the `SignedContractEvent

` wrapper that pairs +//! a typed payload with `event_name` + `signer_pubkey_hex` + +//! `signature_hex`. Signature pins `(event_name, payload)` +//! together so relabeling attacks fail verification. +//! +//! Phase A (this PR): primitives + types + declarations + unit tests. +//! Phase B (next): pubkey lookup against L1-4's `presence:peer-manifest`, +//! verify-on-replay handler over L1-2's `AircEventTransport`. + +pub mod envelope; +pub mod event_classes; +pub mod signing; + +#[cfg(test)] +mod chain_tests; + +pub use envelope::SignedContractEvent; +pub use event_classes::{ + declare_contract_event_classes, + ContractAcceptedPayload, ContractBidPayload, ContractDeliveredPayload, + ContractDisputedPayload, ContractExecutingPayload, ContractPaidPayload, + ContractProposedPayload, ContractVerifiedPayload, + ALL_CONTRACT_EVENT_NAMES, CONTRACT_SCHEMA_VERSION, + EVENT_CONTRACT_ACCEPTED, EVENT_CONTRACT_BID, EVENT_CONTRACT_DELIVERED, + EVENT_CONTRACT_DISPUTED, EVENT_CONTRACT_EXECUTING, EVENT_CONTRACT_PAID, + EVENT_CONTRACT_PROPOSED, EVENT_CONTRACT_VERIFIED, +}; +pub use signing::{ + canonical_hash, ContractSigningKey, ContractVerifyingKey, SigningError, + CANONICAL_HASH_LEN, PUBLIC_KEY_LEN, SIGNATURE_LEN, +}; diff --git a/src/workers/continuum-core/src/contracts/signing.rs b/src/workers/continuum-core/src/contracts/signing.rs new file mode 100644 index 000000000..cab014097 --- /dev/null +++ b/src/workers/continuum-core/src/contracts/signing.rs @@ -0,0 +1,381 @@ +//! ed25519 signing primitives for L1-6 contract event envelopes. +//! +//! Roadmap item L1-6 (see docs/grid/GRID-MIGRATION-ROADMAP.md). +//! Spec: GRID-BUS-ARCHITECTURE §4.4 + MULTI-PEER-COMMANDS §7. +//! +//! Matches the `ed25519-dalek = "2"` choice in `airc-protocol` so peer +//! signing keys advertised through L1-4's `presence:peer-manifest` use +//! the SAME byte layout that this module verifies. No re-encoding, +//! no protocol bridging. +//! +//! Scope (Phase A — buildable independent of L1-4): +//! - Key types: `ContractSigningKey` (private), `ContractVerifyingKey` (public). +//! - `sign(payload_bytes)` / `verify(payload_bytes, sig, pubkey)`. +//! - `canonical_hash(payload)`: SHA-256 of the canonicalized payload +//! bytes — the deterministic substance the signature commits to. +//! - Errors are explicit (`SigningError`); no silent fail-soft paths. +//! +//! Phase B (deferred to a follow-up PR once L1-4 lands): +//! - Pubkey lookup against the per-peer manifest index. +//! - Verify-on-replay handler that pulls pubkeys at event-receipt time. + +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +/// Length in bytes of an ed25519 signature. +pub const SIGNATURE_LEN: usize = 64; + +/// Length in bytes of an ed25519 public key. +pub const PUBLIC_KEY_LEN: usize = 32; + +/// Length in bytes of the SHA-256 canonical hash. +pub const CANONICAL_HASH_LEN: usize = 32; + +/// Errors raised by L1-6 signing / verification. +/// +/// Every variant carries enough context for a debugger to root-cause — +/// per the global never-swallow-evidence rule, callers must surface +/// these (not silently fall back to "not verified"). +#[derive(Debug, Error)] +pub enum SigningError { + #[error("ed25519 signature is the wrong length: expected {expected}, got {got}")] + SignatureLength { expected: usize, got: usize }, + + #[error("ed25519 public key is the wrong length: expected {expected}, got {got}")] + PublicKeyLength { expected: usize, got: usize }, + + #[error("ed25519 public key bytes are not a valid point on the curve")] + InvalidPublicKey, + + #[error("ed25519 signature verification failed for {bytes_signed} bytes of payload")] + VerificationFailed { bytes_signed: usize }, + + #[error("payload serialization failed during canonical-hash computation: {0}")] + PayloadSerialization(String), +} + +/// A privately-held ed25519 signing key. Wrapper around +/// `ed25519_dalek::SigningKey` so future migrations (HSM, secure enclave) +/// can swap the backing store without touching call sites. +/// +/// Not `Serialize` / `Deserialize` on purpose — signing keys are +/// per-process secrets, never on the wire. The corresponding +/// [`ContractVerifyingKey`] IS serializable (it's the public half). +pub struct ContractSigningKey { + inner: SigningKey, +} + +impl std::fmt::Debug for ContractSigningKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Don't print key bytes. Show only the corresponding pubkey + // (which is public anyway) so logs aren't useless. + f.debug_struct("ContractSigningKey") + .field("verifying_key", &self.verifying_key()) + .finish() + } +} + +impl ContractSigningKey { + /// Generate a fresh keypair using the OS CSPRNG (`rand::rngs::OsRng`). + /// + /// Wrapped here (rather than exposing a generic RNG parameter) so + /// callers don't accidentally pass `thread_rng()` — which is fast + /// but NOT a CSPRNG and therefore unsuitable for long-lived + /// signing keys. The OS RNG is the right default for every L1-6 + /// keygen path; HSM-backed key import goes through `from_bytes`. + pub fn generate() -> Self { + use rand::rngs::OsRng; + Self { + inner: SigningKey::generate(&mut OsRng), + } + } + + /// Construct from raw 32 bytes (e.g. loaded from disk / HSM). + /// Used by call sites that already have the secret material. + pub fn from_bytes(bytes: &[u8; 32]) -> Self { + Self { + inner: SigningKey::from_bytes(bytes), + } + } + + /// The corresponding public key — safe to share with peers (this is + /// what L1-4's `presence:peer-manifest` advertises). + pub fn verifying_key(&self) -> ContractVerifyingKey { + ContractVerifyingKey { + inner: self.inner.verifying_key(), + } + } + + /// Sign the canonical bytes. Returns the 64-byte ed25519 signature. + /// + /// Determinism: ed25519 signatures are deterministic per (key, + /// message). Two signs of the same payload by the same key produce + /// byte-identical signatures — important for replay-equivalence + /// checks in the L1-6 audit-replay path. + pub fn sign(&self, canonical_bytes: &[u8]) -> [u8; SIGNATURE_LEN] { + self.inner.sign(canonical_bytes).to_bytes() + } +} + +/// The public half of a signing key — appears on the wire (in +/// `presence:peer-manifest` and in signed envelopes' `signer_pubkey` +/// field). Verifies signatures. +/// +/// The on-wire representation is the 32-byte compressed point, base64 +/// encoded by serde when crossing the JSON boundary. ts-rs sees it as +/// `string` (handled by the `#[ts(type = "string")]` attribute on the +/// envelope wrapper that contains it). +#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContractVerifyingKey { + /// Stored as the compressed-Edwards-point byte form. Round-trips + /// through JSON as a 32-byte sequence (or base64 if encoded that + /// way by the wrapper). + inner: VerifyingKey, +} + +impl std::fmt::Debug for ContractVerifyingKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let bytes = self.to_bytes(); + // Show first 4 + last 4 bytes hex for log identity without + // overwhelming output. Public bytes — no secrecy concern. + write!( + f, + "ContractVerifyingKey({:02x}{:02x}{:02x}{:02x}..{:02x}{:02x}{:02x}{:02x})", + bytes[0], bytes[1], bytes[2], bytes[3], + bytes[28], bytes[29], bytes[30], bytes[31], + ) + } +} + +impl ContractVerifyingKey { + /// Construct from raw 32 bytes. Validates the point is on-curve. + /// Returns `InvalidPublicKey` on bad bytes (e.g. tampered manifest). + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() != PUBLIC_KEY_LEN { + return Err(SigningError::PublicKeyLength { + expected: PUBLIC_KEY_LEN, + got: bytes.len(), + }); + } + let mut arr = [0u8; PUBLIC_KEY_LEN]; + arr.copy_from_slice(bytes); + let inner = VerifyingKey::from_bytes(&arr).map_err(|_| SigningError::InvalidPublicKey)?; + Ok(Self { inner }) + } + + /// 32-byte compressed-Edwards-point form. Round-trippable via + /// `from_bytes`. + pub fn to_bytes(&self) -> [u8; PUBLIC_KEY_LEN] { + self.inner.to_bytes() + } + + /// Verify a signature over the canonical bytes. Returns + /// `VerificationFailed` (not `Ok(false)`) on mismatch so callers + /// can't accidentally treat a failed verify as success — the only + /// way past this call is a real cryptographic match. + pub fn verify( + &self, + canonical_bytes: &[u8], + signature_bytes: &[u8], + ) -> Result<(), SigningError> { + if signature_bytes.len() != SIGNATURE_LEN { + return Err(SigningError::SignatureLength { + expected: SIGNATURE_LEN, + got: signature_bytes.len(), + }); + } + let mut arr = [0u8; SIGNATURE_LEN]; + arr.copy_from_slice(signature_bytes); + let sig = Signature::from_bytes(&arr); + self.inner.verify(canonical_bytes, &sig).map_err(|_| { + SigningError::VerificationFailed { + bytes_signed: canonical_bytes.len(), + } + }) + } +} + +/// Compute the canonical SHA-256 hash of a payload that's about to be +/// signed. +/// +/// Why a separate "canonical" step: ed25519 signs whatever bytes you +/// hand it. If we signed `serde_json::to_vec(&payload)` directly, two +/// serializers (or two builds with different feature flags) could +/// produce non-identical byte sequences for the same logical payload, +/// breaking verification. Canonicalization pins the byte sequence to +/// the SORTED-KEYS JSON form (`serde_json`'s default with a key-sorted +/// `BTreeMap` round-trip), then hashes — peers always sign the same +/// 32-byte digest regardless of build. +/// +/// Returns the 32-byte SHA-256 of the canonical bytes. +pub fn canonical_hash(payload: &T) -> Result<[u8; CANONICAL_HASH_LEN], SigningError> { + // 1. Serialize to JSON value (handles any T: Serialize). + let value = + serde_json::to_value(payload).map_err(|e| SigningError::PayloadSerialization(e.to_string()))?; + // 2. Reserialize through BTreeMap-backed Value to get key-sorted output. + // serde_json's Value uses BTreeMap when the `preserve_order` + // feature is OFF (default). So `to_vec(&value)` yields keys in + // lexicographic order. This is the canonical form. + let canonical_bytes = serde_json::to_vec(&value) + .map_err(|e| SigningError::PayloadSerialization(e.to_string()))?; + // 3. SHA-256 the canonical bytes. + let mut hasher = Sha256::new(); + hasher.update(&canonical_bytes); + let digest = hasher.finalize(); + let mut out = [0u8; CANONICAL_HASH_LEN]; + out.copy_from_slice(&digest); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Serialize, Deserialize)] + struct DummyPayload { + contract_id: String, + bid_zmw: u64, + peer: String, + } + + fn dummy() -> DummyPayload { + DummyPayload { + contract_id: "c-001".into(), + bid_zmw: 42, + peer: "peer-a".into(), + } + } + + #[test] + fn keygen_then_sign_then_verify_roundtrips() { + + let sk = ContractSigningKey::generate(); + let vk = sk.verifying_key(); + + let hash = canonical_hash(&dummy()).unwrap(); + let sig = sk.sign(&hash); + + vk.verify(&hash, &sig).expect("fresh signature must verify"); + } + + #[test] + fn pubkey_round_trips_through_bytes() { + + let sk = ContractSigningKey::generate(); + let vk = sk.verifying_key(); + + let bytes = vk.to_bytes(); + let restored = ContractVerifyingKey::from_bytes(&bytes).unwrap(); + assert_eq!(vk.to_bytes(), restored.to_bytes()); + + // Restored key still verifies signatures. + let hash = canonical_hash(&dummy()).unwrap(); + let sig = sk.sign(&hash); + restored.verify(&hash, &sig).unwrap(); + } + + #[test] + fn bad_signature_bytes_fail_loud() { + + let sk = ContractSigningKey::generate(); + let vk = sk.verifying_key(); + + let hash = canonical_hash(&dummy()).unwrap(); + let mut sig = sk.sign(&hash); + // Flip a single bit. Per ed25519, this MUST fail. + sig[0] ^= 0x01; + + let err = vk.verify(&hash, &sig).unwrap_err(); + assert!(matches!(err, SigningError::VerificationFailed { .. })); + } + + #[test] + fn wrong_payload_fails_loud() { + + let sk = ContractSigningKey::generate(); + let vk = sk.verifying_key(); + + let hash = canonical_hash(&dummy()).unwrap(); + let sig = sk.sign(&hash); + + // Sign payload A, verify against payload B — must fail. + let other_hash = canonical_hash(&DummyPayload { + contract_id: "c-001".into(), + bid_zmw: 43, // <-- changed + peer: "peer-a".into(), + }) + .unwrap(); + assert_ne!(hash, other_hash); + let err = vk.verify(&other_hash, &sig).unwrap_err(); + assert!(matches!(err, SigningError::VerificationFailed { .. })); + } + + #[test] + fn cross_key_verify_fails_loud() { + + let sk_a = ContractSigningKey::generate(); + let sk_b = ContractSigningKey::generate(); + + let hash = canonical_hash(&dummy()).unwrap(); + let sig_by_a = sk_a.sign(&hash); + + // B's pubkey must NOT verify A's signature. + let err = sk_b.verifying_key().verify(&hash, &sig_by_a).unwrap_err(); + assert!(matches!(err, SigningError::VerificationFailed { .. })); + } + + #[test] + fn signature_is_deterministic() { + + let sk = ContractSigningKey::generate(); + + let hash = canonical_hash(&dummy()).unwrap(); + let sig1 = sk.sign(&hash); + let sig2 = sk.sign(&hash); + assert_eq!(sig1, sig2, "ed25519 must be deterministic for replay-equivalence"); + } + + #[test] + fn canonical_hash_stable_across_field_order() { + // Even if a struct is serialized with fields in a different + // declaration order, the canonical hash must agree (because + // serde_json's default Value uses BTreeMap → key-sorted output). + #[derive(Serialize)] + struct Order1 { + a: u32, + z: u32, + } + #[derive(Serialize)] + struct Order2 { + z: u32, + a: u32, + } + let h1 = canonical_hash(&Order1 { a: 1, z: 2 }).unwrap(); + let h2 = canonical_hash(&Order2 { z: 2, a: 1 }).unwrap(); + assert_eq!(h1, h2, "canonical hash MUST be order-insensitive"); + } + + #[test] + fn signature_length_validation() { + + let vk = ContractSigningKey::generate().verifying_key(); + let err = vk.verify(b"anything", &[0u8; 63]).unwrap_err(); + assert!(matches!(err, SigningError::SignatureLength { expected: 64, got: 63 })); + } + + #[test] + fn pubkey_length_validation() { + let err = ContractVerifyingKey::from_bytes(&[0u8; 31]).unwrap_err(); + assert!(matches!(err, SigningError::PublicKeyLength { expected: 32, got: 31 })); + } + + // NOTE: Point-validation (rejecting 32 bytes that decompress off-curve) + // is delegated to `ed25519_dalek::VerifyingKey::from_bytes` — its own + // test suite covers curve-membership. We don't duplicate that here. + // Tampered-input coverage is exercised end-to-end by the envelope tests + // (`pubkey_swap_fails_verify` etc.), and length-mismatch is covered by + // `pubkey_length_validation` above. +} diff --git a/src/workers/continuum-core/src/lib.rs b/src/workers/continuum-core/src/lib.rs index 41b570a71..31b5b8eba 100644 --- a/src/workers/continuum-core/src/lib.rs +++ b/src/workers/continuum-core/src/lib.rs @@ -23,6 +23,7 @@ pub mod code; pub mod comms; pub mod cognition; pub mod concurrency; +pub mod contracts; pub mod events; pub mod ffi; pub mod forge;