Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -39,6 +39,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `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 `12`); 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 @@ -101,6 +101,7 @@ The flagship contract locks tokens up front and releases them linearly.
| `create_stream(sender, recipient, token, amount, start, end, cliff)` | Lock tokens and open a stream. Returns the stream id. |
| `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
51 changes: 51 additions & 0 deletions contracts/stream/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ pub enum StreamError {
AlreadyInitialized = 9,
TreasuryNotSet = 10,
MetadataTooLong = 11,
SameRecipient = 12,
}

/// A single linear payment stream. Tokens are locked in the contract and vest
Expand Down Expand Up @@ -110,6 +111,16 @@ pub struct StreamCancelled {
pub refunded: i128,
}

/// 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 @@ -307,6 +318,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.
pub fn claimable(env: Env, stream_id: u64) -> Result<i128, StreamError> {
let stream: Stream = env
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 @@ -485,3 +485,154 @@ fn test_metadata_length_limits() {
// The rejections happened before any transfer.
assert_eq!(token.balance(&sender), 1000);
}

#[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