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
98 changes: 86 additions & 12 deletions docs/issuer-transfer-expiry.md
Original file line number Diff line number Diff line change
@@ -1,26 +1,100 @@
# Issuer Transfer Expiry

The Revora contract implements a 24-hour expiry for issuer transfer proposals to ensure system security and prevent stale transfers from being executed.
Issuer transfer proposals have a configurable expiry window. The default is **7 days**
(604,800 seconds). Issuers can override this per-proposal within the bounds
`[1 hour, 30 days]`.

## Mechanics
## Constants

1. **Proposal Timestamp**: When an issuer proposes a transfer via `propose_issuer_transfer`, the current ledger timestamp is recorded.
2. **Expiry Window**: Proposals are valid for exactly **24 hours** (86,400 seconds).
3. **Enforcement**: The `accept_issuer_transfer` function checks the elapsed time. If more than 24 hours have passed since the proposal, the transaction fails with `IssuerTransferExpired` (Error code 30).
4. **Automatic Overwrite**: If a transfer has expired, the current issuer can simply call `propose_issuer_transfer` again to start a new 24-hour window, overwriting the expired proposal.
5. **Manual Cleanup**: Anyone can call `cleanup_expired_transfer` to remove an expired proposal from storage, which is useful for storage hygiene.
| Constant | Value | Description |
|---|---|---|
| `ISSUER_TRANSFER_EXPIRY_SECS` | 604,800 s (7 days) | Default expiry when none is specified |
| `MIN_ISSUER_TRANSFER_EXPIRY_SECS` | 3,600 s (1 hour) | Minimum allowed custom expiry |
| `MAX_ISSUER_TRANSFER_EXPIRY_SECS` | 2,592,000 s (30 days) | Maximum allowed custom expiry |

## Proposing a Transfer

### Default expiry (7 days)

```
propose_issuer_transfer(issuer, namespace, token, new_issuer)
```

### Custom expiry

```
propose_transfer_with_expiry(issuer, namespace, token, new_issuer, expiry_secs)
```

`expiry_secs` is clamped to `[MIN_ISSUER_TRANSFER_EXPIRY_SECS, MAX_ISSUER_TRANSFER_EXPIRY_SECS]`
before being stored. Passing `0` is treated as "use default" and stores `0` in
`PendingTransfer.expiry_secs`; `accept_issuer_transfer` then applies the 7-day default.

## Accepting a Transfer

`accept_issuer_transfer` reads the stored `expiry_secs` from `PendingTransfer`:

- If `expiry_secs == 0` → effective expiry is `ISSUER_TRANSFER_EXPIRY_SECS` (7 days).
- Otherwise → effective expiry is the stored value.

The check is **inclusive on the boundary**:

```
now <= proposal_timestamp + effective_expiry → accepted
now > proposal_timestamp + effective_expiry → IssuerTransferExpired
```

## Replacing a Pending Transfer

`replace_issuer_transfer` atomically cancels the current pending transfer and proposes
a new one to a different `new_issuer`. The **original `expiry_secs` is preserved** so
the replacement inherits the same window as the original proposal.

## Querying Pending Transfer Details

`get_pending_transfer_details(issuer, namespace, token)` returns
`Option<PendingTransfer>` with:

| Field | Type | Description |
|---|---|---|
| `new_issuer` | `Address` | Proposed new issuer |
| `timestamp` | `u64` | Ledger timestamp when the proposal was created |
| `expiry_secs` | `u64` | Stored expiry (0 = default 7 days) |

Use this to display the remaining acceptance window in UIs or off-chain tooling.

## Security Rationale

* **Key Compromise Protection**: If an issuer proposes a transfer and then their keys (or the new issuer's keys) are compromised weeks later, the attacker cannot use the old, forgotten proposal to hijack the offering.
* **Operational Clarity**: Expiry forces both parties to coordinate and complete the transfer in a timely manner, reducing "pending state" ambiguity.
- **Key compromise protection**: A stale proposal cannot be used to hijack an offering
after the expiry window closes.
- **Bounded window**: The `[1h, 30d]` clamp prevents both trivially short windows
(race conditions) and indefinitely long windows (forgotten proposals).
- **Replace preserves expiry**: Replacing a pending transfer does not silently reset
the expiry to the default, preventing a governance bypass where an attacker replaces
a short-window proposal with a default-window one.

## Error Codes

| Code | Name | Description |
|---|---|---|
| 30 | `IssuerTransferExpired` | The transfer proposal has passed the 24-hour validity window. |
| 12 | `IssuerTransferPending` | A transfer is already pending; cancel or replace it first. |
| 13 | `NoTransferPending` | No pending transfer to accept or cancel. |
| 14 | `UnauthorizedTransferAccept` | Caller is not the proposed new issuer. |
| 43 | `IssuerTransferExpired` | The proposal has passed its expiry window. |

## Developer Guidance
## Test Coverage

Developers should ensure that the `accept_issuer_transfer` call is made shortly after the proposal is confirmed on-chain. If the window is missed, the process must be restarted by the current issuer.
| Test | What it verifies |
|---|---|
| `issuer_transfer_default_expiry_used_when_expiry_secs_zero` | Default 7-day window accepted just before expiry |
| `issuer_transfer_default_expiry_rejects_after_seven_days` | Default window rejects after 7 days |
| `issuer_transfer_custom_expiry_accepted_within_window` | Custom 2h window accepts at 1h |
| `issuer_transfer_custom_expiry_rejected_after_window` | Custom 2h window rejects at 2h+1s |
| `issuer_transfer_custom_expiry_accepted_at_exact_boundary` | Inclusive boundary: accepts at exactly 2h |
| `issuer_transfer_expiry_below_min_clamped_to_min` | Below-min input clamped to 1h |
| `issuer_transfer_min_clamp_accept_at_exact_one_hour_boundary` | Min-clamped expiry accepts at exactly 1h |
| `issuer_transfer_expiry_above_max_clamped_to_max` | Above-max input clamped to 30 days |
| `issuer_transfer_max_clamp_accept_within_thirty_day_window` | Max-clamped expiry accepts within 30 days |
| `replace_issuer_transfer_preserves_custom_expiry` | Replace preserves original custom expiry |
| `get_pending_issuer_transfer_details_returns_expiry` | Details query returns correct expiry_secs |
| `get_pending_issuer_transfer_details_returns_none_when_no_pending` | Details query returns None when no pending |
97 changes: 79 additions & 18 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,8 +326,12 @@ const EVENT_FREEZE_OFFERING: Symbol = symbol_short!("frz_off");
const EVENT_UNFREEZE_OFFERING: Symbol = symbol_short!("ufrz_off");
const EVENT_PROPOSAL_CREATED: Symbol = symbol_short!("prop_new");
const EVENT_FREEZE: Symbol = symbol_short!("freeze");
/// Issuer transfer expiry: 7 days in seconds.
/// Issuer transfer expiry: 7 days in seconds (default).
const ISSUER_TRANSFER_EXPIRY_SECS: u64 = 7 * 24 * 60 * 60;
/// Minimum configurable issuer transfer expiry: 1 hour.
const MIN_ISSUER_TRANSFER_EXPIRY_SECS: u64 = 60 * 60;
/// Maximum configurable issuer transfer expiry: 30 days.
const MAX_ISSUER_TRANSFER_EXPIRY_SECS: u64 = 30 * 24 * 60 * 60;
const EVENT_CLAIM: Symbol = symbol_short!("claim");
const EVENT_CLAIM_DELAY_SET: Symbol = symbol_short!("dly_set");
// v1 versioned event symbols (legacy)
Expand Down Expand Up @@ -438,6 +442,8 @@ pub struct DistributionEntry {
pub struct PendingTransfer {
pub new_issuer: Address,
pub timestamp: u64,
/// Effective expiry in seconds. 0 means use ISSUER_TRANSFER_EXPIRY_SECS default.
pub expiry_secs: u64,
}

/// Cross-offering aggregated metrics (#39).
Expand Down Expand Up @@ -684,6 +690,7 @@ pub enum DataKey {
OfferingFeeBps(OfferingId, Address),
/// Platform level per-asset fee (#98).
PlatformFeePerAsset(Address),

}

/// Secondary storage keys for auxiliary/extended contract state.
Expand Down Expand Up @@ -721,16 +728,6 @@ pub enum DataKey2 {
/// Direct offering index: (issuer, namespace, token) -> Offering for O(1) get_offering (#360).
OfferingRecord(OfferingId),

/// Metadata reference for an offering.
OfferingMetadata(OfferingId),
/// Per-offering minimum revenue threshold (#25).
MinRevenueThreshold(OfferingId),
/// Total deposited revenue for an offering (#39).
DepositedRevenue(OfferingId),
/// Per-offering supply cap (#96). 0 = no cap.
SupplyCap(OfferingId),
/// Per-offering investment constraints (#97).
InvestmentConstraints(OfferingId),
/// Per-offering blacklist size limit (#358). If not set, defaults to MAX_BLACKLIST_SIZE.
BlacklistSizeLimit(OfferingId),
}
Expand Down Expand Up @@ -1667,6 +1664,20 @@ impl RevoraRevenueShare {
.map(|pending| pending.new_issuer)
}

/// Return full details of a pending issuer transfer, including the proposed new issuer,
/// the proposal timestamp, and the effective expiry in seconds (0 = default 7 days).
pub fn get_pending_transfer_details(
env: Env,
issuer: Address,
namespace: Symbol,
token: Address,
) -> Option<PendingTransfer> {
let offering_id = OfferingId { issuer, namespace, token };
env.storage()
.persistent()
.get::<DataKey, PendingTransfer>(&DataKey::PendingIssuerTransfer(offering_id))
}

fn find_pending_transfer_for_new_issuer(
env: &Env,
namespace: &Symbol,
Expand Down Expand Up @@ -1713,6 +1724,33 @@ impl RevoraRevenueShare {
namespace: Symbol,
token: Address,
new_issuer: Address,
) -> Result<(), RevoraError> {
Self::do_propose_issuer_transfer(env, issuer, namespace, token, new_issuer, 0)
}

/// Propose an issuer transfer with a custom expiry window.
///
/// `expiry_secs` is clamped to `[MIN_ISSUER_TRANSFER_EXPIRY_SECS, MAX_ISSUER_TRANSFER_EXPIRY_SECS]`.
/// Pass `0` to use the default `ISSUER_TRANSFER_EXPIRY_SECS` (7 days).
#[allow(clippy::too_many_arguments)]
pub fn propose_transfer_with_expiry(
env: Env,
issuer: Address,
namespace: Symbol,
token: Address,
new_issuer: Address,
expiry_secs: u64,
) -> Result<(), RevoraError> {
Self::do_propose_issuer_transfer(env, issuer, namespace, token, new_issuer, expiry_secs)
}

fn do_propose_issuer_transfer(
env: Env,
issuer: Address,
namespace: Symbol,
token: Address,
new_issuer: Address,
expiry_secs: u64,
) -> Result<(), RevoraError> {
Self::require_not_frozen(&env)?;
Self::require_not_paused(&env)?;
Expand All @@ -1735,10 +1773,22 @@ impl RevoraRevenueShare {
return Err(RevoraError::IssuerTransferPending);
}

// Clamp expiry: 0 means default; non-zero is clamped to [MIN, MAX].
let effective_expiry = if expiry_secs == 0 {
0
} else {
expiry_secs.max(MIN_ISSUER_TRANSFER_EXPIRY_SECS).min(MAX_ISSUER_TRANSFER_EXPIRY_SECS)
};

let timestamp = env.ledger().timestamp();
env.storage()
.persistent()
.set(&key, &PendingTransfer { new_issuer: new_issuer.clone(), timestamp });
env.storage().persistent().set(
&key,
&PendingTransfer {
new_issuer: new_issuer.clone(),
timestamp,
expiry_secs: effective_expiry,
},
);
env.events().publish(
(EVENT_ISSUER_TRANSFER_PROPOSED, issuer.clone(), namespace.clone(), token.clone()),
(new_issuer.clone(), timestamp),
Expand Down Expand Up @@ -1776,9 +1826,15 @@ impl RevoraRevenueShare {

let pending: PendingTransfer = env.storage().persistent().get(&key).unwrap();
let timestamp = env.ledger().timestamp();
env.storage()
.persistent()
.set(&key, &PendingTransfer { new_issuer: new_issuer.clone(), timestamp });
// Preserve the original expiry_secs so the replacement inherits the same window.
env.storage().persistent().set(
&key,
&PendingTransfer {
new_issuer: new_issuer.clone(),
timestamp,
expiry_secs: pending.expiry_secs,
},
);

env.events().publish(
(EVENT_ISSUER_TRANSFER_CANCELLED, issuer.clone(), namespace.clone(), token.clone()),
Expand Down Expand Up @@ -1812,7 +1868,12 @@ impl RevoraRevenueShare {
.ok_or(RevoraError::NoTransferPending)?;

let current_timestamp = env.ledger().timestamp();
if current_timestamp > pending.timestamp.saturating_add(ISSUER_TRANSFER_EXPIRY_SECS) {
let effective_expiry = if pending.expiry_secs == 0 {
ISSUER_TRANSFER_EXPIRY_SECS
} else {
pending.expiry_secs
};
if current_timestamp > pending.timestamp.saturating_add(effective_expiry) {
return Err(RevoraError::IssuerTransferExpired);
}

Expand Down
Loading
Loading