Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 70 additions & 9 deletions contract/contracts/helphone-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use soroban_sdk::{
contract, contracterror, contractevent, contractimpl, contracttype, symbol_short, Address,
Env, String,
Bytes, Env, String,
};

mod compression;
Expand All @@ -25,6 +25,7 @@ pub use multisig::{Proposal, ProposalAction};
// ("active", u32) → u64 active request IDs by slot index
//
// Persistent (pay-to-live):
// ("req", u64) → HelpRequest (includes the sealed, responder-only payload)
// ("req2", u64) → StoredHelpRequest (packed coordinates and timestamp)
// ("req", u64) → HelpRequest (legacy entries, read through migration fallback)
// ("rcount", request_id) → u32 responder count per request
Expand Down Expand Up @@ -77,6 +78,12 @@ pub enum Error {
DuplicateApproval = 9,
ThresholdNotMet = 10,
ProposalExecuted = 11,
/// `create_request` was called without an encrypted payload.
PayloadEmpty = 12,
/// The encrypted payload exceeds `MAX_ENCRYPTED_PAYLOAD_BYTES`.
PayloadTooLarge = 13,
/// `emergency_type` is empty or longer than `MAX_EMERGENCY_TYPE_BYTES`.
EmergencyTypeInvalid = 14,
}

// ── Types ──────────────────────────────────────────────────────────
Expand All @@ -89,6 +96,37 @@ pub enum Status {
Cancelled,
}

/// Upper bound on the opaque encrypted blob, in bytes.
///
/// Mirrors `MAX_ENVELOPE_BYTES` in `src/lib/crypto.ts`, which enforces the
/// same limit client-side so an oversized payload fails locally instead of
/// reverting a signed transaction. The envelope hex-encodes its ciphertext, so
/// this is roughly twice the `MAX_PAYLOAD_PLAINTEXT_BYTES` (4 KiB) cap.
pub const MAX_ENCRYPTED_PAYLOAD_BYTES: u32 = 12288;

/// Upper bound on `emergency_type`, in bytes. Kept short because it is
/// plaintext and is indexed/filtered on by responders.
pub const MAX_EMERGENCY_TYPE_BYTES: u32 = 32;

/// A help request as stored on the ledger.
///
/// The sensitive half of the request — contact number, medical notes,
/// allergies — is **not** here in the clear. `encrypted_payload` is a sealed
/// envelope produced by the client (ECDH P-256 + HKDF-SHA256 + AES-256-GCM,
/// see `src/lib/crypto.ts`) whose content key is wrapped once per authorized
/// responder. This contract never holds a decryption key and never parses the
/// envelope; it stores opaque bytes and bounds their length.
///
/// Deliberately left in plaintext, because dispatch is impossible without them:
/// * `lat` / `lng` — responders must see where. Coarse location privacy is
/// the separate ZK/Aegis layer's job (circuits/, contracts/aegis_vault).
/// * `emergency_type` — responders filter on it to send the right aid.
///
/// ### Storage-layout note
/// This replaces the previous `nickname: String` / `contact: String` pair with
/// a single `Bytes`, which changes the XDR layout of every stored request.
/// Existing deployments need a state migration before upgrading; see
/// docs/security-architecture.md → "End-to-End Encrypted Payloads".
#[derive(Clone, Debug, Eq, PartialEq)]
#[contracttype]
pub struct HelpRequest {
Expand All @@ -99,8 +137,8 @@ pub struct HelpRequest {
/// Longitude encoded as integer (degrees × 1_000_000)
pub lng: i32,
pub emergency_type: String,
pub nickname: String,
pub contact: String,
/// Opaque, sealed responder-only payload. Never readable by this contract.
pub encrypted_payload: Bytes,
pub status: Status,
pub created_at: u64,
pub resolved_at: Option<u64>,
Expand Down Expand Up @@ -387,25 +425,48 @@ impl HelPhone {

// ── Emergency Request Lifecycle ─────────────────────────────────

/// Broadcast a help request.
///
/// `encrypted_payload` is an opaque sealed envelope (JSON, hex-encoded
/// ciphertext) built client-side. The contract validates only that it is
/// present and within `MAX_ENCRYPTED_PAYLOAD_BYTES`; it cannot read it and
/// holds no key that could. Responders pull the envelope, then decrypt it
/// locally with their own private key.
///
/// # Errors
/// * `PayloadEmpty` — no payload supplied; plaintext contact details are
/// no longer accepted at all.
/// * `PayloadTooLarge` — longer than `MAX_ENCRYPTED_PAYLOAD_BYTES`.
/// * `EmergencyTypeInvalid` — empty or over-long dispatch category.
#[allow(clippy::too_many_arguments)]
pub fn create_request(
env: Env,
requester: Address,
lat: i32,
lng: i32,
emergency_type: String,
nickname: String,
contact: String,
) -> u64 {
encrypted_payload: Bytes,
) -> Result<u64, Error> {
requester.require_auth();

if encrypted_payload.is_empty() {
return Err(Error::PayloadEmpty);
}
if encrypted_payload.len() > MAX_ENCRYPTED_PAYLOAD_BYTES {
return Err(Error::PayloadTooLarge);
}
if emergency_type.is_empty() || emergency_type.len() > MAX_EMERGENCY_TYPE_BYTES {
return Err(Error::EmergencyTypeInvalid);
}

let count = Self::get_request_count(env.clone()) + 1;
env.storage().instance().set(&key_req_count(), &count);
let req = StoredHelpRequest {
id: count,
requester,
packed_location: compression::pack_location(&env, lat, lng, env.ledger().timestamp()),
emergency_type,
nickname,
contact,
encrypted_payload,
status: Status::Pending,
resolved_at: None,
};
Expand All @@ -418,7 +479,7 @@ impl HelPhone {
env.storage()
.instance()
.set(&key_active_count(), &(active_count + 1));
count
Ok(count)
}

pub fn accept_request(
Expand Down
Loading