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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `escrow` contract id allocation: `create_escrow` now takes no `id`, assigns one from a `NextId`
counter in instance storage and returns it, and a new `next_escrow_id()` view reports the id the
next creation will receive. Every creation TTL-bumps the instance entry holding the counter.
- `transfer_recipient` on the `stream` contract, letting the current recipient hand an active stream
to a new address (key rotation, sale). Emits a `("stream", "transferred")` event and adds the
`SameRecipient` error (code `20`); exposed in the SDK as `StreamsClient.transferRecipient`.
- Flagship modular Counter smart contract containing `initialize(owner)`, `increment()`, `decrement()`, `reset()`, `get_count()`, and `get_owner()` across dedicated files (`lib.rs`, `storage.rs`, `event.rs`, `error.rs`).
- Robust unit tests in `contracts/counter/src/test.rs` verifying contract errors, bounds limits, state manipulation, and event audits.
- CLI automation command scripts `scripts/invoke-counter.ps1` and `scripts/invoke-counter.sh` to initialize, query, and mutate contract states on Testnet.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ The flagship contract locks tokens up front and releases them linearly.
| `create_streams(sender, token, entries)` | Lock a whole batch atomically. Returns the new ids, in input order. |
| `withdraw(id)` | Recipient claims the vested, unclaimed balance. |
| `cancel(id)` | Sender cancels; unvested funds are refunded, vested funds stay claimable. |
| `transfer_recipient(id, caller, new_recipient)` | Recipient hands the unwithdrawn balance off to a new address. |
| `claimable(id)` / `vested(id)` | View helpers. |
| `get_stream(id)` / `get_config()` / `next_stream_id()` | Read helpers. |

Expand Down
52 changes: 52 additions & 0 deletions contracts/stream/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ pub enum StreamError {
StreamCompleted = 18,
/// Issue #181: `top_up` arithmetic would exceed the supported range.
AmountOverflow = 19,
/// `transfer_recipient` was asked to hand a stream to its current recipient.
SameRecipient = 20,
}

/// A single linear payment stream. Tokens are locked in the contract and vest
Expand Down Expand Up @@ -188,6 +190,16 @@ pub struct StreamResumed {
pub total_paused: u64,
}

/// Emitted when the recipient hands a stream off to a new address.
#[contractevent(topics = ["stream", "transferred"])]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StreamRecipientTransferred {
#[topic]
pub id: u64,
pub old_recipient: Address,
pub new_recipient: Address,
}

#[contract]
pub struct StreamContract;

Expand Down Expand Up @@ -551,6 +563,46 @@ impl StreamContract {
Ok(())
}

/// Hand a stream off to `new_recipient`, e.g. after a key rotation or a sale.
///
/// Only the current recipient may call this: `caller` must authorize and
/// match the stored recipient, otherwise the call fails with `Unauthorized`.
/// Amounts already withdrawn stay where they are; everything not yet
/// withdrawn — including a balance that has vested but was never claimed —
/// becomes withdrawable by `new_recipient` only. A recipient who wants to
/// keep the vested part should `withdraw` before transferring.
pub fn transfer_recipient(
env: Env,
stream_id: u64,
caller: Address,
new_recipient: Address,
) -> Result<(), StreamError> {
let key = DataKey::Stream(stream_id);
let mut stream: Stream =
env.storage().persistent().get(&key).ok_or(StreamError::StreamNotFound)?;

caller.require_auth();
if caller != stream.recipient {
return Err(StreamError::Unauthorized);
}
if new_recipient == stream.recipient {
return Err(StreamError::SameRecipient);
}

let old_recipient = core::mem::replace(&mut stream.recipient, new_recipient);
env.storage().persistent().set(&key, &stream);
env.storage().persistent().extend_ttl(&key, LEDGER_BUMP_THRESHOLD, LEDGER_BUMP_LIMIT);

StreamRecipientTransferred {
id: stream_id,
old_recipient,
new_recipient: stream.recipient.clone(),
}
.publish(&env);

Ok(())
}

/// View the amount currently available for the recipient to withdraw.
/// Issue #182: pause accrual for `stream_id`. Payer-only. Idempotent.
pub fn pause(env: Env, stream_id: u64) -> Result<(), StreamError> {
Expand Down
151 changes: 151 additions & 0 deletions contracts/stream/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1624,3 +1624,154 @@ fn test_claimable_vested_view_costs() {
let diff = cpu_claimable.abs_diff(cpu_vested);
assert!(diff < 10_000, "claimable/vested cpu diverged: {}", diff);
}

#[test]
fn test_transfer_recipient_mid_stream() {
let env = Env::default();
env.mock_all_auths();
let (token, sender, recipient, client) = setup(&env);
token.mint(&sender, 1000);
let new_recipient = account(&env);

let id = create(&env, &client, &sender, &recipient, &token.id, 1000, 0, 1000, 0);

// The original recipient claims what has vested so far, then hands off.
advance_time(&env, 300);
assert_eq!(client.withdraw(&id), 300);
client.transfer_recipient(&id, &recipient, &new_recipient);

// Checked first: the event log only holds the most recent invocation's events.
assert_eq!(
env.events().all().filter_by_contract(&client.address),
[StreamRecipientTransferred {
id,
old_recipient: recipient.clone(),
new_recipient: new_recipient.clone(),
}
.to_xdr(&env, &client.address)],
);

// Only the recipient changes; the withdrawn amount and schedule are kept.
let stream = client.get_stream(&id).unwrap();
assert_eq!(stream.recipient, new_recipient);
assert_eq!(stream.sender, sender);
assert_eq!(stream.withdrawn, 300);
assert_eq!(stream.total_amount, 1000);
assert_eq!((stream.start_time, stream.end_time, stream.cliff_time), (0, 1000, 0));

// Future withdrawals accrue to the new recipient.
advance_time(&env, 200);
assert_eq!(client.claimable(&id), 200);
assert_eq!(client.withdraw(&id), 200);
assert_eq!(token.balance(&new_recipient), 200);
assert_eq!(token.balance(&recipient), 300);

advance_time(&env, 500);
assert_eq!(client.withdraw(&id), 500);
assert_eq!(token.balance(&new_recipient), 700);
assert_eq!(token.balance(&recipient), 300);
assert_eq!(token.balance(&client.address), 0);
}

#[test]
fn test_transfer_recipient_moves_unclaimed_vested_balance() {
let env = Env::default();
env.mock_all_auths();
let (token, sender, recipient, client) = setup(&env);
token.mint(&sender, 1000);
let new_recipient = account(&env);

let id = create(&env, &client, &sender, &recipient, &token.id, 1000, 0, 1000, 0);

// Vested but never withdrawn: it is part of the remaining balance and moves.
advance_time(&env, 400);
client.transfer_recipient(&id, &recipient, &new_recipient);
assert_eq!(client.withdraw(&id), 400);
assert_eq!(token.balance(&new_recipient), 400);
assert_eq!(token.balance(&recipient), 0);
}

#[test]
fn test_transfer_recipient_after_cancel_moves_vested_remainder() {
let env = Env::default();
env.mock_all_auths();
let (token, sender, recipient, client) = setup(&env);
token.mint(&sender, 1000);
let new_recipient = account(&env);

let id = create(&env, &client, &sender, &recipient, &token.id, 1000, 0, 1000, 0);
advance_time(&env, 250);
client.cancel(&id);

client.transfer_recipient(&id, &recipient, &new_recipient);
assert_eq!(client.withdraw(&id), 250);
assert_eq!(token.balance(&new_recipient), 250);
assert_eq!(token.balance(&sender), 750);
}

#[test]
fn test_transfer_recipient_rejects_non_recipient() {
let env = Env::default();
env.mock_all_auths();
let (token, sender, recipient, client) = setup(&env);
token.mint(&sender, 1000);
let outsider = account(&env);

let id = create(&env, &client, &sender, &recipient, &token.id, 1000, 0, 1000, 0);

// Neither a third party nor the stream's own sender may redirect it.
assert_eq!(
client.try_transfer_recipient(&id, &outsider, &outsider),
Err(Ok(StreamError::Unauthorized)),
);
assert_eq!(
client.try_transfer_recipient(&id, &sender, &outsider),
Err(Ok(StreamError::Unauthorized)),
);
assert_eq!(client.get_stream(&id).unwrap().recipient, recipient);
}

#[test]
fn test_transfer_recipient_requires_recipient_auth() {
let env = Env::default();
env.mock_all_auths();
let (token, sender, recipient, client) = setup(&env);
token.mint(&sender, 1000);
let new_recipient = account(&env);

let id = create(&env, &client, &sender, &recipient, &token.id, 1000, 0, 1000, 0);

// Naming the recipient as `caller` is not enough without their signature.
env.mock_auths(&[]);
assert!(client.try_transfer_recipient(&id, &recipient, &new_recipient).is_err());

env.mock_all_auths();
assert_eq!(client.get_stream(&id).unwrap().recipient, recipient);
}

#[test]
fn test_transfer_recipient_to_same_address_fails() {
let env = Env::default();
env.mock_all_auths();
let (token, sender, recipient, client) = setup(&env);
token.mint(&sender, 1000);

let id = create(&env, &client, &sender, &recipient, &token.id, 1000, 0, 1000, 0);
assert_eq!(
client.try_transfer_recipient(&id, &recipient, &recipient),
Err(Ok(StreamError::SameRecipient)),
);
}

#[test]
fn test_transfer_recipient_unknown_stream() {
let env = Env::default();
env.mock_all_auths();
let client = register(&env);
let caller = account(&env);
let new_recipient = account(&env);
assert_eq!(
client.try_transfer_recipient(&99, &caller, &new_recipient),
Err(Ok(StreamError::StreamNotFound)),
);
}
Loading
Loading