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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,17 @@ soroban contract deploy \
--network testnet
```

## Data Migration

The `data_migration` payload library provides format-agnostic snapshot export/import capabilities across all contracts.

### Import Replay Protection

When importing payloads, the contracts use a `MigrationTracker` to prevent duplicate imports and replay attacks.
- Payload Identity is cryptographically bound to a `(checksum, version)` tuple.
- The state tracker persists this tuple alongside the ingestion `timestamp_ms` to outright reject replayed or copied payloads using `MigrationError::DuplicateImport`.
- Restores are safely guarded against double spending or overriding the active state multiple times.

## Operational Limits

ID and record-count operating limits (including `u32` overflow analysis and monitoring alerts) are documented in the **Operational Limits and Monitoring** section of [ARCHITECTURE.md](ARCHITECTURE.md).
Expand Down
85 changes: 79 additions & 6 deletions data_migration/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ pub enum MigrationError {
InvalidFormat(String),
ValidationFailed(String),
DeserializeError(String),
/// Indicates that the payload has already been imported.
DuplicateImport,
}

impl std::fmt::Display for MigrationError {
Expand All @@ -201,12 +203,48 @@ impl std::fmt::Display for MigrationError {
MigrationError::InvalidFormat(s) => write!(f, "invalid format: {}", s),
MigrationError::ValidationFailed(s) => write!(f, "validation failed: {}", s),
MigrationError::DeserializeError(s) => write!(f, "deserialize error: {}", s),
MigrationError::DuplicateImport => write!(f, "duplicate payload import detected"),
}
}
}

impl std::error::Error for MigrationError {}

/// Tracks imported migration payloads to prevent replay attacks and duplicate restores.
///
/// Binds payload identity to a `(checksum, version)` tuple.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MigrationTracker {
/// Stores the set of imported payloads, keyed by their checksum and version.
/// Tracks the timestamp when it was imported.
imported_payloads: HashMap<(String, u32), u64>,
}

impl MigrationTracker {
pub fn new() -> Self {
Self {
imported_payloads: HashMap::new(),
}
}

/// Mark a payload as imported.
/// Returns an error if it was already imported, preventing replay attacks.
pub fn mark_imported(&mut self, snapshot: &ExportSnapshot, timestamp_ms: u64) -> Result<(), MigrationError> {
let identity = (snapshot.header.checksum.clone(), snapshot.header.version);
if self.imported_payloads.contains_key(&identity) {
return Err(MigrationError::DuplicateImport);
}
self.imported_payloads.insert(identity, timestamp_ms);
Ok(())
}

/// Check if a snapshot has already been imported.
pub fn is_imported(&self, snapshot: &ExportSnapshot) -> bool {
let identity = (snapshot.header.checksum.clone(), snapshot.header.version);
self.imported_payloads.contains_key(&identity)
}
}

/// Export snapshot to JSON bytes.
pub fn export_to_json(snapshot: &ExportSnapshot) -> Result<Vec<u8>, MigrationError> {
serde_json::to_vec_pretty(snapshot).map_err(|e| MigrationError::DeserializeError(e.to_string()))
Expand Down Expand Up @@ -260,19 +298,29 @@ pub fn import_from_encrypted_payload(encoded: &str) -> Result<Vec<u8>, Migration
.map_err(|e| MigrationError::InvalidFormat(e.to_string()))
}

/// Import snapshot from JSON bytes with validation.
pub fn import_from_json(bytes: &[u8]) -> Result<ExportSnapshot, MigrationError> {
/// Import snapshot from JSON bytes with validation and replay protection.
pub fn import_from_json(
bytes: &[u8],
tracker: &mut MigrationTracker,
timestamp_ms: u64,
) -> Result<ExportSnapshot, MigrationError> {
let snapshot: ExportSnapshot = serde_json::from_slice(bytes)
.map_err(|e| MigrationError::DeserializeError(e.to_string()))?;
snapshot.validate_for_import()?;
tracker.mark_imported(&snapshot, timestamp_ms)?;
Ok(snapshot)
}

/// Import snapshot from binary bytes with validation.
pub fn import_from_binary(bytes: &[u8]) -> Result<ExportSnapshot, MigrationError> {
/// Import snapshot from binary bytes with validation and replay protection.
pub fn import_from_binary(
bytes: &[u8],
tracker: &mut MigrationTracker,
timestamp_ms: u64,
) -> Result<ExportSnapshot, MigrationError> {
let snapshot: ExportSnapshot =
bincode::deserialize(bytes).map_err(|e| MigrationError::DeserializeError(e.to_string()))?;
snapshot.validate_for_import()?;
tracker.mark_imported(&snapshot, timestamp_ms)?;
Ok(snapshot)
}

Expand Down Expand Up @@ -371,7 +419,8 @@ mod tests {
});
let snapshot = ExportSnapshot::new(payload, ExportFormat::Json);
let bytes = export_to_json(&snapshot).unwrap();
let loaded = import_from_json(&bytes).unwrap();
let mut tracker = MigrationTracker::new();
let loaded = import_from_json(&bytes, &mut tracker, 123456).unwrap();
assert_eq!(loaded.header.version, SCHEMA_VERSION);
assert!(loaded.verify_checksum());
}
Expand All @@ -387,10 +436,34 @@ mod tests {
});
let snapshot = ExportSnapshot::new(payload, ExportFormat::Binary);
let bytes = export_to_binary(&snapshot).unwrap();
let loaded = import_from_binary(&bytes).unwrap();
let mut tracker = MigrationTracker::new();
let loaded = import_from_binary(&bytes, &mut tracker, 123456).unwrap();
assert!(loaded.verify_checksum());
}

#[test]
fn test_import_replay_protection_prevents_duplicates() {
let payload = SnapshotPayload::RemittanceSplit(RemittanceSplitExport {
owner: "GREPLAY".into(),
spending_percent: 50,
savings_percent: 30,
bills_percent: 10,
insurance_percent: 10,
});
let snapshot = ExportSnapshot::new(payload, ExportFormat::Json);
let bytes = export_to_json(&snapshot).unwrap();

let mut tracker = MigrationTracker::new();

// First import should succeed
let loaded1 = import_from_json(&bytes, &mut tracker, 1000).unwrap();
assert!(tracker.is_imported(&loaded1));

// Second import of the exact same snapshot should fail
let result2 = import_from_json(&bytes, &mut tracker, 2000);
assert_eq!(result2.unwrap_err(), MigrationError::DuplicateImport);
}

#[test]
fn test_checksum_mismatch_import_fails() {
let payload = SnapshotPayload::RemittanceSplit(RemittanceSplitExport {
Expand Down
1 change: 0 additions & 1 deletion orchestrator/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ impl MockFamilyWallet {
}
}

/// Mock Remittance Split contract for testing
#[contract]
pub struct MockRemittanceSplit;

Expand Down
Loading