Skip to content
Open
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
15 changes: 13 additions & 2 deletions backend/src/soroban/soroban.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,13 @@ export class SorobanService implements OnModuleInit {
try {
const result = await this.client.getPayment(invoiceId);
return !!result;
} catch {
} catch (error: any) {
if (error?.code === "PaymentArchived" || error?.numericCode === 24) {
this.logger.log(
`Invoice ${invoiceId} is recorded on-chain but archived due to TTL expiration`,
);
return true;
}
return false;
}
}
Expand All @@ -150,7 +156,12 @@ export class SorobanService implements OnModuleInit {
}
try {
return await this.client.getPayment(invoiceId);
} catch {
} catch (error: any) {
if (error?.code === "PaymentArchived" || error?.numericCode === 24) {
this.logger.warn(
`Invoice ${invoiceId} payment record is archived on-chain and needs restoration before reading`,
);
}
return null;
}
}
Expand Down
13 changes: 11 additions & 2 deletions soroban/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -579,9 +579,9 @@ event**, giving the Invoisio backend two independent reconciliation paths:
| **Admin-gated writes** | Only the backend service account (`admin`) may call `record_payment` |
| **Two-step admin handoff** | `propose_admin` (current admin) + `accept_admin` (proposed admin) — no single transaction can change the admin, so a lost/compromised key can never hand off alone |
| **One record per `invoice_id`** | Idempotent; prevents double-counting in reconciliation |
| **Persistent storage** | Records survive ledger archival windows |
| **Tiered storage retention** | Instance storage is permanent; persistent storage has a 90-day retention window with bulk extension (`extend_history_ttl`) and archival restoration |
| **Minimized events** | Only `schema_version` + `invoice_id` in each event (issue #512); a subscriber that needs the full record must already know `invoice_id` and call `get_payment` |
| **Privacy-by-default reads** | Bulk/volume reads (`payment_history`, `payment_count`, `settlement_ref_history`, `settlement_ref_index_status`, `history_index_status`) are admin-gated; `settlement_ref` is stored as a SHA-256 commitment, not plaintext (issue #512) — see `contracts/invoice-payment/README.md`'s "Disclosure guarantee / threat model" section |
| **Privacy-by-default reads** | Bulk/volume reads (`payment_history`, `payment_count`, `settlement_ref_history`, `settlement_ref_index_status`, `history_index_status`, `extend_history_ttl`) are admin-gated; `settlement_ref` is stored as a SHA-256 commitment, not plaintext (issue #512) — see `contracts/invoice-payment/README.md`'s "Disclosure guarantee / threat model" section |

#### Admin transfer flow

Expand Down Expand Up @@ -755,6 +755,15 @@ The contract uses `#[contracterror]`; these codes are returned as `ScError::Cont
| 20 | SettlementRefAlreadyUsed | The settlement reference is already recorded. Ambiguous on its own — call `settlement_ref_owner()` to tell a benign retry (same invoice) from a genuine conflict (different invoice) apart (issue #495). |
| 21 | MustBePausedForUpgrade | `upgrade()` was called while the contract is not paused; the contract must stay paused for the whole `upgrade()` → `upgrade_storage()` window. |
| 22 | LegacyPaymentMigrationBatchTooLarge | `migrate_legacy_payments()` was called with more invoice_ids than `MAX_LEGACY_MIGRATION_BATCH` (20) in one call; split the batch across multiple calls. |
| 23 | IssuerMigrationIncomplete | `upgrade_storage()` rewrote a bounded batch of Token issuers from String to Address and has more payment-log slots left; call `upgrade_storage()` again while paused. |
| 24 | PaymentArchived | `get_payment()` called for an `invoice_id` that exists in the write log but has expired due to TTL policy. The record can be restored via a Stellar `RestoreFootprint` operation. |

#### Retention and Archival Management

The contract implements a 3-tier retention policy:
1. **Hot Tier (Instance Storage):** Contract config and sequence counters are permanent and refreshed on every read/write.
2. **Active Rent Tier (Persistent Storage):** Payment records and history slots are extended to `BUMP_TTL` (~90 days / 1,555,200 ledgers) upon access. Administrators perform bulk sweeps via `extend_history_ttl` (`./invoke-extend-history-ttl.sh`).
3. **Cold Archival Tier (Network Archival):** Expired records return `PaymentArchived` (code 24) rather than `PaymentNotFound` (code 4), and are restorable on demand via `./invoke-restore-record.sh` without re-anchoring or index corruption. See [`docs/retention-and-restore.md`](docs/retention-and-restore.md) for full details.

#### Typed error manifest (off-chain reference)

Expand Down
8 changes: 8 additions & 0 deletions soroban/client/src/codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ export const MAX_SETTLEMENT_REF_LEN = 128;
*/
export const MAX_LEGACY_MIGRATION_BATCH = 20;

/**
* Maximum number of payment records/history slots extended in one
* `extendHistoryTtl` call. Mirrors `storage::MAX_TTL_EXTEND_BATCH`.
*/
export const MAX_TTL_EXTEND_BATCH = 20;

const CANONICAL_IDENTIFIER_PATTERN = /^[a-z0-9-]+$/;

/**
Expand Down Expand Up @@ -243,6 +249,8 @@ export function decodePaymentHistoryPage(scVal: xdr.ScVal): PaymentHistoryPage {
records: records.map((record) => decodePaymentRecordFromNative(record)),
nextCursor: Number(raw['next_cursor']),
hasMore: Boolean(raw['has_more']),
gapsSkipped: Number(raw['gaps_skipped'] ?? 0),
archivedSkipped: Number(raw['archived_skipped'] ?? 0),
};
}

Expand Down
1 change: 1 addition & 0 deletions soroban/client/src/error-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const EXPECTED_CONTRACT_ERRORS = [
{ code: 21, name: 'MustBePausedForUpgrade' },
{ code: 22, name: 'LegacyPaymentMigrationBatchTooLarge' },
{ code: 23, name: 'IssuerMigrationIncomplete' },
{ code: 24, name: 'PaymentArchived' },
] as const;

describe('CONTRACT_ERROR_MANIFEST', () => {
Expand Down
6 changes: 6 additions & 0 deletions soroban/client/src/error-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,12 @@ export const CONTRACT_ERROR_MANIFEST = [
meaning:
'upgrade_storage() rewrote a bounded batch of Token issuers from String to Address and has more payment-log slots left; call upgrade_storage() again. Stay paused until storage_schema_version is current.',
},
{
code: 24,
name: 'PaymentArchived',
meaning:
'get_payment() was called for an invoice_id that exists in the on-chain write log but whose persistent record has expired and been archived due to TTL expiration. It can be restored via a RestoreFootprint operation before reading.',
},
] as const satisfies readonly ContractErrorManifestEntry[];

/** Union of every known contract error name (excludes the `Unknown` fallback). */
Expand Down
45 changes: 44 additions & 1 deletion soroban/client/src/soroban-invoice-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
MAX_INVOICE_ID_LEN,
MAX_LEGACY_MIGRATION_BATCH,
MAX_SETTLEMENT_REF_LEN,
MAX_TTL_EXTEND_BATCH,
parseContractError,
} from './codec';

Expand Down Expand Up @@ -547,6 +548,46 @@ export class SorobanInvoiceClient {
return this.submitWrite(tx);
}

/**
* Extend persistent storage TTL across a bounded range of payment history records,
* write logs, and settlement references.
*
* @param cursor - zero-based history slot to start from (default 0)
* @param limit - maximum slots to process (default 20, capped at {@link MAX_TTL_EXTEND_BATCH})
*
* The **contract admin** keypair must be provided via `signerSecretKey`.
*
* @throws {Error} if `limit` exceeds {@link MAX_TTL_EXTEND_BATCH}
* @throws {SorobanContractError} on contract-level rejection (e.g. `Unauthorized`)
*/
async extendHistoryTtl(cursor = 0, limit = MAX_TTL_EXTEND_BATCH): Promise<TransactionResult> {
this.requireSigner();
if (limit > MAX_TTL_EXTEND_BATCH) {
throw new Error(
`limit must be at most ${MAX_TTL_EXTEND_BATCH}, got ${limit}`,
);
}
const account = await this.server.getAccount(this.keypair!.publicKey());
const caller = this.keypair!.publicKey();

const tx = new TransactionBuilder(account, {
fee: BASE_FEE,
networkPassphrase: this.config.networkPassphrase,
})
.addOperation(
this.contract.call(
'extend_history_ttl',
encodeAddress(caller),
encodeU32(cursor),
encodeU32(limit),
),
)
.setTimeout(TX_TIMEOUT_SECONDS)
.build();

return this.submitWrite(tx);
}

// ─── Read operations (permissionless) ──────────────────────────────────────

/**
Expand Down Expand Up @@ -585,7 +626,9 @@ export class SorobanInvoiceClient {
/**
* Fetch the full `PaymentRecord` for an invoice.
*
* @throws {SorobanContractError} with code `PaymentNotFound` if not recorded
* @throws {SorobanContractError} with code `PaymentArchived` if the record exists on-chain
* in the write log but has expired due to TTL policy (restorable via RestoreFootprint)
* @throws {SorobanContractError} with code `PaymentNotFound` if never recorded
*/
async getPayment(invoiceId: string): Promise<PaymentRecord> {
const retval = await this.simulateView('get_payment', encodeString(invoiceId));
Expand Down
18 changes: 18 additions & 0 deletions soroban/client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,24 @@ export interface PaymentHistoryPage {
readonly records: PaymentRecord[];
readonly nextCursor: number;
readonly hasMore: boolean;
/**
* Number of index slots in this page's range that represent unindexed
* corruption gaps (where neither PaymentHistory nor PaymentLog hold an entry).
*/
readonly gapsSkipped: number;
/**
* Number of index slots in this page's range that hold a valid entry in
* PaymentLog but whose persistent PaymentHistory record has expired and
* been archived due to TTL expiration.
*/
readonly archivedSkipped: number;
}

/** Result of extending TTL across a bounded range of history records. */
export interface ExtendHistoryTtlResult {
readonly extendedCount: number;
readonly nextCursor: number;
readonly hasMore: boolean;
}

/**
Expand Down
3 changes: 3 additions & 0 deletions soroban/contracts/invoice-payment/generate-schema.sh
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ declare -A ERROR_DESC=(
[MustBePausedForUpgrade]="upgrade() was called while the contract is not paused; the contract must stay paused for the whole upgrade() -> upgrade_storage() window."
[LegacyPaymentMigrationBatchTooLarge]="migrate_legacy_payments() was called with more invoice_ids than MAX_LEGACY_MIGRATION_BATCH in one call; split the batch across multiple calls."
[IssuerMigrationIncomplete]="upgrade_storage() rewrote a bounded batch of Token issuers from String to Address and has more payment-log slots left; call upgrade_storage() again while paused."
[PaymentArchived]="get_payment() called for an invoice_id that exists in the write log but has expired due to TTL policy."
)

# "Name = code," lines inside the ContractError enum, in declaration order.
Expand Down Expand Up @@ -127,6 +128,7 @@ declare -A METHOD_AUTH=(
[is_paused]="none"
[rebuild_history_index]="admin"
[history_index_status]="admin"
[extend_history_ttl]="admin"
[migrate_legacy_payments]="admin"
)
declare -A METHOD_DESC=(
Expand Down Expand Up @@ -159,6 +161,7 @@ declare -A METHOD_DESC=(
[is_paused]="Return true if the contract is currently paused."
[rebuild_history_index]="Rebuild the payment history index from existing records after a corruption or incomplete migration."
[history_index_status]="Admin-gated (issue #512): return (history_count, payment_count, is_consistent) diagnostic status for the history index."
[extend_history_ttl]="Admin-gated: extend persistent storage TTL across a bounded range of payment history records, write logs, and settlement references."
[migrate_legacy_payments]="Migrate a caller-supplied, bounded batch of legacy Payment(invoice_id) keys to PaymentV1, removing each legacy entry as it migrates. Returns (migrated, already_current, not_found)."
)

Expand Down
4 changes: 3 additions & 1 deletion soroban/contracts/invoice-payment/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,8 @@
"SettlementRefAlreadyUsed": { "code": 20, "description": "The settlement reference has already been used for a different invoice; each settlement reference must be globally unique across all payments." },
"MustBePausedForUpgrade": { "code": 21, "description": "upgrade() was called while the contract is not paused; the contract must stay paused for the whole upgrade() -> upgrade_storage() window." },
"LegacyPaymentMigrationBatchTooLarge": { "code": 22, "description": "migrate_legacy_payments() was called with more invoice_ids than MAX_LEGACY_MIGRATION_BATCH in one call; split the batch across multiple calls." },
"IssuerMigrationIncomplete": { "code": 23, "description": "upgrade_storage() rewrote a bounded batch of Token issuers from String to Address and has more payment-log slots left; call upgrade_storage() again while paused." }
"IssuerMigrationIncomplete": { "code": 23, "description": "upgrade_storage() rewrote a bounded batch of Token issuers from String to Address and has more payment-log slots left; call upgrade_storage() again while paused." },
"PaymentArchived": { "code": 24, "description": "get_payment() called for an invoice_id that exists in the write log but has expired due to TTL policy." }
},
"methods": {
"initialize": { "auth": "none", "description": "One-time setup; sets the admin." },
Expand Down Expand Up @@ -460,6 +461,7 @@
"is_paused": { "auth": "none", "description": "Return true if the contract is currently paused." },
"rebuild_history_index": { "auth": "admin", "description": "Rebuild the payment history index from existing records after a corruption or incomplete migration." },
"migrate_legacy_payments": { "auth": "admin", "description": "Migrate a caller-supplied, bounded batch of legacy Payment(invoice_id) keys to PaymentV1, removing each legacy entry as it migrates. Returns (migrated, already_current, not_found)." },
"extend_history_ttl": { "auth": "admin", "description": "Admin-gated: extend persistent storage TTL across a bounded range of payment history records, write logs, and settlement references." },
"history_index_status": { "auth": "admin", "description": "Admin-gated (issue #512): return (history_count, payment_count, is_consistent) diagnostic status for the history index." }
}
}
7 changes: 7 additions & 0 deletions soroban/contracts/invoice-payment/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,4 +146,11 @@ pub enum ContractError {
/// from `IssuerMigrationCursor` (issue #480). The contract must stay
/// paused until `version_info().storage_schema_version` reads current.
IssuerMigrationIncomplete = 23,

/// `get_payment()` was called for an `invoice_id` that was recorded on-chain
/// in the write log, but whose persistent payment record has expired and been
/// archived by the network due to TTL expiration. The record can be restored
/// using a Soroban RestoreFootprint operation before reading it again.
PaymentArchived = 24,
}

49 changes: 44 additions & 5 deletions soroban/contracts/invoice-payment/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ pub use errors::ContractError;
pub use storage::{
AllowlistEntry, AllowlistMode, AllowlistPage, Asset, ContractConfig, ContractMeta, DataKey,
PaymentHistoryPage, PaymentRecord, SettlementRefEntry, SettlementRefPage, CONTRACT_VERSION,
CONTRACT_VERSION_MAJOR, CONTRACT_VERSION_MINOR, CONTRACT_VERSION_PATCH, STORAGE_SCHEMA_VERSION,
CONTRACT_VERSION_MAJOR, CONTRACT_VERSION_MINOR, CONTRACT_VERSION_PATCH,
MAX_TTL_EXTEND_BATCH, STORAGE_SCHEMA_VERSION,
};

use events::{
Expand All @@ -22,9 +23,9 @@ use events::{
};
use storage::{
append_payment_history, append_payment_log, bump_count, bump_history_count,
clear_pending_admin, current_contract_meta, ensure_current_contract_meta, get_admin,
get_asset_decimals, get_contract_config, get_count, get_payment, get_payment_history_page,
get_pending_admin, get_pending_admin_opt, get_state_contract_version,
clear_pending_admin, current_contract_meta, ensure_current_contract_meta, extend_history_ttl_range,
get_admin, get_asset_decimals, get_contract_config, get_count, get_payment,
get_payment_history_page, get_pending_admin, get_pending_admin_opt, get_state_contract_version,
get_storage_schema_version, has_admin, has_payment, has_pending_admin, is_asset_allowed,
is_native_allowed, revoke_asset, set_admin, set_contract_meta, set_native_allowed, set_payment,
set_pending_admin,
Expand Down Expand Up @@ -115,6 +116,7 @@ use storage::{
/// | `config` | permissionless | yes | Aggregates several of the instance reads above |
/// | `is_paused` | permissionless | yes | Instance read |
/// | `history_index_status` | **admin-gated**| yes | Two instance counters, O(1) — volume summary |
/// | `extend_history_ttl` | **admin-gated**| yes | Bounded bulk TTL extension across persistent payment & history records |
///
/// "Extends TTL on a hit" means the call touches a read-write **footprint**
/// (Soroban tracks TTL on the entry's own key) but never changes the
Expand Down Expand Up @@ -467,7 +469,9 @@ impl InvoicePaymentContract {
/// Return the [`PaymentRecord`] for `invoice_id`.
///
/// Returns [`ContractError::InvalidInvoiceId`] if `invoice_id` is empty.
/// Returns [`ContractError::PaymentNotFound`] if nothing has been recorded.
/// Returns [`ContractError::PaymentArchived`] if the payment was recorded on-chain
/// in the write log, but its persistent payment record has expired due to TTL.
/// Returns [`ContractError::PaymentNotFound`] if nothing was ever recorded for this `invoice_id`.
/// Use [`has_payment`] first if existence is uncertain.
///
/// ## Legacy records
Expand Down Expand Up @@ -1125,6 +1129,7 @@ impl InvoicePaymentContract {
/// | `upgrade_storage` | yes | Storage migration runs between `upgrade()` and the final unpause |
/// | `rebuild_history_index` | yes | Administrative recovery; may run in the upgrade window or standalone |
/// | `migrate_legacy_payments`| yes | Administrative cleanup of legacy keys; may run standalone (issue #508) |
/// | `extend_history_ttl` | yes | Bulk TTL extension for retention maintenance; runs standalone or paused |
/// | `payment_count` | yes | Admin-gated bulk read (issue #512); auditing must work during containment |
/// | `payment_history` | yes | Admin-gated bulk read (issue #512); auditing must work during containment |
/// | `settlement_ref_history` | yes | Admin-gated bulk read (issue #512); auditing must work during containment |
Expand Down Expand Up @@ -1278,6 +1283,40 @@ impl InvoicePaymentContract {
crate::migration::migrate_legacy_payments(&env, &invoice_ids)
}

/// Extend persistent storage TTL for a bounded range of payment history records,
/// write logs, and settlement references.
///
/// - `cursor` — zero-based history slot to start from.
/// - `limit` — maximum slots to process (capped internally at [`storage::MAX_TTL_EXTEND_BATCH`]).
///
/// ## Authorization
/// **Admin-gated**: Only the contract admin can call this method.
///
/// ## Pause interaction
/// **Exempt** from the pause guard. As a maintenance/retention function it
/// may be invoked either inside the pause window or standalone during normal
/// operations.
///
/// ## Returns
/// `(extended_count, next_cursor, has_more)`
///
/// ## Errors
/// - [`ContractError::NotInitialized`] — contract was never initialised
/// - [`ContractError::Unauthorized`] — caller is not admin
pub fn extend_history_ttl(
env: Env,
admin: Address,
cursor: u32,
limit: u32,
) -> Result<(u32, u32, bool), ContractError> {
let current_admin = get_admin(&env)?;
if admin != current_admin {
return Err(ContractError::Unauthorized);
}
admin.require_auth();
Ok(extend_history_ttl_range(&env, cursor, limit))
}

/// Get the consistency status of the history index.
///
/// Returns a tuple (history_count, payment_count, is_consistent).
Expand Down
Loading
Loading