Skip to content

Commit c45f749

Browse files
authored
Merge pull request #453 from Awointa/main
feat(remittance_split): harden import_snapshot validation pipeline
2 parents 8ffcbbe + 98f7bd2 commit c45f749

36 files changed

Lines changed: 6689 additions & 3427 deletions

remittance_split/README.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,65 @@ Read-only integrity check for a snapshot payload — performs all structural che
239239

240240
**Use case:** pre-flight validation before calling `import_snapshot`, or off-chain verification of exported payloads.
241241

242+
---
243+
244+
## Snapshot Import Validation
245+
246+
### Ordered Validation Pipeline
247+
248+
`import_snapshot` runs the following checks in strict order. The first failing check aborts the
249+
call, appends a failed audit entry, and returns the corresponding error. No state is written on
250+
failure.
251+
252+
| Step | Guard | Error returned |
253+
|------|-------|----------------|
254+
| 1 | `caller.require_auth()` + contract not paused + nonce matches | `Unauthorized` / `InvalidNonce` |
255+
| 2 | `snapshot.version` within `[MIN_SUPPORTED_SCHEMA_VERSION, SCHEMA_VERSION]` | `UnsupportedVersion` |
256+
| 3 | FNV-1a checksum matches recomputed value | `ChecksumMismatch` |
257+
| 4 | `snapshot.config.initialized == true` | `SnapshotNotInitialized` |
258+
| 5 | Each percentage field `<= 100` | `InvalidPercentageRange` |
259+
| 6 | Sum of all four percentage fields `== 100` | `InvalidPercentages` |
260+
| 7 | `snapshot.config.timestamp` and `exported_at` are not in the future | `InvalidAmount` |
261+
| 8 | Caller is the current on-chain owner (`existing.owner == caller`) | `Unauthorized` |
262+
| 9 | Snapshot owner matches caller (`snapshot.config.owner == caller`) | `OwnerMismatch` |
263+
264+
### New Error Variants (discriminants 17–20)
265+
266+
These variants were added as part of the snapshot import hardening and extend the
267+
`RemittanceSplitError` enum:
268+
269+
| Discriminant | Variant | Trigger condition |
270+
|---|---|---|
271+
| 17 | `SnapshotNotInitialized` | The snapshot's `config.initialized` flag is `false`; importing an uninitialized config is rejected. |
272+
| 18 | `FutureTimestamp` | Reserved for future use; the pipeline currently maps future-timestamp failures to `InvalidAmount` (discriminant 4). |
273+
| 19 | `OwnerMismatch` | `snapshot.config.owner` does not equal the calling address, meaning the snapshot was exported by a different owner. |
274+
| 20 | `InvalidPercentageRange` | At least one of the four percentage fields exceeds 100; delegated to `validate_percentages`. |
275+
276+
### `verify_snapshot` Pre-flight Helper
277+
278+
`verify_snapshot` is a **stateless, read-only** function that mirrors steps 2–7 of the
279+
`import_snapshot` pipeline. It is intended as a pre-flight check before committing a nonce and
280+
writing state.
281+
282+
**Checks performed (in order):**
283+
284+
| Step | Guard | Error returned |
285+
|------|-------|----------------|
286+
| 1 | Schema version within supported range | `UnsupportedVersion` |
287+
| 2 | FNV-1a checksum integrity | `ChecksumMismatch` |
288+
| 3 | `config.initialized == true` | `SnapshotNotInitialized` |
289+
| 4 | Per-field percentage range (`<= 100`) | `InvalidPercentageRange` |
290+
| 5 | Percentage sum `== 100` | `InvalidPercentages` |
291+
| 6 | Timestamp not in the future | `InvalidAmount` |
292+
293+
**Not checked by `verify_snapshot`:**
294+
- Caller authorization / nonce (steps 1, 8 of the full pipeline)
295+
- Ownership match (step 9 of the full pipeline)
296+
297+
This means `verify_snapshot` can be called by anyone without consuming a nonce or requiring the
298+
caller to be the contract owner. It returns `true` when all structural checks pass and `false`
299+
(or propagates an error) when any check fails.
300+
242301
#### `calculate_split(env, total_amount) -> Vec<i128>`
243302

244303
Storage-read-only calculation — returns `[spending, savings, bills, insurance]` amounts.
@@ -335,6 +394,10 @@ pub enum RemittanceSplitError {
335394
DeadlineExpired = 14, // request expired
336395
RequestHashMismatch = 15, // request hash binding failed
337396
NonceAlreadyUsed = 16, // replay duplicate protection
397+
SnapshotNotInitialized = 17, // snapshot config.initialized is false
398+
FutureTimestamp = 18, // reserved; pipeline uses InvalidAmount for future timestamps
399+
OwnerMismatch = 19, // snapshot.config.owner != caller
400+
InvalidPercentageRange = 20, // a percentage field exceeds 100
338401
}
339402
```
340403

remittance_split/src/lib.rs

Lines changed: 73 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,44 @@ pub struct SplitInitializedEvent {
2525
pub timestamp: u64,
2626
}
2727

28+
/// Domain-separated authorization payload for `initialize_split`.
29+
/// Passed to `require_auth_for_args` to bind the authorization to the
30+
/// specific operation parameters, preventing replay across different calls.
31+
#[derive(Clone, Debug, Eq, PartialEq)]
32+
#[contracttype]
33+
pub struct SplitAuthPayload {
34+
pub domain_id: Symbol,
35+
pub network_id: BytesN<32>,
36+
pub contract_addr: Address,
37+
pub owner_addr: Address,
38+
pub nonce_val: u64,
39+
pub usdc_contract: Address,
40+
pub spending_percent: u32,
41+
pub savings_percent: u32,
42+
pub bills_percent: u32,
43+
pub insurance_percent: u32,
44+
}
45+
2846
#[contracterror]
2947
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
3048
#[repr(u32)]
3149
pub enum RemittanceSplitError {
3250
AlreadyInitialized = 1,
51+
/// The contract has not been initialized yet; `initialize_split` must be called first.
3352
NotInitialized = 2,
53+
/// One or more split percentages are invalid: either a field exceeds 100 or the four
54+
/// fields do not sum to exactly 100.
3455
InvalidPercentages = 3,
3556
InvalidAmount = 4,
3657
Overflow = 5,
58+
/// The caller is not authorized to perform this operation.
3759
Unauthorized = 6,
3860
InvalidNonce = 7,
61+
/// The snapshot's `schema_version` is outside the supported range
62+
/// `[MIN_SUPPORTED_SCHEMA_VERSION, SCHEMA_VERSION]`.
3963
UnsupportedVersion = 8,
64+
/// The snapshot's `checksum` field does not match the value computed from its contents;
65+
/// the payload may have been tampered with or corrupted.
4066
ChecksumMismatch = 9,
4167
InvalidDueDate = 10,
4268
ScheduleNotFound = 11,
@@ -1022,20 +1048,36 @@ impl RemittanceSplit {
10221048
}))
10231049
}
10241050

1025-
/// Import a previously exported snapshot after validating version and checksum.
1051+
/// Import a previously exported snapshot, restoring the full `SplitConfig` and
1052+
/// associated `RemittanceSchedule` list to on-chain storage after running the
1053+
/// complete validation pipeline.
10261054
///
10271055
/// # Arguments
10281056
/// * `caller` - Split owner address (must authorize)
10291057
/// * `nonce` - Replay-protection nonce (must equal `get_nonce(caller)`)
10301058
/// * `snapshot` - Serialized configuration snapshot to restore
10311059
///
10321060
/// # Errors
1033-
/// - `Unauthorized` if `caller` is not the split owner or the contract is paused
1034-
/// - `InvalidNonce` if the replay-protection nonce does not match
1035-
/// - `UnsupportedVersion` if the snapshot schema version is not supported
1036-
/// - `ChecksumMismatch` if the snapshot checksum is invalid
1037-
/// - `PercentagesDoNotSumTo100` if the imported configuration is malformed
1038-
/// - `NotInitialized` if no existing configuration is present to authorize the caller
1061+
/// * `UnsupportedVersion` — `snapshot.schema_version` is outside
1062+
/// `[MIN_SUPPORTED_SCHEMA_VERSION, SCHEMA_VERSION]`.
1063+
/// * `ChecksumMismatch` — `snapshot.checksum` does not match the value computed
1064+
/// by `compute_checksum` over the snapshot fields.
1065+
/// * `SnapshotNotInitialized` — `snapshot.config.initialized` is `false`; the
1066+
/// snapshot represents an incomplete or factory-default configuration.
1067+
/// * `InvalidPercentageRange` — at least one of `spending_percent`,
1068+
/// `savings_percent`, `bills_percent`, or `insurance_percent` exceeds `100`.
1069+
/// * `InvalidPercentages` — all four percentage fields are within `[0, 100]` but
1070+
/// their sum is not equal to `100`.
1071+
/// * `InvalidAmount` — `snapshot.config.timestamp` is greater than the current
1072+
/// ledger timestamp, indicating a future-dated or replayed payload.
1073+
/// * `Unauthorized` — `caller` is not the current on-chain owner stored in
1074+
/// instance storage, or the contract is paused.
1075+
/// * `OwnerMismatch` — `snapshot.config.owner` does not equal `caller`, which
1076+
/// would silently transfer ownership if allowed.
1077+
/// * `NotInitialized` — no existing `SplitConfig` is present in instance storage
1078+
/// (the contract has not been initialized).
1079+
/// * `InvalidNonce` — the provided `nonce` has already been used or does not
1080+
/// match the expected replay-protection value for `caller`.
10391081
pub fn import_snapshot(
10401082
env: Env,
10411083
caller: Address,
@@ -1084,7 +1126,7 @@ impl RemittanceSplit {
10841126
+ snapshot.config.insurance_percent;
10851127
if total != 100 {
10861128
Self::append_audit(&env, symbol_short!("import"), &caller, false);
1087-
return Err(RemittanceSplitError::InvalidPercentages);
1129+
return Err(e);
10881130
}
10891131

10901132
// 6. Timestamp sanity — reject payloads whose timestamps are in the future.
@@ -1157,29 +1199,41 @@ impl RemittanceSplit {
11571199

11581200
/// Verify snapshot integrity without importing it.
11591201
///
1160-
/// Runs the same checks as `import_snapshot` (version boundary, checksum,
1161-
/// initialized flag, per-field range, sum constraint, and timestamp sanity)
1162-
/// **without** modifying any contract state. Use this as a pre-flight check
1163-
/// before calling `import_snapshot`.
1202+
/// Runs the same validation pipeline as `import_snapshot` steps 2–7 — schema
1203+
/// version boundary, checksum integrity, initialized flag, per-field percentage
1204+
/// range, percentage sum constraint, and timestamp sanity — **without** modifying
1205+
/// any contract state. Use this as a read-only pre-flight check before calling
1206+
/// `import_snapshot`.
11641207
///
1165-
/// Returns `Ok(true)` when the snapshot is valid and ready to import.
1166-
/// Returns an error variant describing the first failing check.
1208+
/// Returns `Ok(true)` when all checks pass and the snapshot is ready to import.
1209+
///
1210+
/// # Errors
1211+
/// - `UnsupportedVersion` — `snapshot.schema_version` is outside
1212+
/// `[MIN_SUPPORTED_SCHEMA_VERSION, SCHEMA_VERSION]`.
1213+
/// - `ChecksumMismatch` — the stored checksum does not match the freshly
1214+
/// computed digest over the snapshot fields.
1215+
/// - `SnapshotNotInitialized` — `snapshot.config.initialized` is `false`.
1216+
/// - `InvalidPercentageRange` — at least one of the four percentage fields
1217+
/// individually exceeds `100`.
1218+
/// - `InvalidPercentages` — all four fields are ≤ 100 but their sum is not `100`.
1219+
/// - `InvalidAmount` — `snapshot.config.timestamp` is greater than the current
1220+
/// ledger timestamp (future-dated payload).
11671221
///
11681222
/// # Note
1169-
/// This function does **not** verify ownership mapping (that requires knowing
1170-
/// which address will perform the import) or nonce validity.
1223+
/// This function does **not** check ownership mapping or nonce validity; those
1224+
/// require a specific caller context and are only enforced by `import_snapshot`.
11711225
pub fn verify_snapshot(
11721226
env: Env,
11731227
snapshot: ExportSnapshot,
11741228
) -> Result<bool, RemittanceSplitError> {
1175-
// 1. Version boundary
1229+
// Step 2. Schema version boundary
11761230
if snapshot.schema_version < MIN_SUPPORTED_SCHEMA_VERSION
11771231
|| snapshot.schema_version > SCHEMA_VERSION
11781232
{
11791233
return Err(RemittanceSplitError::UnsupportedVersion);
11801234
}
11811235

1182-
// 2. Checksum
1236+
// Step 3. Checksum integrity
11831237
let expected = Self::compute_checksum(
11841238
snapshot.schema_version,
11851239
&snapshot.config,
@@ -1189,7 +1243,7 @@ impl RemittanceSplit {
11891243
return Err(RemittanceSplitError::ChecksumMismatch);
11901244
}
11911245

1192-
// 3. Initialized flag
1246+
// Step 4. Initialized flag
11931247
if !snapshot.config.initialized {
11941248
return Err(RemittanceSplitError::NotInitialized);
11951249
}

0 commit comments

Comments
 (0)