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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ NODE_ENV=development
# Server port
PORT=3001

# Passkeys: use the production hostname and origin when enabling WebAuthn.
PASSKEY_RP_ID=localhost
PASSKEY_ORIGIN=http://localhost:3000
PASSKEY_RP_NAME=HelPhone
SESSION_SECRET=replace-with-at-least-32-random-characters

# Database connection
DATABASE_URL=postgresql://user:password@localhost:5432/helphone

Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,7 @@ tests/integration/.local-deployment.json

# WASM spike reader build output (ADR-014)
src/wasm/**/target/

# Python tooling cache
__pycache__/
*.py[cod]
44 changes: 44 additions & 0 deletions circuits/scripts/shard-aegis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Split a large aegis.json asset into cacheable, bounded-size files."""

import argparse
import hashlib
import json
from pathlib import Path


def shard(source: Path, output: Path, chunk_bytes: int) -> Path:
if chunk_bytes < 1:
raise ValueError("chunk size must be positive")
if source.stat().st_size == 0:
raise ValueError("cannot shard an empty circuit artifact")
output.mkdir(parents=True, exist_ok=True)
files = []
with source.open("rb") as stream:
index = 0
while chunk := stream.read(chunk_bytes):
name = f"aegis.chunk{index:04d}"
(output / name).write_bytes(chunk)
files.append({"file": name, "bytes": len(chunk), "sha256": hashlib.sha256(chunk).hexdigest()})
index += 1
digest = hashlib.sha256()
with source.open("rb") as stream:
for part in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(part)
manifest = {"version": 1, "bytes": source.stat().st_size, "sha256": digest.hexdigest(), "chunks": files}
target = output / "aegis.manifest.json"
target.write_text(json.dumps(manifest, separators=(",", ":")) + "\n", encoding="utf-8")
return target


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("source", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--chunk-bytes", type=int, default=5 * 1024 * 1024)
args = parser.parse_args()
print(shard(args.source, args.output, args.chunk_bytes))


if __name__ == "__main__":
main()
28 changes: 28 additions & 0 deletions circuits/scripts/test-shard-aegis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import importlib.util
import json
import tempfile
import unittest
from pathlib import Path

SCRIPT = Path(__file__).with_name("shard-aegis.py")
SPEC = importlib.util.spec_from_file_location("shard_aegis", SCRIPT)
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)


class ShardAegisTests(unittest.TestCase):
def test_shards_reassemble_and_manifest_sizes_match(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / "aegis.json"
payload = bytes(range(256)) * 9
source.write_bytes(payload)
manifest_path = MODULE.shard(source, root / "out", 512)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
rebuilt = b"".join((manifest_path.parent / item["file"]).read_bytes() for item in manifest["chunks"])
self.assertEqual(rebuilt, payload)
self.assertTrue(all(item["bytes"] <= 512 for item in manifest["chunks"]))


if __name__ == "__main__":
unittest.main()
40 changes: 40 additions & 0 deletions contract/contracts/helphone-contract/src/compression.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
use soroban_sdk::{BytesN, Env};

/// Lossless 16-byte encoding: signed latitude (i32), signed longitude (i32), ledger timestamp (u64).
pub fn pack_location(env: &Env, lat: i32, lng: i32, timestamp: u64) -> BytesN<16> {
let mut bytes = [0u8; 16];
bytes[..4].copy_from_slice(&lat.to_be_bytes());
bytes[4..8].copy_from_slice(&lng.to_be_bytes());
bytes[8..].copy_from_slice(&timestamp.to_be_bytes());
BytesN::from_array(env, &bytes)
}

pub fn unpack_location(packed: &BytesN<16>) -> (i32, i32, u64) {
let bytes = packed.to_array();
(
i32::from_be_bytes(bytes[..4].try_into().expect("four-byte latitude")),
i32::from_be_bytes(bytes[4..8].try_into().expect("four-byte longitude")),
u64::from_be_bytes(bytes[8..].try_into().expect("eight-byte timestamp")),
)
}

#[cfg(test)]
mod tests {
use super::*;
use soroban_sdk::Env;

#[test]
fn round_trips_signed_coordinates_and_full_timestamp() {
let env = Env::default();
let cases = [
(-90_000_000, -180_000_000, 0),
(90_000_000, 180_000_000, u64::MAX),
];
for (lat, lng, timestamp) in cases {
assert_eq!(
unpack_location(&pack_location(&env, lat, lng, timestamp)),
(lat, lng, timestamp)
);
}
}
}
103 changes: 69 additions & 34 deletions contract/contracts/helphone-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use soroban_sdk::{
Env, String,
};

mod compression;
mod multisig;
mod nonce;
mod ring_buffer;
Expand All @@ -24,7 +25,8 @@ pub use multisig::{Proposal, ProposalAction};
// ("active", u32) → u64 active request IDs by slot index
//
// Persistent (pay-to-live):
// ("req", u64) → HelpRequest
// ("req2", u64) → StoredHelpRequest (packed coordinates and timestamp)
// ("req", u64) → HelpRequest (legacy entries, read through migration fallback)
// ("rcount", request_id) → u32 responder count per request
// ("resp", request_id, idx) → ResponderRecord
// ("evcount", wallet) → u32 verifications ever recorded per wallet (write cursor)
Expand All @@ -37,7 +39,7 @@ pub use multisig::{Proposal, ProposalAction};
// repeat invocations.
//
// * mark_arrived / resolve_request / cancel_request touch only
// ("req", id) / ("resp", request_id, responder_index) — fully determined
// ("req2", id) / ("resp", request_id, responder_index) — fully determined
// by the function arguments, so the client pre-bakes their footprint.
// * create_request / accept_request / record_expert_verification append to
// counter/slot keys whose values depend on on-chain state; their footprint
Expand Down Expand Up @@ -104,6 +106,61 @@ pub struct HelpRequest {
pub resolved_at: Option<u64>,
}

#[derive(Clone, Debug, Eq, PartialEq)]
#[contracttype]
struct StoredHelpRequest {
pub id: u64,
pub requester: Address,
pub packed_location: soroban_sdk::BytesN<16>,
pub emergency_type: String,
pub nickname: String,
pub contact: String,
pub status: Status,
pub resolved_at: Option<u64>,
}

fn load_request(env: &Env, id: u64) -> Option<StoredHelpRequest> {
if let Some(stored) = env.storage().persistent().get(&(symbol_short!("req2"), id)) {
return Some(stored);
}
let legacy: HelpRequest = env
.storage()
.persistent()
.get(&(symbol_short!("req"), id))?;
Some(StoredHelpRequest {
id: legacy.id,
requester: legacy.requester,
packed_location: compression::pack_location(env, legacy.lat, legacy.lng, legacy.created_at),
emergency_type: legacy.emergency_type,
nickname: legacy.nickname,
contact: legacy.contact,
status: legacy.status,
resolved_at: legacy.resolved_at,
})
}

fn save_request(env: &Env, request: &StoredHelpRequest) {
env.storage()
.persistent()
.set(&(symbol_short!("req2"), request.id), request);
}

fn request_view(request: StoredHelpRequest) -> HelpRequest {
let (lat, lng, created_at) = compression::unpack_location(&request.packed_location);
HelpRequest {
id: request.id,
requester: request.requester,
lat,
lng,
emergency_type: request.emergency_type,
nickname: request.nickname,
contact: request.contact,
status: request.status,
created_at,
resolved_at: request.resolved_at,
}
}

#[derive(Clone, Debug, Eq, PartialEq)]
#[contracttype]
pub struct ResponderRecord {
Expand Down Expand Up @@ -266,7 +323,7 @@ impl HelPhone {
}

pub fn get_request(env: Env, id: u64) -> Option<HelpRequest> {
env.storage().persistent().get(&(symbol_short!("req"), id))
load_request(&env, id).map(request_view)
}

pub fn get_responder_count(env: Env, request_id: u64) -> u32 {
Expand Down Expand Up @@ -342,21 +399,17 @@ impl HelPhone {
requester.require_auth();
let count = Self::get_request_count(env.clone()) + 1;
env.storage().instance().set(&key_req_count(), &count);
let req = HelpRequest {
let req = StoredHelpRequest {
id: count,
requester,
lat,
lng,
packed_location: compression::pack_location(&env, lat, lng, env.ledger().timestamp()),
emergency_type,
nickname,
contact,
status: Status::Pending,
created_at: env.ledger().timestamp(),
resolved_at: None,
};
env.storage()
.persistent()
.set(&(symbol_short!("req"), count), &req);
save_request(&env, &req);
// Append to active list
let active_count = Self::get_active_count(env.clone());
env.storage()
Expand All @@ -377,18 +430,12 @@ impl HelPhone {
eta_seconds: u32,
) -> Result<u32, Error> {
responder.require_auth();
let mut req: HelpRequest = env
.storage()
.persistent()
.get(&(symbol_short!("req"), request_id))
.ok_or(Error::NotFound)?;
let mut req = load_request(&env, request_id).ok_or(Error::NotFound)?;
if req.status != Status::Pending {
return Err(Error::WrongStatus);
}
req.status = Status::Enroute;
env.storage()
.persistent()
.set(&(symbol_short!("req"), request_id), &req);
save_request(&env, &req);

let idx: u32 = env
.storage()
Expand Down Expand Up @@ -439,11 +486,7 @@ impl HelPhone {

pub fn resolve_request(env: Env, requester: Address, request_id: u64) -> Result<(), Error> {
requester.require_auth();
let mut req: HelpRequest = env
.storage()
.persistent()
.get(&(symbol_short!("req"), request_id))
.ok_or(Error::NotFound)?;
let mut req = load_request(&env, request_id).ok_or(Error::NotFound)?;
if req.requester != requester {
return Err(Error::NotAuthorized);
}
Expand All @@ -452,29 +495,21 @@ impl HelPhone {
}
req.status = Status::Resolved;
req.resolved_at = Some(env.ledger().timestamp());
env.storage()
.persistent()
.set(&(symbol_short!("req"), request_id), &req);
save_request(&env, &req);
Ok(())
}

pub fn cancel_request(env: Env, requester: Address, request_id: u64) -> Result<(), Error> {
requester.require_auth();
let mut req: HelpRequest = env
.storage()
.persistent()
.get(&(symbol_short!("req"), request_id))
.ok_or(Error::NotFound)?;
let mut req = load_request(&env, request_id).ok_or(Error::NotFound)?;
if req.requester != requester {
return Err(Error::NotAuthorized);
}
if req.status != Status::Pending {
return Err(Error::WrongStatus);
}
req.status = Status::Cancelled;
env.storage()
.persistent()
.set(&(symbol_short!("req"), request_id), &req);
save_request(&env, &req);
Ok(())
}

Expand Down
Loading
Loading