Skip to main content

DonationStreamer

The DonationStreamer contract enables permissionless, scheduled donations to FXSwap (Twocrypto-NG) pools. Donors deposit tokens and ETH rewards upfront to create "streams" that distribute donations over multiple periods. Anyone can execute due streams and earn ETH bounties for doing so.

This contract is fully permissionless — there is no admin or owner. Once a stream is created, it can only be cancelled by the original donor.

A frontend for creating, viewing, and executing streams is available at curvefi.github.io/refuel-automation.

For background on how pool-level donations work, see the Refuel Mechanism and FXSwap Implementation pages.

DonationStreamer.vy

The source code for the DonationStreamer.vy contract can be found on GitHub. The contract is written in Vyper version 0.4.3.

The contract is deployed at the same address on all chains (via CREATE3):

{ }Contract ABI
[{"anonymous":false,"inputs":[{"indexed":false,"name":"stream_id","type":"uint256"},{"indexed":true,"name":"donor","type":"address"},{"indexed":true,"name":"pool","type":"address"},{"indexed":false,"name":"amounts","type":"uint256[2]"},{"indexed":false,"name":"period_length","type":"uint256"},{"indexed":false,"name":"n_periods","type":"uint256"},{"indexed":false,"name":"reward_per_period","type":"uint256"}],"name":"StreamCreated","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"name":"stream_id","type":"uint256"},{"indexed":true,"name":"caller","type":"address"},{"indexed":true,"name":"pool","type":"address"},{"indexed":false,"name":"periods","type":"uint256"},{"indexed":false,"name":"amounts","type":"uint256[2]"},{"indexed":false,"name":"reward_paid","type":"uint256"}],"name":"StreamExecuted","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"name":"stream_id","type":"uint256"},{"indexed":true,"name":"donor","type":"address"},{"indexed":true,"name":"pool","type":"address"},{"indexed":false,"name":"amounts","type":"uint256[2]"},{"indexed":false,"name":"reward_refund","type":"uint256"}],"name":"StreamCancelled","type":"event"},{"inputs":[{"name":"pool","type":"address"},{"name":"coins","type":"address[2]"},{"name":"amounts","type":"uint256[2]"},{"name":"period_length","type":"uint256"},{"name":"n_periods","type":"uint256"},{"name":"reward_per_period","type":"uint256"}],"name":"create_stream","outputs":[{"name":"","type":"uint256"}],"stateMutability":"payable","type":"function"},{"inputs":[{"name":"stream_id","type":"uint256"}],"name":"cancel_stream","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"name":"stream_id","type":"uint256"}],"name":"execute","outputs":[{"name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"name":"stream_ids","type":"uint256[]"}],"name":"execute_many","outputs":[{"name":"","type":"bool[]"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"name":"stream_id","type":"uint256"}],"name":"is_due","outputs":[{"name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"streams_and_rewards_due","outputs":[{"name":"","type":"uint256[]"},{"name":"","type":"uint256[]"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"stream_count","outputs":[{"name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"name":"arg0","type":"uint256"}],"name":"streams","outputs":[{"components":[{"name":"donor","type":"address"},{"name":"pool","type":"address"},{"name":"coins","type":"address[2]"},{"name":"amounts_per_period","type":"uint256[2]"},{"name":"period_length","type":"uint256"},{"name":"reward_per_period","type":"uint256"},{"name":"next_ts","type":"uint256"},{"name":"reward_remaining","type":"uint256"},{"name":"amounts_remaining","type":"uint256[2]"},{"name":"periods_remaining","type":"uint256"}],"name":"","type":"tuple"}],"stateMutability":"view","type":"function"},{"inputs":[],"outputs":[],"stateMutability":"nonpayable","type":"constructor"}]

Stream Management

create_stream

DonationStreamer.create_stream(pool: address, coins: address[2], amounts: uint256[2], period_length: uint256, n_periods: uint256, reward_per_period: uint256) -> uint256

Payable function to create a new donation stream for a pool. The caller deposits the full token amounts and ETH rewards upfront. Tokens are transferred into the contract and donated to the pool in equal portions over n_periods. The coins parameter must match the pool's coin configuration. Any excess ETH beyond the required reward_per_period * n_periods is refunded.

InputTypeDescription
pooladdressAddress of the target FXSwap pool
coinsaddress[2]Token addresses matching the pool's coins(0) and coins(1)
amountsuint256[2]Total token amounts to donate across all periods
period_lengthuint256Duration of each period in seconds
n_periodsuint256Number of donation periods
reward_per_perioduint256ETH reward paid to the executor for each period

Returns: the stream ID (uint256).

Emits: StreamCreated event.

<>Source code
@external
@payable
@nonreentrant
def create_stream(
pool: address,
coins: address[N_COINS],
amounts: uint256[N_COINS],
period_length: uint256,
n_periods: uint256,
reward_per_period: uint256,
) -> uint256:
"""
@notice Create a donation stream for a pool.
"""
assert pool != empty(address), "pool required"
assert n_periods > 0, "bad n_periods"
assert period_length > 0, "bad period_length"
assert amounts[0] > 0 or amounts[1] > 0, "zero amounts"

# Ensure caller-provided coins match the pool configuration.
assert (
coins[0] == staticcall DonationPoolTarget(pool).coins(0)
and coins[1] == staticcall DonationPoolTarget(pool).coins(1)
), "coin mismatch"

# Rewards are pre-funded for all periods; excess is refunded.
reward_total: uint256 = reward_per_period * n_periods
assert msg.value >= reward_total, "reward mismatch"

# Per-period amounts are truncated; remainders donate on the final period.
amounts_per_period: uint256[N_COINS] = empty(uint256[N_COINS])
for i: uint256 in range(N_COINS):
amount: uint256 = amounts[i]
if amount > 0:
balance_before: uint256 = staticcall IERC20(coins[i]).balanceOf(self)
assert extcall IERC20(coins[i]).transferFrom(
msg.sender, self, amount, default_return_value=True
), "transfer failed"
balance_after: uint256 = staticcall IERC20(coins[i]).balanceOf(self)
assert balance_after - balance_before == amount, "bad token transfer"
amounts_per_period[i] = amount // n_periods

stream_id: uint256 = self.stream_count
self.stream_count = stream_id + 1

self.streams[stream_id] = DonationStream(
donor=msg.sender,
pool=pool,
coins=coins,
amounts_per_period=amounts_per_period,
period_length=period_length,
reward_per_period=reward_per_period,
next_ts=block.timestamp,
reward_remaining=reward_total,
amounts_remaining=amounts,
periods_remaining=n_periods,
)

# Refund excess message value.
if msg.value > reward_total:
send(msg.sender, msg.value - reward_total)

log StreamCreated(
stream_id=stream_id,
donor=msg.sender,
pool=pool,
amounts=amounts,
period_length=period_length,
n_periods=n_periods,
reward_per_period=reward_per_period,
)

return stream_id
Example
>>> DonationStreamer.create_stream(
... pool, # FXSwap pool address
... [coin0, coin1], # pool coin addresses
... [1000e18, 500e18], # total amounts to donate
... 86400, # period length: 1 day
... 7, # number of periods
... 0.001e18, # 0.001 ETH reward per period
... value=0.007e18 # total ETH rewards
... )
0 # stream_id

cancel_stream

DonationStreamer.cancel_stream(stream_id: uint256)
Guarded Method

This function can only be called by the original donor who created the stream.

Cancels a stream and refunds all remaining tokens and ETH rewards to the donor. The stream storage is cleared after cancellation.

InputTypeDescription
stream_iduint256ID of the stream to cancel

Emits: StreamCancelled event.

<>Source code
@external
@nonreentrant
def cancel_stream(stream_id: uint256):
"""
@notice Cancel a stream and refund remaining balances.
"""
stream: DonationStream = self.streams[stream_id]
assert stream.donor == msg.sender, "donor only"

pool: address = stream.pool
coins: address[N_COINS] = stream.coins
amounts_refund: uint256[N_COINS] = stream.amounts_remaining
reward_refund: uint256 = stream.reward_remaining
self.streams[stream_id] = empty(DonationStream)

for i: uint256 in range(N_COINS):
if amounts_refund[i] > 0:
assert extcall IERC20(coins[i]).transfer(
msg.sender, amounts_refund[i], default_return_value=True
), "refund failed"
if reward_refund > 0:
send(msg.sender, reward_refund)

log StreamCancelled(
stream_id=stream_id,
donor=msg.sender,
pool=pool,
amounts=amounts_refund,
reward_refund=reward_refund,
)
Example
>>> DonationStreamer.cancel_stream(0)

Stream Execution

execute

DonationStreamer.execute(stream_id: uint256) -> bool

Executes a single stream if it has due periods. The function calculates how many periods are due, donates the proportional token amounts to the pool via add_liquidity(donation=True), and pays the caller the corresponding ETH reward. If all periods are completed, the stream storage is cleared. Returns False if no periods are due.

InputTypeDescription
stream_iduint256ID of the stream to execute

Returns: whether the stream was executed (bool).

Emits: StreamExecuted event.

<>Source code
@external
@nonreentrant
def execute(stream_id: uint256) -> bool:
"""
@notice Execute a single stream id.
"""
return self._execute_stream(stream_id)


@internal
def _execute_stream(stream_id: uint256) -> bool:
"""
@dev Execute a single stream if due and pay its reward.
"""
stream: DonationStream = self.streams[stream_id]
periods_due: uint256 = self._due_periods(stream)
if periods_due == 0:
return False

is_final: bool = periods_due == stream.periods_remaining
pool: address = stream.pool
coins: address[N_COINS] = stream.coins

# Compute the slice for this execution, using the remainder on the final call.
amounts_to_donate: uint256[N_COINS] = empty(uint256[N_COINS])
for j: uint256 in range(N_COINS):
remaining: uint256 = stream.amounts_remaining[j]
if remaining == 0:
continue
amount: uint256 = stream.amounts_per_period[j] * periods_due
if is_final:
amount = remaining
amounts_to_donate[j] = amount
stream.amounts_remaining[j] = remaining - amount

stream.periods_remaining -= periods_due
stream.next_ts += stream.period_length * periods_due

# Rewards are prorated per period, with final execution paying the remainder.
reward_paid: uint256 = stream.reward_per_period * periods_due
if is_final:
reward_paid = stream.reward_remaining
stream.reward_remaining -= reward_paid

# Clear storage once the stream is finished.
if is_final:
self.streams[stream_id] = empty(DonationStream)
else:
self.streams[stream_id] = stream

# Only approve and add liquidity when there is a non-zero donation.
if amounts_to_donate[0] > 0 or amounts_to_donate[1] > 0:
balances_before: uint256[N_COINS] = empty(uint256[N_COINS])
for j: uint256 in range(N_COINS):
if amounts_to_donate[j] > 0:
balances_before[j] = staticcall IERC20(coins[j]).balanceOf(self)
for j: uint256 in range(N_COINS):
if amounts_to_donate[j] > 0:
self._safe_approve(coins[j], pool, amounts_to_donate[j])
extcall DonationPoolTarget(pool).add_liquidity(
amounts_to_donate,
0,
empty(address),
True,
)
for j: uint256 in range(N_COINS):
if amounts_to_donate[j] > 0:
balance_after: uint256 = staticcall IERC20(coins[j]).balanceOf(self)
assert balances_before[j] >= balance_after, "bad pool pull"
assert balances_before[j] - balance_after == amounts_to_donate[j], "bad pool pull"
self._safe_approve(coins[j], pool, 0)

if reward_paid > 0:
send(msg.sender, reward_paid)
log StreamExecuted(
stream_id=stream_id,
caller=msg.sender,
pool=pool,
periods=periods_due,
amounts=amounts_to_donate,
reward_paid=reward_paid,
)

return True


@internal
def _safe_approve(token: address, spender: address, amount: uint256):
"""
@dev Safely set allowance, resetting to zero when needed.
"""
allowance: uint256 = staticcall IERC20(token).allowance(self, spender)
if allowance == amount:
return
if allowance != 0 and amount != 0:
assert extcall IERC20(token).approve(
spender, 0, default_return_value=True
), "approve failed"
assert extcall IERC20(token).approve(
spender, amount, default_return_value=True
), "approve failed"


@internal
@view
def _due_periods(stream: DonationStream) -> uint256:
"""
@dev Return the number of due periods for a stream.
"""
if (
stream.donor == empty(address)
or stream.periods_remaining == 0
or stream.period_length == 0
or block.timestamp < stream.next_ts
):
return 0

return min(
(block.timestamp - stream.next_ts) // stream.period_length + 1,
stream.periods_remaining,
)
Example
>>> DonationStreamer.execute(0)
True

execute_many

DonationStreamer.execute_many(stream_ids: DynArray[uint256, 32]) -> DynArray[bool, 32]

Executes a batch of streams in a single transaction. Each stream is processed independently — if a stream is not due, its result is False. The caller receives the combined ETH rewards for all successfully executed streams. Maximum batch size is 32.

InputTypeDescription
stream_idsDynArray[uint256, 32]Array of stream IDs to execute

Returns: per-stream execution results in input order (DynArray[bool, 32]).

<>Source code
@external
@nonreentrant
def execute_many(stream_ids: DynArray[uint256, N_MAX_EXECUTE]) -> DynArray[bool, N_MAX_EXECUTE]:
"""
@notice Execute a batch of stream ids.
@return Per-stream execution results in input order.
"""
results: DynArray[bool, N_MAX_EXECUTE] = empty(DynArray[bool, N_MAX_EXECUTE])
for i: uint256 in range(len(stream_ids), bound=N_MAX_EXECUTE):
results.append(self._execute_stream(stream_ids[i]))
return results
Example
>>> DonationStreamer.execute_many([0, 1, 2])
[True, True, False]

View Methods

is_due

DonationStreamer.is_due(stream_id: uint256) -> bool: view

Returns True if the stream has one or more periods that can be executed now.

InputTypeDescription
stream_iduint256ID of the stream to check

Returns: whether the stream is due for execution (bool).

<>Source code
@view
@external
def is_due(stream_id: uint256) -> bool:
"""
@notice Return true if the stream can be executed now.
"""
return self._due_periods(self.streams[stream_id]) > 0


@internal
@view
def _due_periods(stream: DonationStream) -> uint256:
"""
@dev Return the number of due periods for a stream.
"""
if (
stream.donor == empty(address)
or stream.periods_remaining == 0
or stream.period_length == 0
or block.timestamp < stream.next_ts
):
return 0

return min(
(block.timestamp - stream.next_ts) // stream.period_length + 1,
stream.periods_remaining,
)
Example

streams_and_rewards_due

DonationStreamer.streams_and_rewards_due() -> (DynArray[uint256, 1024], DynArray[uint256, 1024]): view

Returns two arrays: the IDs of all due streams and their corresponding ETH rewards, ordered from newest to oldest. Iterates over up to 1024 streams starting from the most recent. This function is intended for off-chain use (e.g., by keeper bots) and should not be called on-chain.

Returns: a tuple of (due stream IDs, corresponding rewards) (DynArray[uint256, 1024], DynArray[uint256, 1024]).

<>Source code
@view
@external
def streams_and_rewards_due(
) -> (DynArray[uint256, N_MAX_VIEW], DynArray[uint256, N_MAX_VIEW]):
"""
@notice Return due stream ids and rewards, newest first.
@dev Not meant to be called onchain; iterates over up to N_MAX_VIEW streams starting from the newest.
"""
due_ids: DynArray[uint256, N_MAX_VIEW] = empty(DynArray[uint256, N_MAX_VIEW])
rewards: DynArray[uint256, N_MAX_VIEW] = empty(DynArray[uint256, N_MAX_VIEW])
count: uint256 = self.stream_count
if count == 0:
return due_ids, rewards

# Walk backward from newest to oldest, capped for view usage.
limit: uint256 = min(count, N_MAX_VIEW)
for i: uint256 in range(limit, bound=N_MAX_VIEW):
stream_id: uint256 = count - 1 - i
stream: DonationStream = self.streams[stream_id]
periods_due: uint256 = self._due_periods(stream)
if periods_due == 0:
continue

due_ids.append(stream_id)
if periods_due == stream.periods_remaining:
rewards.append(stream.reward_remaining)
else:
rewards.append(stream.reward_per_period * periods_due)

return due_ids, rewards
Example
>>> DonationStreamer.streams_and_rewards_due()
([2, 0], [1000000000000000, 3000000000000000])

stream_count

DonationStreamer.stream_count() -> uint256: view

Returns the total number of streams that have been created. Stream IDs are sequential starting from 0.

Returns: total number of streams created (uint256).

<>Source code
stream_count: public(uint256)
Example

streams

DonationStreamer.streams(arg0: uint256) -> DonationStream: view

Returns the full DonationStream struct for a given stream ID. Completed or cancelled streams return a zeroed-out struct.

The DonationStream struct contains the following fields:

FieldTypeDescription
donoraddressAddress of the stream creator
pooladdressTarget FXSwap pool address
coinsaddress[2]Token addresses for the pool
amounts_per_perioduint256[2]Token amounts donated per period (truncated from total)
period_lengthuint256Duration of each period in seconds
reward_per_perioduint256ETH reward paid per period execution
next_tsuint256Timestamp when the next period becomes due
reward_remaininguint256Total ETH rewards still to be paid out
amounts_remaininguint256[2]Token amounts still to be donated
periods_remaininguint256Number of periods left to execute
InputTypeDescription
arg0uint256Stream ID

Returns: the stream data (DonationStream).

<>Source code
struct DonationStream:
# Static
donor: address
pool: address
coins: address[N_COINS]
amounts_per_period: uint256[N_COINS]
period_length: uint256
reward_per_period: uint256
# Dynamic
next_ts: uint256
reward_remaining: uint256
amounts_remaining: uint256[N_COINS]
periods_remaining: uint256

streams: public(HashMap[uint256, DonationStream])
Example