Skip to content

Commit

Permalink
refactor(py-sdk)!: clean up validators and bots (#110)
Browse files Browse the repository at this point in the history
* refactor(py-sdk): break out different classes into their own modules

updates to some of the experience of using validators

* chore: add flake8

* refactor(py-sdk): allow a better way to integrate w/ Silverback

* fix: use new `.creation_metadata` API

* refactor: allow using `StreamManager(address)` instead of `address=...`

* refactor(demo): create a local experiment, and a demo Silverback app

* docs(bot): add some documentation for the bot

* tests: fix test for new StreamManager.__init__

* fix: remove `global` as bad practice

* fix: remove stream before adding it

* docs: fix name of platform

* refactor(example): update comments and data structure used in bot
  • Loading branch information
fubuloubu authored Sep 23, 2024
1 parent c68e7b0 commit 0e09989
Show file tree
Hide file tree
Showing 13 changed files with 769 additions and 675 deletions.
47 changes: 47 additions & 0 deletions bots/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Silverback Example App

These apps are intended to be examples of how to work with your own deployment of the ApePay
protocol interacting your own infrastructure for a SaaS product, using Silverback for real-time
events. You can host these examples on the Silverback Platform (https://silverback.apeworx.io) or
self-host.

## Stream Handling

ApePay has some different events that should trigger updates to your off-chain tracking of the
current status of a Stream, which should also affect the delivery of your product or service.

The lifecycle of a Stream starts with being created, after which funds can be added at any time to
extend the stream, and finally the Stream can be cancelled at any time (after an initial required
delay).

At each point in the Stream lifecycle, an update to an off-chain record should be created to ensure
consistency within your product and how it is managing aspects of your microservice architecture,
and your Silverback app can directly trigger changes in those other services or perform those
changes themselves.

One important thing to note is that while almost every part of the lifecycle has some sort of on-
chain action that occurs when a user performs an action, a Stream can also "run out of time" which
means you should notify your users as the deadline becomes near to prevent service interruptions,
and also necessitates performing "garbage collection" after the Streams run out completely in order
to remove the associated resources that are no longer being paid for (similar to when a Stream is
cancelled manually).

Lastly, it is important that you understand your own regulatory and reporting requirements and
implement those using a combination of specialized ApePay "validator" contracts as well as trigger-
ing manual cancellation (or review) of the services for breach of terms within your app.
These tasks require a key with access to the Owner role and may be best implemented as a separate
bot to ensure access control restrictions and not interfere with other running bots.

## Revenue Collection

One interesting perk of ApePay is that claiming a Stream (aka "earning revenue") is entirely left
to you as an action you can do at whatever frequency you desire (based on revenue cycles, gas
costs, etc.), and can integrate into other various parts of your revenue management systems, tax
tracking, or whatever else is needed to be accounted for (according to your business needs or
jurisdiction).

It is recommended that you implement this as a separate bot, as optimizing revenue operations can
be a great way to improve overall cost optimization, and also may require advanced access control
rights that your other microservices don't require. We provide an example here of what that might
look like within the example, however please note that a key that has the ability to make
transactions is required for production use.
50 changes: 50 additions & 0 deletions bots/example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import os
from collections import defaultdict

from ape.types import AddressType
from silverback import SilverbackApp

from apepay import Stream, StreamManager

app = SilverbackApp()
# NOTE: This bot assumes you use a new bot per ApePay deployment
sm = StreamManager(os.environ["APEPAY_CONTRACT_ADDRESS"])

# NOTE: You would probably want to index your db by network and deployment address,
# if you were operating on multiple networks and/or deployments (for easy lookup)
db: defaultdict[AddressType, list[Stream]] = defaultdict(list)
# TODO: Migrate to `app.state.db` when feature becomes available


@app.on_startup()
async def load_db(_):
for stream in sm.active_streams():
while len(db[stream.creator]) < stream.stream_id:
db[stream.creator].append(None) # Fill with empty values
assert stream.stream_id == len(db[stream.creator])
db[stream.creator].append(stream)


@sm.on_stream_created(app)
async def grant_product(stream):
assert stream.stream_id == len(db[stream.creator])
db[stream.creator].append(stream)
print(f"provisioning product for {stream.creator}")
return stream.time_left


@sm.on_stream_funded(app)
async def update_product_funding(stream):
# NOTE: properties of stream have changed, you may not need to handle this, but typically you
# would want to update `stream.time_left` in db for use in user Stream life notifications
db[stream.creator].pop(stream.stream_id)
db[stream.creator].insert(stream.stream_id, stream)
return stream.time_left


@sm.on_stream_cancelled(app)
async def revoke_product(stream):
print(f"unprovisioning product for {stream.creator}")
db[stream.creator].pop(stream.stream_id)
db[stream.creator].insert(stream.stream_id, None)
return stream.time_left
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,12 @@ bot = [
"silverback>=0.5,<1",
]
lint = [
"flake8",
"black",
"isort",
"mypy",
# NOTE: Be able to lint our silverback add-ons
"apepay[bot]"
]
test = [
"ape-foundry",
Expand Down
96 changes: 96 additions & 0 deletions scripts/demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""
A demo showing some accounts randomly creating, modifying, and cancelling streams
"""

import random
from datetime import timedelta

import click
from ape.cli import ConnectedProviderCommand, ape_cli_context

from apepay import StreamManager


@click.command(cls=ConnectedProviderCommand)
@ape_cli_context()
@click.option("-l", "--min-stream-life", default=0)
@click.option("-n", "--num-accounts", default=10)
@click.option("-b", "--num-blocks", default=1000)
@click.option("-m", "--max-streams", default=10)
@click.option("-c", "--create-stream", type=float, default=0.1)
@click.option("-f", "--fund-stream", type=float, default=0.7)
@click.option("-k", "--cancel-stream", type=float, default=0.2)
def cli(
cli_ctx,
min_stream_life,
num_accounts,
num_blocks,
max_streams,
create_stream,
fund_stream,
cancel_stream,
):
# Initialize experiment
deployer = cli_ctx.account_manager.test_accounts[-1]
token = cli_ctx.local_project.TestToken.deploy(sender=deployer)
sm = StreamManager(
cli_ctx.local_project.StreamManager.deploy(
deployer, min_stream_life, [], [token], sender=deployer
)
)

# Wait for user to start the example SB app...
click.secho(
f"Please run `APEPAY_CONTRACT_ADDRESS={sm.address} silverback run bots.example:app`",
fg="bright_magenta",
)
if not click.confirm("Start experiment?"):
return

# Make sure all accounts have some tokens
accounts = cli_ctx.account_manager.test_accounts[:num_accounts]
decimals = token.decimals()
for account in accounts:
token.DEBUG_mint(account, 10_000 * 10**decimals, sender=account)

# 26 tokens per day
starting_life = timedelta(minutes=5).total_seconds()
starting_tokens = 26 * 10**decimals
funding_amount = 2 * 10**decimals
streams = {a.address: [] for a in accounts}

while cli_ctx.chain_manager.blocks.head.number < num_blocks:
payer = random.choice(accounts)

# Do a little garbage collection
for stream in streams[payer.address]:
click.echo(f"{payer}:{stream.stream_id} - {stream.time_left}")
if not stream.is_active:
click.echo(f"Stream '{payer}:{stream.stream_id}' is expired, removing...")
streams[payer.address].remove(stream)

if len(streams[payer.address]) > 0:
stream = random.choice(streams[payer.address])

if token.balanceOf(payer) >= 10 ** (decimals + 1) and random.random() < fund_stream:
click.echo(
f"Stream '{payer}:{stream.stream_id}' is being funded "
f"w/ {funding_amount / 10**decimals:.2f} tokens..."
)
token.approve(sm.address, funding_amount, sender=payer)
stream.add_funds(funding_amount, sender=payer)

elif random.random() < cancel_stream:
click.echo(f"Stream '{payer}:{stream.stream_id}' is being cancelled...")
stream.cancel(sender=payer)
streams[payer.address].remove(stream)

elif token.balanceOf(payer) < starting_tokens:
continue

elif len(streams[payer.address]) < max_streams and random.random() < create_stream:
click.echo(f"'{payer}' is creating a new stream...")
token.approve(sm.address, starting_tokens, sender=payer)
stream = sm.create(token, int(starting_tokens / starting_life), sender=payer)
streams[payer.address].append(stream)
click.echo(f"Stream '{payer}:{stream.stream_id}' was created successfully.")
55 changes: 0 additions & 55 deletions scripts/example.py

This file was deleted.

Loading

0 comments on commit 0e09989

Please sign in to comment.