Skip to content

Commit c883f30

Browse files
authored
Streamed payment (#178)
1 parent c7db47d commit c883f30

File tree

5 files changed

+548
-0
lines changed

5 files changed

+548
-0
lines changed

src/lib.cairo

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ pub mod core;
22
pub mod owned_nft;
33
pub mod positions;
44
pub mod router;
5+
pub mod streamed_payment;
56

67
#[cfg(test)]
78
pub(crate) mod tests;

src/streamed_payment.cairo

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
use starknet::ContractAddress;
2+
3+
#[derive(Drop, Copy, Serde, starknet::Store)]
4+
pub struct PaymentStreamInfo {
5+
pub token_address: ContractAddress,
6+
pub owner: ContractAddress,
7+
pub recipient: ContractAddress,
8+
pub amount_total: u128,
9+
pub amount_paid: u128,
10+
pub start_time: u64,
11+
pub end_time: u64,
12+
}
13+
14+
#[starknet::interface]
15+
pub trait IStreamedPayment<TContractState> {
16+
// Creates a payment stream and returns the ID of the payment stream
17+
fn create_stream(
18+
ref self: TContractState,
19+
token_address: ContractAddress,
20+
amount: u128,
21+
recipient: ContractAddress,
22+
start_time: u64,
23+
end_time: u64,
24+
) -> u64;
25+
26+
// Returns info on an existing payment stream
27+
fn get_stream_info(self: @TContractState, id: u64) -> PaymentStreamInfo;
28+
29+
// Transfers ownership of a stream. Only callable by the current owner.
30+
fn transfer_stream_ownership(ref self: TContractState, id: u64, new_owner: ContractAddress);
31+
32+
// Changes the recipient of the stream. Only callable by the stream owner or the recipient.
33+
fn change_stream_recipient(ref self: TContractState, id: u64, new_recipient: ContractAddress);
34+
35+
// Cancels a payment stream that has not ended yet
36+
fn cancel(ref self: TContractState, id: u64) -> u128;
37+
38+
// Collects any pending amount for the given payment stream and returns the amount
39+
fn collect(ref self: TContractState, id: u64) -> u128;
40+
}
41+
42+
#[starknet::contract]
43+
pub mod StreamedPayment {
44+
use core::array::{Array, ArrayTrait};
45+
use core::num::traits::Zero;
46+
use starknet::storage::{
47+
Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePathEntry,
48+
StoragePointerReadAccess, StoragePointerWriteAccess,
49+
};
50+
use starknet::{get_block_timestamp, get_caller_address, get_contract_address};
51+
use crate::interfaces::erc20::{IERC20Dispatcher, IERC20DispatcherTrait};
52+
use super::{ContractAddress, IStreamedPayment, PaymentStreamInfo};
53+
54+
55+
#[derive(starknet::Event, Drop)]
56+
pub struct StreamCreated {
57+
pub id: u64,
58+
pub token_address: ContractAddress,
59+
pub owner: ContractAddress,
60+
pub recipient: ContractAddress,
61+
pub start_time: u64,
62+
pub end_time: u64,
63+
pub amount: u128,
64+
}
65+
66+
#[derive(starknet::Event, Drop)]
67+
pub struct PaymentCollected {
68+
pub id: u64,
69+
pub amount: u128,
70+
}
71+
72+
#[derive(starknet::Event, Drop)]
73+
pub struct StreamCanceled {
74+
pub id: u64,
75+
pub refund: u128,
76+
}
77+
78+
#[derive(starknet::Event, Drop)]
79+
#[event]
80+
enum Event {
81+
StreamCreated: StreamCreated,
82+
PaymentCollected: PaymentCollected,
83+
StreamCanceled: StreamCanceled,
84+
}
85+
86+
87+
#[storage]
88+
struct Storage {
89+
pub next_id: u64,
90+
pub streams: Map<u64, PaymentStreamInfo>,
91+
}
92+
93+
#[abi(embed_v0)]
94+
impl StreamedPaymentImpl of IStreamedPayment<ContractState> {
95+
fn create_stream(
96+
ref self: ContractState,
97+
token_address: ContractAddress,
98+
amount: u128,
99+
recipient: ContractAddress,
100+
start_time: u64,
101+
end_time: u64,
102+
) -> u64 {
103+
assert(end_time > start_time, 'End time < start time');
104+
105+
let owner = get_caller_address();
106+
107+
let id = self.next_id.read();
108+
self.next_id.write(id + 1);
109+
110+
self
111+
.streams
112+
.write(
113+
id,
114+
PaymentStreamInfo {
115+
token_address: token_address,
116+
owner: owner,
117+
recipient: recipient,
118+
amount_total: amount,
119+
amount_paid: 0,
120+
start_time: start_time,
121+
end_time: end_time,
122+
},
123+
);
124+
125+
assert(
126+
IERC20Dispatcher { contract_address: token_address }
127+
.transferFrom(owner, get_contract_address(), amount.into()),
128+
'transferFrom failed',
129+
);
130+
131+
self
132+
.emit(
133+
StreamCreated {
134+
id, token_address, owner, recipient, start_time, end_time, amount,
135+
},
136+
);
137+
138+
return id;
139+
}
140+
141+
fn get_stream_info(self: @ContractState, id: u64) -> PaymentStreamInfo {
142+
self.streams.entry(id).read()
143+
}
144+
145+
146+
fn transfer_stream_ownership(ref self: ContractState, id: u64, new_owner: ContractAddress) {
147+
let mut stream = self.streams.read(id);
148+
149+
assert(stream.owner == get_caller_address(), 'Only owner can transfer');
150+
151+
stream.owner = new_owner;
152+
153+
self.streams.write(id, stream);
154+
}
155+
156+
fn change_stream_recipient(
157+
ref self: ContractState, id: u64, new_recipient: ContractAddress,
158+
) {
159+
let mut stream = self.streams.read(id);
160+
161+
let caller = get_caller_address();
162+
assert(
163+
stream.owner == caller || stream.recipient == caller,
164+
'Only owner/recipient can change',
165+
);
166+
167+
stream.recipient = new_recipient;
168+
169+
self.streams.write(id, stream);
170+
}
171+
172+
// Collects any pending amount for the given payment stream
173+
fn collect(ref self: ContractState, id: u64) -> u128 {
174+
let stream_entry = self.streams.entry(id);
175+
let mut stream = stream_entry.read();
176+
177+
let now = get_block_timestamp();
178+
let payment: u128 = if now < stream.start_time {
179+
0
180+
} else if now < stream.end_time {
181+
let amount_owed: u256 = stream.amount_total.into()
182+
* (now - stream.start_time).into()
183+
/ (stream.end_time - stream.start_time).into();
184+
185+
(amount_owed - stream.amount_paid.into()).try_into().unwrap()
186+
} else {
187+
stream.amount_total - stream.amount_paid
188+
};
189+
190+
if (payment.is_non_zero()) {
191+
stream.amount_paid += payment;
192+
193+
stream_entry.write(stream);
194+
195+
IERC20Dispatcher { contract_address: stream.token_address }
196+
.transfer(stream.recipient, payment.into());
197+
198+
self.emit(PaymentCollected { id, amount: payment });
199+
}
200+
201+
payment
202+
}
203+
204+
// Cancels a payment stream that has not ended yet
205+
fn cancel(ref self: ContractState, id: u64) -> u128 {
206+
// first we force a collect so you cannot refund unclaimed amounts
207+
self.collect(id);
208+
209+
let stream_entry = self.streams.entry(id);
210+
let mut stream = stream_entry.read();
211+
212+
assert(stream.owner == get_caller_address(), 'Only owner can cancel');
213+
214+
let refund = stream.amount_total - stream.amount_paid;
215+
216+
if (refund.is_non_zero()) {
217+
// the total amount is now just the amount paid
218+
stream.amount_total = stream.amount_paid;
219+
// refund is only non zero iff block timestamp < end_time
220+
stream.end_time = get_block_timestamp();
221+
stream_entry.write(stream);
222+
223+
assert(
224+
IERC20Dispatcher { contract_address: stream.token_address }
225+
.transfer(stream.owner, refund.into()),
226+
'transfer failed',
227+
);
228+
229+
self.emit(StreamCanceled { id, refund });
230+
}
231+
232+
refund
233+
}
234+
}
235+
}

src/tests.cairo

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub(crate) mod owned_nft_test;
88
pub(crate) mod positions_test;
99
pub(crate) mod router_test;
1010
pub(crate) mod store_packing_test;
11+
pub(crate) mod streamed_payment_test;
1112
pub(crate) mod token_registry_test;
1213
pub(crate) mod twamm_test;
1314
pub(crate) mod upgradeable_test;

src/tests/helper.cairo

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use ekubo::lens::token_registry::{ITokenRegistryDispatcher, TokenRegistry};
1818
use ekubo::owned_nft::{IOwnedNFTDispatcher, OwnedNFT};
1919
use ekubo::positions::Positions;
2020
use ekubo::router::{IRouterDispatcher, Router};
21+
use ekubo::streamed_payment::{IStreamedPaymentDispatcher, StreamedPayment};
2122
use ekubo::tests::mock_erc20::{IMockERC20Dispatcher, MockERC20, MockERC20IERC20ImplTrait};
2223
use ekubo::tests::mocks::locker::{
2324
Action, ActionResult, CoreLocker, ICoreLockerDispatcher, ICoreLockerDispatcherTrait,
@@ -251,6 +252,18 @@ pub impl DeployerTraitImpl of DeployerTrait {
251252
ITokenRegistryDispatcher { contract_address: address }
252253
}
253254

255+
fn deploy_streamed_payment(ref self: Deployer) -> IStreamedPaymentDispatcher {
256+
let (address, _) = deploy_syscall(
257+
StreamedPayment::TEST_CLASS_HASH.try_into().unwrap(),
258+
self.get_next_nonce(),
259+
array![].span(),
260+
true,
261+
)
262+
.expect('streamed payment deploy');
263+
264+
IStreamedPaymentDispatcher { contract_address: address }
265+
}
266+
254267

255268
fn setup_pool(
256269
ref self: Deployer,

0 commit comments

Comments
 (0)