From 5eade0dc47047d5668118f7a65a4e63fc67a978b Mon Sep 17 00:00:00 2001 From: iguberman Date: Wed, 27 May 2026 23:43:10 -0500 Subject: [PATCH 1/2] Redesign program: minimal-witness model, slim Shipment, drop Proof PDA + status --- Anchor.toml | 4 +- README.md | 97 +-- package.json | 1 + programs/device-registry/src/errors.rs | 12 +- programs/device-registry/src/events.rs | 25 +- .../src/instructions/assign_device.rs | 5 +- .../src/instructions/close_shipment.rs | 29 + .../src/instructions/create_shipment.rs | 19 +- .../device-registry/src/instructions/mod.rs | 6 +- .../src/instructions/submit_proof.rs | 57 +- .../instructions/update_shipment_status.rs | 34 - programs/device-registry/src/lib.rs | 42 +- programs/device-registry/src/state.rs | 86 +-- tests/device-registry.ts | 628 +++++++++++------- 14 files changed, 602 insertions(+), 443 deletions(-) create mode 100644 programs/device-registry/src/instructions/close_shipment.rs delete mode 100644 programs/device-registry/src/instructions/update_shipment_status.rs diff --git a/Anchor.toml b/Anchor.toml index 5291fe5..2a0d418 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -6,10 +6,10 @@ resolution = true skip-lint = false [programs.localnet] -device_registry = "APRu6WGxe1NC4X2FrcLpujRRtqLNfMTSt6fYp5wQZVtP" +device_registry = "APRBVwwJJeStD5wShyg4HivneDYj4TCPYKtSFX5F4jez" [programs.devnet] -device_registry = "APRu6WGxe1NC4X2FrcLpujRRtqLNfMTSt6fYp5wQZVtP" +device_registry = "APRBVwwJJeStD5wShyg4HivneDYj4TCPYKtSFX5F4jez" [registry] url = "https://api.apr.dev" diff --git a/README.md b/README.md index 10ce808..9067b3f 100644 --- a/README.md +++ b/README.md @@ -2,34 +2,42 @@ Solana on-chain programs for [April Gate](https://aprilgatehq.com) — tamper-evident cold chain verification for pharmaceutical cargo. -This repository contains the on-chain components: device registry, shipment lifecycle, immutable assignment history, and consensus commitment storage. The off-chain consensus engine lives in [coldchain-core](https://github.com/april-gate/coldchain-core). +This repository contains the on-chain components: device registry, shipment lifecycle, immutable assignment history, and consensus dispatch records. The off-chain consensus engine lives in [coldchain-core](https://github.com/april-gate/coldchain-core). + +> **Colosseum May 2026 submission:** [`colosseum-submission-may2026`](https://github.com/april-gate/coldchain-programs/releases/tag/colosseum-submission-may2026). Devnet program: `APRu6WGxe1NC4X2FrcLpujRRtqLNfMTSt6fYp5wQZVtP`. +> +> Main branch reflects active post-submission development. ## Program | Name | Program ID | |---|---| -| `device_registry` | `APRu6WGxe1NC4X2FrcLpujRRtqLNfMTSt6fYp5wQZVtP` | +| `device_registry` | `APRBVwwJJeStD5wShyg4HivneDYj4TCPYKtSFX5F4jez` | + +The program is pinned to this address on every cluster. The deploy keypair lives at `APRBVwwJJeStD5wShyg4HivneDYj4TCPYKtSFX5F4jez.json` at the repo root — its filename is its public key, so the keypair's identity is self-evident. + +## Architecture + +April Gate is structured as three layers, each with a distinct responsibility and trust model: -The program is pinned to this address on every cluster. The deploy keypair lives at `APRu6WGxe1NC4X2FrcLpujRRtqLNfMTSt6fYp5wQZVtP.json` at the repo root — its filename is its public key, so the keypair's identity is self-evident. +**On-chain witness (this program).** A Solana program that records the lifecycle of EQS (Ephemeral Quorum Subnet) shipment-monitoring instances: subnet formation, device-to-subnet assignment, consensus dispatches from members, and subnet dissolution. The chain authenticates that submitters are registered, hardware-rooted devices and records their dispatches. It does not arbitrate the subnet's internal consensus protocol. -## Deployments +**Operator authority.** The shipment authority key founds each subnet, maintains the off-chain manifest (route, temperature range, deadlines, lot numbers, quorum policy, expected cluster), and dissolves the subnet when the shipment completes. The chain binds the off-chain manifest via a 32-byte commitment. -| Cluster | Program | Deploy transaction | IDL account | -|---|---|---|---| -| devnet | [`APRu6WGxe1NC4X2FrcLpujRRtqLNfMTSt6fYp5wQZVtP`](https://solscan.io/account/APRu6WGxe1NC4X2FrcLpujRRtqLNfMTSt6fYp5wQZVtP?cluster=devnet) | [`2QcXmnEF…YdkW`](https://solscan.io/tx/2QcXmnEFz2dnVNzPNjdwHio3DTs5FP56qkQq8kzoe4BRAX9kCUBHpW3NHwHgvZCYDkfz338ffC3r8Zuue93mDYdkW?cluster=devnet) | [`Go4Lhea…23n1m`](https://solscan.io/account/Go4LheavUvDmRzgU9eAQ3ygYEMqErYsWKWE3vmH23n1m?cluster=devnet) | +**Off-chain data availability.** Operational detail that does not belong on a public chain — full readings, decrypted diagnostics, human-readable context — lives in customer-controlled storage, consumed by insurers, lawyers, and auditors alongside the on-chain dispatch record. This layer lives in [coldchain-core](https://github.com/april-gate/coldchain-core). -The full integration test suite passes against this deployed program — every state transition, account creation, and `getProgramAccounts` query exercised below is verifiable on-chain. +The chain alone answers a single factual question: *did registered devices anchor consensus dispatches, in what sequence, between a shipment's start and close transactions?* Whether a given dispatch count meets a shipment's compliance bar is determined off-chain by parties holding the manifest. The chain is evidentiary; interpretation is human. ## Why this design -Cold-chain disputes cost the pharmaceutical industry $35B annually. The verification problem is structural: temperature logs are stored centrally, can be edited, backdated, and challenged in court. April Gate replaces thousands of mutable readings with a single tamper-evident proof, anchored on Solana. +Cold-chain disputes cost the pharmaceutical industry billions annually. The verification problem is structural: temperature logs are stored centrally, can be edited, backdated, and challenged in court. April Gate replaces thousands of mutable readings with a stream of tamper-evident consensus dispatches anchored on Solana, each signed by a hardware-rooted device identity. The `device_registry` program records: -1. **Hardware-rooted device identity.** Each device's identity is its ATECC608 secure element serial — burned in at the factory, with a public key whose private half cannot be exfiltrated. -2. **Shipment lifecycle.** A state machine: `Created → InTransit → Delivered → Closed`. `Closed` is terminal. Invalid transitions are rejected on-chain. +1. **Hardware-rooted device identity.** Each device's identity is its ATECC608 secure element serial — burned in at the factory, with a public key whose private half cannot be exfiltrated. Only a device's recorded authority can submit dispatches under its assignment. +2. **Shipment lifecycle.** A subnet is *founded* (`create_shipment`), *active*, then *dissolved* (`close_shipment`, one-way). The chain tracks only this minimal bracketing — not human-interpretive status. Operational lifecycle (in transit, delivered, spoiled) is the operator's off-chain concern. 3. **Immutable assignment history.** Every device-to-shipment assignment creates a new PDA, never overwrites. An insurance investigator querying a shipment 18 months later sees the full chronological history of every device that ever carried it. -4. **Consensus commitments.** Off-chain consensus proofs (BLS-aggregated, generated by [coldchain-core](https://github.com/april-gate/coldchain-core)) are committed on-chain against specific assignments, providing a verifiable chain of custody. +4. **Consensus dispatches.** Each round of off-chain quorum consensus produces a 32-byte commitment, anchored on-chain against the shipment. No per-dispatch account is created — the dispatch lives as an event in the transaction log, and the shipment's commitment count is updated in place. On-chain storage per shipment is O(1) regardless of dispatch count. ## Account model @@ -44,10 +52,11 @@ DeviceRegistry PDA: [b"device", device_id] Shipment PDA: [b"shipment", authority, nonce] ├── authority 32 B - ├── nonce 8 B - ├── status 1 B (enum: Created | InTransit | Delivered | Closed) - ├── created_at 8 B - ├── proof_count 4 B + ├── nonce 32 B (unguessable shipment identifier) + ├── manifest_commitment 32 B (hash binding the off-chain manifest) + ├── proof_count 4 B (consensus dispatches anchored so far) + ├── last_commitment 32 B (most recent dispatch's commitment) + ├── closed 1 B (bool; one-way terminal flag) └── bump 1 B DeviceAssignment PDA: [b"assignment", device, sequence_le_bytes] @@ -57,36 +66,29 @@ DeviceAssignment PDA: [b"assignment", device, sequence_le_bytes] ├── authority 32 B ├── assigned_at 8 B ├── ended_at 8 B (0 = still active) - ├── proof_count 4 B - └── bump 1 B - -Proof PDA: [b"proof", assignment, sequence_le_bytes] - ├── assignment 32 B - ├── sequence 4 B - ├── commitment 32 B (hash binding the off-chain aggregated proof) - ├── submitter 32 B - ├── submitted_at 8 B + ├── proof_count 4 B (per-assignment dispatch count, off-chain accounting) └── bump 1 B ``` -The PDA seed structures enable native on-chain queries: +There is no per-dispatch account. The audit trail of dispatches is the transaction log: each `submit_proof` emits a `ProofSubmitted` event, retrievable via standard Solana RPC (`getSignaturesForAddress` on the shipment PDA, then parse each transaction's events). Solana's `blockTime` provides the authoritative chain-witnessed timestamp. + +The PDA seed structures enable native on-chain queries without an off-chain indexer: - **All devices ever on shipment X** — `getProgramAccounts(DeviceAssignment, memcmp on shipment field)`. - **Full assignment history for device D** — derive PDAs at sequences `0..device.assignment_count`. -- **All proofs for assignment A** — `getProgramAccounts(Proof, memcmp on assignment field)` or derive at sequences `0..assignment.proof_count`. -No off-chain indexer required. +Shipment nonces are 32-byte unguessable values rather than sequential integers, so an observer who knows an operator's authority wallet cannot enumerate the operator's shipments by guessing nonces. ## Instructions | Instruction | Purpose | |---|---| | `register_device` | Create a `DeviceRegistry` PDA from a 32-byte device ID and 33-byte public key | -| `create_shipment` | Create a `Shipment` PDA in `Created` state | -| `update_shipment_status` | Transition a shipment's status; rejects invalid transitions | -| `assign_device` | Create an immutable `DeviceAssignment` linking device → shipment | -| `end_assignment` | Mark the current assignment as ended (timestamps `ended_at`) | -| `submit_proof` | Record a 32-byte commitment binding an off-chain aggregated proof to an active assignment | +| `create_shipment` | Found a shipment subnet: a `Shipment` PDA bound to a 32-byte nonce and manifest commitment | +| `assign_device` | Create an immutable `DeviceAssignment` linking device → shipment (rejected if shipment is closed) | +| `end_assignment` | Mark the current assignment as ended (permitted regardless of shipment closed state) | +| `submit_proof` | Anchor a 32-byte consensus dispatch commitment against an active assignment; submitter must be the device's recorded authority | +| `close_shipment` | Dissolve the subnet (authority-only, one-way); after close, no new dispatches or assignments are accepted | Every state-changing instruction emits an event for off-chain indexing. @@ -106,7 +108,7 @@ Anchor expects the program keypair at `target/deploy/device_registry-keypair.jso ```bash yarn install mkdir -p target/deploy -cp APRu6WGxe1NC4X2FrcLpujRRtqLNfMTSt6fYp5wQZVtP.json target/deploy/device_registry-keypair.json +cp APRBVwwJJeStD5wShyg4HivneDYj4TCPYKtSFX5F4jez.json target/deploy/device_registry-keypair.json anchor build ``` @@ -114,7 +116,7 @@ Verify the program ID matches: ```bash anchor keys list -# device_registry: APRu6WGxe1NC4X2FrcLpujRRtqLNfMTSt6fYp5wQZVtP +# device_registry: APRBVwwJJeStD5wShyg4HivneDYj4TCPYKtSFX5F4jez ``` ### Test @@ -125,14 +127,15 @@ The test suite runs against an ephemeral local validator: anchor test ``` -Six test cases exercise: +The suite exercises: - Device registration with PDA derivation -- Shipment creation and status verification -- Status state machine transitions (`Created → InTransit → Delivered → Closed`) -- Rejection of invalid status transitions -- Full lifecycle: register → assign → submit proofs → reject duplicate assignment → end → reassign with history preserved +- Shipment creation with 32-byte nonce and manifest commitment +- Assignment lifecycle: assign → reject duplicate → end → reassign with history preserved - `getProgramAccounts` + `memcmp` query proving "all devices ever on shipment X" works on-chain +- Consensus dispatch submission: sequence ordering, per-shipment and per-assignment counts, `last_commitment` update +- Hardware-rooted submitter enforcement: dispatches from a non-authority signer are rejected +- Shipment close: one-way terminal flag, rejection of dispatches and assignments after close, `end_assignment` still permitted post-close ### Deploy to devnet @@ -152,7 +155,7 @@ anchor test --provider.cluster devnet --skip-deploy --skip-local-validator ``` . -├── APRu6...json Program deploy keypair (= program ID) +├── APRBV...json Program deploy keypair (= program ID) ├── Anchor.toml Anchor workspace config ├── Cargo.toml Rust workspace config ├── package.json TypeScript test dependencies @@ -162,17 +165,17 @@ anchor test --provider.cluster devnet --skip-deploy --skip-local-validator │ ├── Cargo.toml │ └── src/ │ ├── lib.rs #[program] entry point + dispatch -│ ├── state.rs Account types and lifecycle helpers +│ ├── state.rs Account types │ ├── errors.rs Custom error codes │ ├── events.rs Emitted events │ └── instructions/ │ ├── mod.rs │ ├── register_device.rs │ ├── create_shipment.rs -│ ├── update_shipment_status.rs │ ├── assign_device.rs │ ├── end_assignment.rs -│ └── submit_proof.rs +│ ├── submit_proof.rs +│ └── close_shipment.rs │ └── tests/ └── device-registry.ts Integration tests (mocha + chai) @@ -181,9 +184,9 @@ anchor test --provider.cluster devnet --skip-deploy --skip-local-validator ## Roadmap - **Multi-authority workflows.** Device manufacturer registers and owns devices; logistics operator creates shipments; assignment requires both signatures. Mirrors real-world responsibility split. -- **Cross-vendor device identifiers.** The 23 reserved bytes in `device_id` will encode a vendor namespace tag (ATECC608, NXP A1006, STSAFE, etc.) so the registry can accommodate multiple secure-element families. -- **Status extensions.** Add `Disputed` and `Cancelled` states for real-world insurance and exception flows. -- **Permissionless verification (ZK).** Today, the on-chain commitment binds an off-chain BLS aggregate produced by the sensor quorum, and compliance verdicts are served by the operator's API to authorized parties. This is the right model when the operator is trusted by all consumers of the data (insurers, regulators) — which is the case in our insurer-first GTM. For deployments where consumers need to verify compliance directly from Solana without trusting the operator (public regulators, consortium operators, third-party auditors), `submit_proof` can be extended to accept a Groth16 proof verified entirely on-chain — proving the quorum agreed on readings within bounds, without revealing the readings themselves. ZK is a mode the architecture supports when the trust model demands it, not a default requirement when the existing BLS aggregate already cryptographically attests that a threshold of hardware-rooted devices agreed. +- **Cross-vendor device identifiers.** The reserved bytes in `device_id` will encode a vendor namespace tag (ATECC608, NXP A1006, STSAFE, etc.) so the registry can accommodate multiple secure-element families. +- **Open-source verification tooling.** A CLI that reconstructs a shipment's full dispatch history from the transaction log, verifies each dispatch, and prints a compliance verdict against the off-chain manifest — making the on-chain record independently auditable by anyone, without trusting the operator's API. +- **Immutable program authority.** Move the program's upgrade authority to a timelocked multisig before mainnet deploy, so any program change is publicly visible before activation. ## License diff --git a/package.json b/package.json index 364e46d..b0e1d89 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "lint:fix": "prettier */*.js \"*/**/*{.js,.ts}\" -w", "lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check" }, + "type": "module", "dependencies": { "@coral-xyz/anchor": "^0.32.1" }, diff --git a/programs/device-registry/src/errors.rs b/programs/device-registry/src/errors.rs index 3f344ba..fc5813d 100644 --- a/programs/device-registry/src/errors.rs +++ b/programs/device-registry/src/errors.rs @@ -10,12 +10,16 @@ pub enum ColdchainError { AssignmentAlreadyEnded, #[msg("Assignment account does not match device's current assignment")] AssignmentMismatch, - #[msg("Invalid shipment status transition")] - InvalidStatusTransition, #[msg("Shipment is closed and cannot accept new operations")] ShipmentClosed, - #[msg("Cannot assign device to a shipment that is not in transit or created")] - ShipmentNotAcceptingDevices, + #[msg("Submitter is not the authority of the device referenced by the assignment")] + UnauthorizedSubmitter, + #[msg("Assignment's device field does not match the provided device account")] + AssignmentDeviceMismatch, + #[msg("Assignment has been ended; cannot submit proofs against it")] + AssignmentEnded, + #[msg("Unauthorized: signer does not match the required authority")] + Unauthorized, #[msg("Arithmetic overflow")] ArithmeticOverflow, } diff --git a/programs/device-registry/src/events.rs b/programs/device-registry/src/events.rs index 7c86204..de8b67c 100644 --- a/programs/device-registry/src/events.rs +++ b/programs/device-registry/src/events.rs @@ -1,5 +1,5 @@ use anchor_lang::prelude::*; -use crate::state::{DeviceId, ShipmentStatus}; +use crate::state::DeviceId; #[event] pub struct DeviceRegistered { @@ -8,20 +8,21 @@ pub struct DeviceRegistered { pub authority: Pubkey, } +/// Emitted by `create_shipment`. Marks subnet formation. #[event] pub struct ShipmentCreated { pub shipment: Pubkey, pub authority: Pubkey, - pub nonce: u64, - pub created_at: i64, + pub manifest_commitment: [u8; 32], } +/// Emitted by `close_shipment`. Marks subnet dissolution. Final proof_count +/// is captured here so an auditor reading just the close transaction's logs +/// knows the total dispatch count without also fetching the Shipment account. #[event] -pub struct ShipmentStatusChanged { +pub struct ShipmentClosed { pub shipment: Pubkey, - pub from: ShipmentStatus, - pub to: ShipmentStatus, - pub changed_at: i64, + pub proof_count: u32, } #[event] @@ -41,11 +42,17 @@ pub struct AssignmentEnded { pub ended_at: i64, } +/// Emitted by `submit_proof`. The per-round audit record — a subnet member's +/// consensus dispatch — lives in the transaction log permanently. Solana +/// blockTime (via getTransaction) is the authoritative chain-witnessed +/// timestamp; not duplicated here. #[event] pub struct ProofSubmitted { + pub shipment: Pubkey, pub assignment: Pubkey, - pub proof: Pubkey, + pub device: Pubkey, + /// Chain-assigned sequence within this shipment. 0 for the first proof. pub sequence: u32, + /// 32-byte hash binding the off-chain consensus dispatch content. pub commitment: [u8; 32], - pub submitted_at: i64, } diff --git a/programs/device-registry/src/instructions/assign_device.rs b/programs/device-registry/src/instructions/assign_device.rs index fc14e5d..18477be 100644 --- a/programs/device-registry/src/instructions/assign_device.rs +++ b/programs/device-registry/src/instructions/assign_device.rs @@ -14,10 +14,7 @@ pub fn handler(ctx: Context) -> Result<()> { ColdchainError::DeviceAlreadyAssigned ); - require!( - matches!(shipment.status, ShipmentStatus::Created | ShipmentStatus::InTransit), - ColdchainError::ShipmentNotAcceptingDevices - ); + require!(!shipment.closed, ColdchainError::ShipmentClosed); let sequence = device.assignment_count; diff --git a/programs/device-registry/src/instructions/close_shipment.rs b/programs/device-registry/src/instructions/close_shipment.rs new file mode 100644 index 0000000..cdf7c4b --- /dev/null +++ b/programs/device-registry/src/instructions/close_shipment.rs @@ -0,0 +1,29 @@ +use anchor_lang::prelude::*; +use crate::state::*; +use crate::events::*; +use crate::errors::*; + +pub fn handler(ctx: Context) -> Result<()> { + let shipment = &mut ctx.accounts.shipment; + + require!(!shipment.closed, ColdchainError::ShipmentClosed); + + shipment.closed = true; + + emit!(ShipmentClosed { + shipment: shipment.key(), + proof_count: shipment.proof_count, + }); + + Ok(()) +} + +#[derive(Accounts)] +pub struct CloseShipment<'info> { + /// Operator-only, one-way. The `has_one = authority` constraint ensures + /// only the wallet that founded the shipment can dissolve it. + #[account(mut, has_one = authority)] + pub shipment: Account<'info, Shipment>, + + pub authority: Signer<'info>, +} diff --git a/programs/device-registry/src/instructions/create_shipment.rs b/programs/device-registry/src/instructions/create_shipment.rs index 8b26b83..fe5dc01 100644 --- a/programs/device-registry/src/instructions/create_shipment.rs +++ b/programs/device-registry/src/instructions/create_shipment.rs @@ -2,29 +2,32 @@ use anchor_lang::prelude::*; use crate::state::*; use crate::events::*; -pub fn handler(ctx: Context, nonce: u64) -> Result<()> { +pub fn handler( + ctx: Context, + nonce: [u8; 32], + manifest_commitment: [u8; 32], +) -> Result<()> { let shipment = &mut ctx.accounts.shipment; - let clock = Clock::get()?; shipment.authority = ctx.accounts.authority.key(); shipment.nonce = nonce; - shipment.status = ShipmentStatus::Created; - shipment.created_at = clock.unix_timestamp; + shipment.manifest_commitment = manifest_commitment; shipment.proof_count = 0; + shipment.last_commitment = [0u8; 32]; + shipment.closed = false; shipment.bump = ctx.bumps.shipment; emit!(ShipmentCreated { shipment: shipment.key(), authority: shipment.authority, - nonce, - created_at: shipment.created_at, + manifest_commitment, }); Ok(()) } #[derive(Accounts)] -#[instruction(nonce: u64)] +#[instruction(nonce: [u8; 32])] pub struct CreateShipment<'info> { #[account( init, @@ -33,7 +36,7 @@ pub struct CreateShipment<'info> { seeds = [ b"shipment", authority.key().as_ref(), - &nonce.to_le_bytes() + nonce.as_ref() ], bump )] diff --git a/programs/device-registry/src/instructions/mod.rs b/programs/device-registry/src/instructions/mod.rs index 903e718..92a5b91 100644 --- a/programs/device-registry/src/instructions/mod.rs +++ b/programs/device-registry/src/instructions/mod.rs @@ -2,10 +2,10 @@ pub mod register_device; pub mod create_shipment; -pub mod update_shipment_status; pub mod assign_device; pub mod end_assignment; pub mod submit_proof; +pub mod close_shipment; // Glob re-exports are required so that #[derive(Accounts)]'s auto-generated // companion modules (__client_accounts_*, __cpi_client_accounts_*) are @@ -16,7 +16,7 @@ pub mod submit_proof; // (e.g. `instructions::register_device::handler`) from lib.rs. pub use register_device::*; pub use create_shipment::*; -pub use update_shipment_status::*; pub use assign_device::*; pub use end_assignment::*; -pub use submit_proof::*; \ No newline at end of file +pub use submit_proof::*; +pub use close_shipment::*; diff --git a/programs/device-registry/src/instructions/submit_proof.rs b/programs/device-registry/src/instructions/submit_proof.rs index d8d4634..dc5ca84 100644 --- a/programs/device-registry/src/instructions/submit_proof.rs +++ b/programs/device-registry/src/instructions/submit_proof.rs @@ -6,36 +6,29 @@ use crate::errors::*; pub fn handler(ctx: Context, commitment: [u8; 32]) -> Result<()> { let assignment = &mut ctx.accounts.assignment; let shipment = &mut ctx.accounts.shipment; - let proof = &mut ctx.accounts.proof; - let clock = Clock::get()?; - // MVP: store commitment only. Production: verify Groth16 proof here - // using public inputs that bind device_id and shipment_id into the circuit. + require!(!shipment.closed, ColdchainError::ShipmentClosed); - let sequence = assignment.proof_count; + let sequence = shipment.proof_count; - proof.assignment = assignment.key(); - proof.sequence = sequence; - proof.commitment = commitment; - proof.submitter = ctx.accounts.submitter.key(); - proof.submitted_at = clock.unix_timestamp; - proof.bump = ctx.bumps.proof; - - assignment.proof_count = assignment + shipment.proof_count = shipment .proof_count .checked_add(1) .ok_or(ColdchainError::ArithmeticOverflow)?; - shipment.proof_count = shipment + shipment.last_commitment = commitment; + + // Per-assignment counter maintained for off-chain accounting. + assignment.proof_count = assignment .proof_count .checked_add(1) .ok_or(ColdchainError::ArithmeticOverflow)?; emit!(ProofSubmitted { + shipment: shipment.key(), assignment: assignment.key(), - proof: proof.key(), + device: ctx.accounts.device.key(), sequence, commitment, - submitted_at: clock.unix_timestamp, }); Ok(()) @@ -43,30 +36,26 @@ pub fn handler(ctx: Context, commitment: [u8; 32]) -> Result<()> { #[derive(Accounts)] pub struct SubmitProof<'info> { - #[account(mut)] - pub assignment: Account<'info, DeviceAssignment>, - #[account( mut, - constraint = assignment.shipment == shipment.key() @ ColdchainError::AssignmentMismatch + constraint = assignment.shipment == shipment.key() @ ColdchainError::AssignmentMismatch, + constraint = assignment.ended_at == 0 @ ColdchainError::AssignmentEnded, + constraint = assignment.device == device.key() @ ColdchainError::AssignmentDeviceMismatch )] + pub assignment: Account<'info, DeviceAssignment>, + + #[account(mut)] pub shipment: Account<'info, Shipment>, + /// The registered device this dispatch is submitted under. Its recorded + /// authority must be the transaction signer (hardware-rooted identity). + pub device: Account<'info, DeviceRegistry>, + + /// Must be the device's recorded authority key. This is the + /// hardware-rooted identity check: only the registered device's owner + /// can submit dispatches under its assignment. #[account( - init, - payer = submitter, - space = 8 + Proof::INIT_SPACE, - seeds = [ - b"proof", - assignment.key().as_ref(), - &assignment.proof_count.to_le_bytes() - ], - bump + constraint = submitter.key() == device.authority @ ColdchainError::UnauthorizedSubmitter )] - pub proof: Account<'info, Proof>, - - #[account(mut)] pub submitter: Signer<'info>, - - pub system_program: Program<'info, System>, } diff --git a/programs/device-registry/src/instructions/update_shipment_status.rs b/programs/device-registry/src/instructions/update_shipment_status.rs deleted file mode 100644 index bb4861a..0000000 --- a/programs/device-registry/src/instructions/update_shipment_status.rs +++ /dev/null @@ -1,34 +0,0 @@ -use anchor_lang::prelude::*; -use crate::state::*; -use crate::events::*; -use crate::errors::*; - -pub fn handler(ctx: Context, new_status: ShipmentStatus) -> Result<()> { - let shipment = &mut ctx.accounts.shipment; - let from = shipment.status; - let clock = Clock::get()?; - - require!( - Shipment::can_transition(from, new_status), - ColdchainError::InvalidStatusTransition - ); - - shipment.status = new_status; - - emit!(ShipmentStatusChanged { - shipment: shipment.key(), - from, - to: new_status, - changed_at: clock.unix_timestamp, - }); - - Ok(()) -} - -#[derive(Accounts)] -pub struct UpdateShipmentStatus<'info> { - #[account(mut, has_one = authority)] - pub shipment: Account<'info, Shipment>, - - pub authority: Signer<'info>, -} diff --git a/programs/device-registry/src/lib.rs b/programs/device-registry/src/lib.rs index cab8206..fd4eb95 100644 --- a/programs/device-registry/src/lib.rs +++ b/programs/device-registry/src/lib.rs @@ -9,9 +9,9 @@ pub mod instructions; // which is where the #[program] macro expects to find Accounts structs and // their auto-generated __client_accounts_* / __cpi_client_accounts_* modules. pub use instructions::*; -use state::{DeviceId, ShipmentStatus}; +use state::DeviceId; -declare_id!("APRu6WGxe1NC4X2FrcLpujRRtqLNfMTSt6fYp5wQZVtP"); +declare_id!("APRBVwwJJeStD5wShyg4HivneDYj4TCPYKtSFX5F4jez"); #[program] pub mod device_registry { @@ -26,34 +26,40 @@ pub mod device_registry { instructions::register_device::handler(ctx, device_id, pubkey) } - /// Create a new shipment owned by the caller. Status starts as Created. - pub fn create_shipment(ctx: Context, nonce: u64) -> Result<()> { - instructions::create_shipment::handler(ctx, nonce) - } - - /// Transition a shipment's status. Allowed transitions: - /// Created → InTransit → Delivered → Closed. - pub fn update_shipment_status( - ctx: Context, - new_status: ShipmentStatus, + /// Found a shipment subnet. `nonce` is a 32-byte unguessable identifier; + /// `manifest_commitment` binds the off-chain shipment manifest. + pub fn create_shipment( + ctx: Context, + nonce: [u8; 32], + manifest_commitment: [u8; 32], ) -> Result<()> { - instructions::update_shipment_status::handler(ctx, new_status) + instructions::create_shipment::handler(ctx, nonce, manifest_commitment) } /// Assign a device to a shipment. Creates an immutable DeviceAssignment - /// PDA, preserving the device's full assignment history. + /// PDA, preserving the device's full assignment history. Rejected if the + /// shipment is closed. pub fn assign_device(ctx: Context) -> Result<()> { instructions::assign_device::handler(ctx) } - /// Mark the device's current assignment as ended. + /// Mark the device's current assignment as ended. Permitted regardless of + /// shipment closed state, so devices can be freed for reuse. pub fn end_assignment(ctx: Context) -> Result<()> { instructions::end_assignment::handler(ctx) } - /// Submit a ZK proof commitment against an active assignment. - /// MVP stores commitment only; production verifies Groth16 on-chain. + /// Submit a consensus dispatch commitment against an active assignment. + /// The submitter must be the device's recorded hardware-rooted authority. + /// No account is created; the Shipment PDA is updated and the dispatch is + /// emitted to the transaction log. pub fn submit_proof(ctx: Context, commitment: [u8; 32]) -> Result<()> { instructions::submit_proof::handler(ctx, commitment) } -} \ No newline at end of file + + /// Dissolve the shipment subnet. Operator-only, one-way. After close, no + /// new dispatches or assignments are accepted. + pub fn close_shipment(ctx: Context) -> Result<()> { + instructions::close_shipment::handler(ctx) + } +} diff --git a/programs/device-registry/src/state.rs b/programs/device-registry/src/state.rs index 4aefe3c..bf61bf4 100644 --- a/programs/device-registry/src/state.rs +++ b/programs/device-registry/src/state.rs @@ -6,18 +6,6 @@ use anchor_lang::prelude::*; /// Reserved for future use: type tag, vendor namespace, identifier extensions. pub type DeviceId = [u8; 32]; -/// Shipment lifecycle states. -/// -/// MVP transitions: Created → InTransit → Delivered → Closed. -/// Closed is terminal. Future versions may add Disputed, Cancelled, etc. -#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq, Eq, InitSpace, Debug)] -pub enum ShipmentStatus { - Created, - InTransit, - Delivered, - Closed, -} - #[account] pub struct DeviceRegistry { /// Owner authorized to assign this device and submit proofs. @@ -40,19 +28,40 @@ impl DeviceRegistry { pub const INIT_SPACE: usize = 32 + 32 + 33 + 4 + 32 + 1; } +/// A Shipment is the on-chain bracketing of an EQS (Ephemeral Quorum Subnet) +/// — the permissioned cluster of sensors monitoring one cargo shipment. +/// +/// The chain records when the subnet formed (create_shipment), the stream of +/// consensus dispatches it produced (submit_proof), and when it was dissolved +/// (close_shipment). The chain does not arbitrate the subnet's internal +/// consensus protocol — that is the subnet's own business, verifiable by +/// parties holding the operator's off-chain manifest. #[account] #[derive(InitSpace)] pub struct Shipment { - /// Owner authorized to update status and request device assignments. + /// Operator wallet that founded this shipment subnet. Authorized to + /// assign devices and to close the subnet. May be a per-shipment fresh + /// keypair (recommended) to break correlation between the operator's + /// real-world identity and their on-chain shipment history. pub authority: Pubkey, - /// Monotonic shipment counter scoped to authority. Used in PDA seed. - pub nonce: u64, - /// Current lifecycle state. - pub status: ShipmentStatus, - /// Unix timestamp of shipment creation. - pub created_at: i64, - /// Total proofs submitted across all assignments to this shipment. + /// 32-byte unguessable shipment identifier chosen by the operator. + /// Stored opaquely so an observer who knows `authority` cannot enumerate + /// the operator's shipments by guessing sequential nonces. + pub nonce: [u8; 32], + /// 32-byte hash binding the off-chain shipment manifest (route, + /// temperature range, deadlines, lot numbers, quorum policy, expected + /// cluster identifiers). The manifest itself lives off-chain under + /// operator control; this commitment makes it tamper-evident. + pub manifest_commitment: [u8; 32], + /// Total consensus dispatches anchored to this shipment. Chain-assigned + /// sequence source; incremented on each accepted proof. pub proof_count: u32, + /// 32-byte commitment from the most recent dispatch. Lets an observer + /// read the latest anchor from a single account fetch. + pub last_commitment: [u8; 32], + /// True once the operator has dissolved the subnet. One-way: cannot be + /// unset. When true, the program rejects new proofs and new assignments. + pub closed: bool, /// PDA bump for re-derivation. pub bump: u8, } @@ -72,42 +81,9 @@ pub struct DeviceAssignment { pub assigned_at: i64, /// Unix timestamp of unassignment. 0 if still active. pub ended_at: i64, - /// Number of proofs submitted against this assignment. + /// Number of proofs submitted against this assignment. Maintained for + /// off-chain per-assignment accounting; not used as a PDA seed. pub proof_count: u32, /// PDA bump. pub bump: u8, } - -#[account] -pub struct Proof { - /// Assignment this proof is bound to. - pub assignment: Pubkey, - /// Sequence number within the assignment (0 = first proof). - pub sequence: u32, - /// 32-byte commitment to the proof (e.g. hash of the full proof bytes - /// stored off-chain, or the proof's public input commitment). - /// MVP: simple commitment. Production: full Bellman/Groth16 verifier. - pub commitment: [u8; 32], - /// Submitter authority. - pub submitter: Pubkey, - /// Unix timestamp. - pub submitted_at: i64, - /// PDA bump. - pub bump: u8, -} - -impl Proof { - // assignment(32) + sequence(4) + commitment(32) + submitter(32) + submitted_at(8) + bump(1) - pub const INIT_SPACE: usize = 32 + 4 + 32 + 32 + 8 + 1; -} - -impl Shipment { - /// Validate that a transition from `from` to `to` is permitted. - pub fn can_transition(from: ShipmentStatus, to: ShipmentStatus) -> bool { - use ShipmentStatus::*; - matches!( - (from, to), - (Created, InTransit) | (InTransit, Delivered) | (Delivered, Closed) - ) - } -} diff --git a/tests/device-registry.ts b/tests/device-registry.ts index 3af3e5b..d27cf59 100644 --- a/tests/device-registry.ts +++ b/tests/device-registry.ts @@ -1,5 +1,5 @@ import * as anchor from "@coral-xyz/anchor"; -import { Program, BN } from "@coral-xyz/anchor"; +import { Program } from "@coral-xyz/anchor"; import { DeviceRegistry } from "../target/types/device_registry"; import { PublicKey, Keypair, SystemProgram } from "@solana/web3.js"; import { assert } from "chai"; @@ -19,22 +19,21 @@ describe("device-registry", () => { const id = Buffer.alloc(32); id[0] = 0x01; id[1] = 0x23; - crypto.randomBytes(6).copy(id, 2); // 6 random middle bytes + crypto.randomBytes(6).copy(id, 2); id[8] = 0xee; - // bytes 9..32 stay zero return Array.from(id); } /** Mock compressed P-256 public key (33 bytes). For tests; not validated on-chain. */ function makePubkey(): number[] { const pk = Buffer.alloc(33); - pk[0] = 0x02; // compressed prefix + pk[0] = 0x02; crypto.randomBytes(32).copy(pk, 1); return Array.from(pk); } - /** Random 32-byte commitment for proof submissions. */ - function makeCommitment(): number[] { + /** Random 32-byte value (used for both nonces and commitments). */ + function rand32(): number[] { return Array.from(crypto.randomBytes(32)); } @@ -46,13 +45,9 @@ describe("device-registry", () => { return pda; } - function shipmentPda(authority: PublicKey, nonce: anchor.BN): PublicKey { + function shipmentPda(authority: PublicKey, nonce: number[]): PublicKey { const [pda] = PublicKey.findProgramAddressSync( - [ - Buffer.from("shipment"), - authority.toBuffer(), - nonce.toArrayLike(Buffer, "le", 8), - ], + [Buffer.from("shipment"), authority.toBuffer(), Buffer.from(nonce)], program.programId ); return pda; @@ -68,22 +63,17 @@ describe("device-registry", () => { return pda; } - function proofPda(assignment: PublicKey, sequence: number): PublicKey { - const seqBuf = Buffer.alloc(4); - seqBuf.writeUInt32LE(sequence, 0); - const [pda] = PublicKey.findProgramAddressSync( - [Buffer.from("proof"), assignment.toBuffer(), seqBuf], - program.programId - ); - return pda; - } - - // Tests ───────────────────────────────────────────────────────────────────── - - it("registers a device", async () => { + /** + * Register a device, create a shipment, and assign the device to it. + * Returns the keys needed to drive submit_proof / close_shipment tests. + */ + async function setupAssignedDevice() { const deviceId = makeDeviceId(); const pubkey = makePubkey(); const device = devicePda(deviceId); + const nonce = rand32(); + const shipment = shipmentPda(authority.publicKey, nonce); + const manifest = rand32(); await program.methods .registerDevice(deviceId, pubkey) @@ -94,20 +84,8 @@ describe("device-registry", () => { }) .rpc(); - const acct = await program.account.deviceRegistry.fetch(device); - assert.deepEqual(acct.deviceId, deviceId); - assert.deepEqual(acct.pubkey, pubkey); - assert.equal(acct.assignmentCount, 0); - assert.ok(acct.authority.equals(authority.publicKey)); - assert.ok(acct.currentAssignment.equals(PublicKey.default)); - }); - - it("creates a shipment", async () => { - const nonce = new BN(1); - const shipment = shipmentPda(authority.publicKey, nonce); - await program.methods - .createShipment(nonce) + .createShipment(nonce, manifest) .accounts({ shipment, authority: authority.publicKey, @@ -115,143 +93,172 @@ describe("device-registry", () => { }) .rpc(); - const acct = await program.account.shipment.fetch(shipment); - assert.deepEqual(acct.status, { created: {} }); - assert.equal(acct.nonce.toNumber(), 1); - assert.equal(acct.proofCount, 0); - }); - - it("transitions shipment Created → InTransit → Delivered → Closed", async () => { - const nonce = new BN(2); - const shipment = shipmentPda(authority.publicKey, nonce); - + const assignment = assignmentPda(device, 0); await program.methods - .createShipment(nonce) + .assignDevice() .accounts({ + device, shipment, + assignment, authority: authority.publicKey, systemProgram: SystemProgram.programId, }) .rpc(); - const transitions = [ - { inTransit: {} }, - { delivered: {} }, - { closed: {} }, - ]; - for (const status of transitions) { + return { deviceId, device, nonce, shipment, assignment, manifest }; + } + + // Device ───────────────────────────────────────────────────────────────────── + + describe("register_device", () => { + it("registers a device", async () => { + const deviceId = makeDeviceId(); + const pubkey = makePubkey(); + const device = devicePda(deviceId); + await program.methods - .updateShipmentStatus(status as any) - .accounts({ shipment, authority: authority.publicKey }) + .registerDevice(deviceId, pubkey) + .accounts({ + device, + authority: authority.publicKey, + systemProgram: SystemProgram.programId, + }) .rpc(); - } - const acct = await program.account.shipment.fetch(shipment); - assert.deepEqual(acct.status, { closed: {} }); + const acct = await program.account.deviceRegistry.fetch(device); + assert.deepEqual(acct.deviceId, deviceId); + assert.deepEqual(acct.pubkey, pubkey); + assert.equal(acct.assignmentCount, 0); + assert.ok(acct.authority.equals(authority.publicKey)); + assert.ok(acct.currentAssignment.equals(PublicKey.default)); + }); }); - it("rejects invalid status transitions", async () => { - const nonce = new BN(3); - const shipment = shipmentPda(authority.publicKey, nonce); + // Shipment ─────────────────────────────────────────────────────────────────── - await program.methods - .createShipment(nonce) - .accounts({ - shipment, - authority: authority.publicKey, - systemProgram: SystemProgram.programId, - }) - .rpc(); + describe("create_shipment", () => { + it("creates a shipment with 32-byte nonce and manifest commitment", async () => { + const nonce = rand32(); + const manifest = rand32(); + const shipment = shipmentPda(authority.publicKey, nonce); - // Created → Delivered should fail (must go through InTransit) - try { await program.methods - .updateShipmentStatus({ delivered: {} } as any) - .accounts({ shipment, authority: authority.publicKey }) + .createShipment(nonce, manifest) + .accounts({ + shipment, + authority: authority.publicKey, + systemProgram: SystemProgram.programId, + }) .rpc(); - assert.fail("expected InvalidStatusTransition"); - } catch (err: any) { - assert.match(err.toString(), /InvalidStatusTransition/); - } - }); - it("runs the full lifecycle: register → create → assign → submit proof → end → reassign", async () => { - // Register the device - const deviceId = makeDeviceId(); - const pubkey = makePubkey(); - const device = devicePda(deviceId); - await program.methods - .registerDevice(deviceId, pubkey) - .accounts({ - device, - authority: authority.publicKey, - systemProgram: SystemProgram.programId, - }) - .rpc(); + const acct = await program.account.shipment.fetch(shipment); + assert.deepEqual(acct.nonce, nonce); + assert.deepEqual(acct.manifestCommitment, manifest); + assert.equal(acct.proofCount, 0); + assert.equal(acct.closed, false); + assert.ok(acct.authority.equals(authority.publicKey)); + }); + + it("derives distinct PDAs for same nonce under different authorities", async () => { + // Privacy property: knowing the authority + guessing nonces is not + // enough to enumerate, because the 32-byte nonce is unguessable. + // This just demonstrates seed independence. + const nonce = rand32(); + const other = Keypair.generate(); + const pda1 = shipmentPda(authority.publicKey, nonce); + const pda2 = shipmentPda(other.publicKey, nonce); + assert.notEqual(pda1.toBase58(), pda2.toBase58()); + }); + }); - // Create shipment A - const nonceA = new BN(100); - const shipmentA = shipmentPda(authority.publicKey, nonceA); - await program.methods - .createShipment(nonceA) - .accounts({ - shipment: shipmentA, - authority: authority.publicKey, - systemProgram: SystemProgram.programId, - }) - .rpc(); + // Assignment ───────────────────────────────────────────────────────────────── - // Assign device to shipment A (sequence 0) - const assignment0 = assignmentPda(device, 0); - await program.methods - .assignDevice() - .accounts({ - device, - shipment: shipmentA, - assignment: assignment0, - authority: authority.publicKey, - systemProgram: SystemProgram.programId, - }) - .rpc(); + describe("assign_device / end_assignment", () => { + it("runs the assignment lifecycle: assign → reject duplicate → end → reassign with history", async () => { + const deviceId = makeDeviceId(); + const pubkey = makePubkey(); + const device = devicePda(deviceId); + await program.methods + .registerDevice(deviceId, pubkey) + .accounts({ + device, + authority: authority.publicKey, + systemProgram: SystemProgram.programId, + }) + .rpc(); - let deviceAcct = await program.account.deviceRegistry.fetch(device); - assert.equal(deviceAcct.assignmentCount, 1); - assert.ok(deviceAcct.currentAssignment.equals(assignment0)); + const nonceA = rand32(); + const shipmentA = shipmentPda(authority.publicKey, nonceA); + await program.methods + .createShipment(nonceA, rand32()) + .accounts({ + shipment: shipmentA, + authority: authority.publicKey, + systemProgram: SystemProgram.programId, + }) + .rpc(); - // Submit two proofs against assignment 0 - for (let i = 0; i < 2; i++) { - const proof = proofPda(assignment0, i); + const assignment0 = assignmentPda(device, 0); await program.methods - .submitProof(makeCommitment()) + .assignDevice() .accounts({ - assignment: assignment0, + device, shipment: shipmentA, - proof, - submitter: authority.publicKey, + assignment: assignment0, + authority: authority.publicKey, systemProgram: SystemProgram.programId, }) .rpc(); - } - let assignAcct = await program.account.deviceAssignment.fetch(assignment0); - let shipmentAcct = await program.account.shipment.fetch(shipmentA); - assert.equal(assignAcct.proofCount, 2); - assert.equal(shipmentAcct.proofCount, 2); + let deviceAcct = await program.account.deviceRegistry.fetch(device); + assert.equal(deviceAcct.assignmentCount, 1); + assert.ok(deviceAcct.currentAssignment.equals(assignment0)); - // Cannot reassign while still active - const nonceB = new BN(101); - const shipmentB = shipmentPda(authority.publicKey, nonceB); - await program.methods - .createShipment(nonceB) - .accounts({ - shipment: shipmentB, - authority: authority.publicKey, - systemProgram: SystemProgram.programId, - }) - .rpc(); + // Cannot reassign while active. + const nonceB = rand32(); + const shipmentB = shipmentPda(authority.publicKey, nonceB); + await program.methods + .createShipment(nonceB, rand32()) + .accounts({ + shipment: shipmentB, + authority: authority.publicKey, + systemProgram: SystemProgram.programId, + }) + .rpc(); - const assignment1 = assignmentPda(device, 1); - try { + const assignment1 = assignmentPda(device, 1); + try { + await program.methods + .assignDevice() + .accounts({ + device, + shipment: shipmentB, + assignment: assignment1, + authority: authority.publicKey, + systemProgram: SystemProgram.programId, + }) + .rpc(); + assert.fail("expected DeviceAlreadyAssigned"); + } catch (err: any) { + assert.match(err.toString(), /DeviceAlreadyAssigned/); + } + + // End the first assignment. + await program.methods + .endAssignment() + .accounts({ + device, + assignment: assignment0, + authority: authority.publicKey, + }) + .rpc(); + + deviceAcct = await program.account.deviceRegistry.fetch(device); + assert.ok(deviceAcct.currentAssignment.equals(PublicKey.default)); + const ended = await program.account.deviceAssignment.fetch(assignment0); + assert.notEqual(ended.endedAt.toNumber(), 0); + + // Reassign to shipment B (sequence 1). await program.methods .assignDevice() .accounts({ @@ -262,105 +269,276 @@ describe("device-registry", () => { systemProgram: SystemProgram.programId, }) .rpc(); - assert.fail("expected DeviceAlreadyAssigned"); - } catch (err: any) { - assert.match(err.toString(), /DeviceAlreadyAssigned/); - } - // End the first assignment - await program.methods - .endAssignment() - .accounts({ - device, - assignment: assignment0, - authority: authority.publicKey, - }) - .rpc(); + deviceAcct = await program.account.deviceRegistry.fetch(device); + assert.equal(deviceAcct.assignmentCount, 2); + assert.ok(deviceAcct.currentAssignment.equals(assignment1)); + + const hist0 = await program.account.deviceAssignment.fetch(assignment0); + const hist1 = await program.account.deviceAssignment.fetch(assignment1); + assert.notEqual(hist0.endedAt.toNumber(), 0); + assert.equal(hist1.endedAt.toNumber(), 0); + assert.ok(hist0.shipment.equals(shipmentA)); + assert.ok(hist1.shipment.equals(shipmentB)); + }); + + it("getProgramAccounts finds all assignments for a shipment", async () => { + // "Show me every device that was ever on this shipment." + const nonce = rand32(); + const shipment = shipmentPda(authority.publicKey, nonce); + await program.methods + .createShipment(nonce, rand32()) + .accounts({ + shipment, + authority: authority.publicKey, + systemProgram: SystemProgram.programId, + }) + .rpc(); - deviceAcct = await program.account.deviceRegistry.fetch(device); - assert.ok(deviceAcct.currentAssignment.equals(PublicKey.default)); - assignAcct = await program.account.deviceAssignment.fetch(assignment0); - assert.notEqual(assignAcct.endedAt.toNumber(), 0); + for (let i = 0; i < 2; i++) { + const deviceId = makeDeviceId(); + const device = devicePda(deviceId); + await program.methods + .registerDevice(deviceId, makePubkey()) + .accounts({ + device, + authority: authority.publicKey, + systemProgram: SystemProgram.programId, + }) + .rpc(); + + const assignment = assignmentPda(device, 0); + await program.methods + .assignDevice() + .accounts({ + device, + shipment, + assignment, + authority: authority.publicKey, + systemProgram: SystemProgram.programId, + }) + .rpc(); + } + + // Offset = 8 (discriminator) + 32 (device) → shipment field. + const assignments = await program.account.deviceAssignment.all([ + { memcmp: { offset: 8 + 32, bytes: shipment.toBase58() } }, + ]); + + assert.equal(assignments.length, 2); + for (const a of assignments) { + assert.ok(a.account.shipment.equals(shipment)); + } + }); + }); - // Reassign to shipment B (sequence 1) - await program.methods - .assignDevice() - .accounts({ - device, - shipment: shipmentB, - assignment: assignment1, - authority: authority.publicKey, - systemProgram: SystemProgram.programId, - }) - .rpc(); + // Proof ────────────────────────────────────────────────────────────────────── + + describe("submit_proof", () => { + it("accepts a dispatch, increments counts, sets last_commitment, no Proof PDA", async () => { + const ctx = await setupAssignedDevice(); + const commitment = rand32(); + + await program.methods + .submitProof(commitment) + .accounts({ + assignment: ctx.assignment, + shipment: ctx.shipment, + device: ctx.device, + submitter: authority.publicKey, + }) + .rpc(); - deviceAcct = await program.account.deviceRegistry.fetch(device); - assert.equal(deviceAcct.assignmentCount, 2); - assert.ok(deviceAcct.currentAssignment.equals(assignment1)); - - // Verify history: assignment 0 ended, assignment 1 active - const hist0 = await program.account.deviceAssignment.fetch(assignment0); - const hist1 = await program.account.deviceAssignment.fetch(assignment1); - assert.notEqual(hist0.endedAt.toNumber(), 0); - assert.equal(hist1.endedAt.toNumber(), 0); - assert.ok(hist0.shipment.equals(shipmentA)); - assert.ok(hist1.shipment.equals(shipmentB)); + const shipmentAcct = await program.account.shipment.fetch(ctx.shipment); + const assignAcct = await program.account.deviceAssignment.fetch(ctx.assignment); + assert.equal(shipmentAcct.proofCount, 1); + assert.equal(assignAcct.proofCount, 1); + assert.deepEqual(shipmentAcct.lastCommitment, commitment); + }); + + it("assigns sequence in submission order across multiple dispatches", async () => { + const ctx = await setupAssignedDevice(); + const N = 4; + let last: number[] = []; + for (let i = 0; i < N; i++) { + last = rand32(); + await program.methods + .submitProof(last) + .accounts({ + assignment: ctx.assignment, + shipment: ctx.shipment, + device: ctx.device, + submitter: authority.publicKey, + }) + .rpc(); + } + const shipmentAcct = await program.account.shipment.fetch(ctx.shipment); + assert.equal(shipmentAcct.proofCount, N); + assert.deepEqual(shipmentAcct.lastCommitment, last); + }); + + it("rejects a dispatch from an unauthorized submitter", async () => { + const ctx = await setupAssignedDevice(); + const wrong = Keypair.generate(); + const sig = await provider.connection.requestAirdrop(wrong.publicKey, 1e8); + await provider.connection.confirmTransaction(sig); + + try { + await program.methods + .submitProof(rand32()) + .accounts({ + assignment: ctx.assignment, + shipment: ctx.shipment, + device: ctx.device, + submitter: wrong.publicKey, + }) + .signers([wrong]) + .rpc(); + assert.fail("expected UnauthorizedSubmitter"); + } catch (err: any) { + assert.match(err.toString(), /UnauthorizedSubmitter/); + } + }); }); - it("getProgramAccounts can find all assignments for a shipment", async () => { - // This is the killer query for the insurance use case: - // "show me every device that was ever on this shipment" - const nonce = new BN(200); - const shipment = shipmentPda(authority.publicKey, nonce); - await program.methods - .createShipment(nonce) - .accounts({ - shipment, - authority: authority.publicKey, - systemProgram: SystemProgram.programId, - }) - .rpc(); + // Close ────────────────────────────────────────────────────────────────────── + + describe("close_shipment", () => { + it("closes a shipment and freezes proof_count in the event", async () => { + const ctx = await setupAssignedDevice(); + for (let i = 0; i < 3; i++) { + await program.methods + .submitProof(rand32()) + .accounts({ + assignment: ctx.assignment, + shipment: ctx.shipment, + device: ctx.device, + submitter: authority.publicKey, + }) + .rpc(); + } - // Register two devices and assign both to this shipment - for (let i = 0; i < 2; i++) { - const deviceId = makeDeviceId(); - const pubkey = makePubkey(); - const device = devicePda(deviceId); await program.methods - .registerDevice(deviceId, pubkey) + .closeShipment() + .accounts({ shipment: ctx.shipment, authority: authority.publicKey }) + .rpc(); + + const acct = await program.account.shipment.fetch(ctx.shipment); + assert.equal(acct.closed, true); + assert.equal(acct.proofCount, 3); + }); + + it("is one-way: a second close fails", async () => { + const ctx = await setupAssignedDevice(); + await program.methods + .closeShipment() + .accounts({ shipment: ctx.shipment, authority: authority.publicKey }) + .rpc(); + try { + await program.methods + .closeShipment() + .accounts({ shipment: ctx.shipment, authority: authority.publicKey }) + .rpc(); + assert.fail("expected ShipmentClosed"); + } catch (err: any) { + assert.match(err.toString(), /ShipmentClosed/); + } + }); + + it("rejects close from a non-authority signer", async () => { + const ctx = await setupAssignedDevice(); + const wrong = Keypair.generate(); + const sig = await provider.connection.requestAirdrop(wrong.publicKey, 1e8); + await provider.connection.confirmTransaction(sig); + + try { + await program.methods + .closeShipment() + .accounts({ shipment: ctx.shipment, authority: wrong.publicKey }) + .signers([wrong]) + .rpc(); + assert.fail("expected has_one constraint violation"); + } catch (err: any) { + // Anchor's has_one violation surfaces as a constraint error. + assert.match(err.toString(), /ConstraintHasOne|has_one|2001/i); + } + }); + + it("rejects submit_proof after close", async () => { + const ctx = await setupAssignedDevice(); + await program.methods + .closeShipment() + .accounts({ shipment: ctx.shipment, authority: authority.publicKey }) + .rpc(); + try { + await program.methods + .submitProof(rand32()) + .accounts({ + assignment: ctx.assignment, + shipment: ctx.shipment, + device: ctx.device, + submitter: authority.publicKey, + }) + .rpc(); + assert.fail("expected ShipmentClosed"); + } catch (err: any) { + assert.match(err.toString(), /ShipmentClosed/); + } + }); + + it("allows end_assignment after close (devices can be freed)", async () => { + const ctx = await setupAssignedDevice(); + await program.methods + .closeShipment() + .accounts({ shipment: ctx.shipment, authority: authority.publicKey }) + .rpc(); + // Should succeed — end_assignment does not depend on shipment.closed. + await program.methods + .endAssignment() .accounts({ - device, + device: ctx.device, + assignment: ctx.assignment, authority: authority.publicKey, - systemProgram: SystemProgram.programId, }) .rpc(); + const deviceAcct = await program.account.deviceRegistry.fetch(ctx.device); + assert.ok(deviceAcct.currentAssignment.equals(PublicKey.default)); + }); - const assignment = assignmentPda(device, 0); + it("rejects assign_device after close", async () => { + const ctx = await setupAssignedDevice(); await program.methods - .assignDevice() + .closeShipment() + .accounts({ shipment: ctx.shipment, authority: authority.publicKey }) + .rpc(); + + const newDeviceId = makeDeviceId(); + const newDevice = devicePda(newDeviceId); + await program.methods + .registerDevice(newDeviceId, makePubkey()) .accounts({ - device, - shipment, - assignment, + device: newDevice, authority: authority.publicKey, systemProgram: SystemProgram.programId, }) .rpc(); - } - - // Filter assignments by shipment field. Layout offset = 8 (discriminator) + 32 (device). - const assignments = await program.account.deviceAssignment.all([ - { - memcmp: { - offset: 8 + 32, - bytes: shipment.toBase58(), - }, - }, - ]); - - assert.equal(assignments.length, 2); - for (const a of assignments) { - assert.ok(a.account.shipment.equals(shipment)); - } + + const newAssignment = assignmentPda(newDevice, 0); + try { + await program.methods + .assignDevice() + .accounts({ + device: newDevice, + shipment: ctx.shipment, + assignment: newAssignment, + authority: authority.publicKey, + systemProgram: SystemProgram.programId, + }) + .rpc(); + assert.fail("expected ShipmentClosed"); + } catch (err: any) { + assert.match(err.toString(), /ShipmentClosed/); + } + }); }); }); From a89587320f0fcc667c7a58551dfcaa379264ce20 Mon Sep 17 00:00:00 2001 From: iguberman Date: Thu, 28 May 2026 03:29:26 -0500 Subject: [PATCH 2/2] replace airdrop with fundFromProvider in tests to avoid rate limiting --- tests/device-registry.ts | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/tests/device-registry.ts b/tests/device-registry.ts index d27cf59..dcda024 100644 --- a/tests/device-registry.ts +++ b/tests/device-registry.ts @@ -37,6 +37,23 @@ describe("device-registry", () => { return Array.from(crypto.randomBytes(32)); } + /** + * Fund a keypair by transferring from the provider wallet, avoiding the + * devnet airdrop faucet (which is rate-limited and fails in CI / repeated runs). + * Works identically on localnet and devnet. + */ + async function fundFromProvider(target: PublicKey, lamports: number) { + const tx = new anchor.web3.Transaction().add( + SystemProgram.transfer({ + fromPubkey: authority.publicKey, + toPubkey: target, + lamports, + }) + ); + await provider.sendAndConfirm(tx); + } + + function devicePda(deviceId: number[]): PublicKey { const [pda] = PublicKey.findProgramAddressSync( [Buffer.from("device"), Buffer.from(deviceId)], @@ -379,9 +396,8 @@ describe("device-registry", () => { it("rejects a dispatch from an unauthorized submitter", async () => { const ctx = await setupAssignedDevice(); - const wrong = Keypair.generate(); - const sig = await provider.connection.requestAirdrop(wrong.publicKey, 1e8); - await provider.connection.confirmTransaction(sig); + const wrong = Keypair.generate(); + await fundFromProvider(wrong.publicKey, 1e7); // 0.01 SOL, plenty for one failed tx try { await program.methods @@ -447,9 +463,8 @@ describe("device-registry", () => { it("rejects close from a non-authority signer", async () => { const ctx = await setupAssignedDevice(); - const wrong = Keypair.generate(); - const sig = await provider.connection.requestAirdrop(wrong.publicKey, 1e8); - await provider.connection.confirmTransaction(sig); + const wrong = Keypair.generate(); + await fundFromProvider(wrong.publicKey, 1e7); // 0.01 SOL, plenty for one failed tx try { await program.methods