Skip to content

Commit 5c0dca4

Browse files
royvardhanseunlanlegeWizdave97
authored
[evm, runtime]: switch to prepaid bandwidth model (#810)
Co-authored-by: Seun Lanlege <seun@polytope.technology> Co-authored-by: David Salami <wizdave97@gmail.com>
1 parent 42e58d8 commit 5c0dca4

43 files changed

Lines changed: 3474 additions & 1135 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 20 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ members = [
2727
"modules/pallets/token-gateway/primitives",
2828
"modules/pallets/token-gateway-inspector",
2929
"modules/pallets/hyperbridge",
30+
"modules/pallets/bandwidth",
3031
"modules/pallets/intents-coprocessor",
3132
"modules/pallets/intents-coprocessor/rpc",
3233
"modules/pallets/state-coprocessor",
@@ -269,7 +270,10 @@ pallet-ismp = { version = "2512.1.0", path = "modules/pallets/ismp", default-fea
269270
pallet-ismp-rpc = { version = "2512.0.0", path = "modules/pallets/ismp/rpc" }
270271
pallet-ismp-runtime-api = { version = "2512.0.0", path = "modules/pallets/ismp/runtime-api", default-features = false }
271272
pallet-hyperbridge = { version = "2512.0.0", path = "modules/pallets/hyperbridge", default-features = false }
273+
pallet-bandwidth = { path = "modules/pallets/bandwidth", default-features = false }
274+
272275
pallet-hyper-fungible-token = { version = "2512.0.0", path = "modules/pallets/hyper-fungible-token", default-features = false }
276+
273277
pallet-token-gateway = { version = "2512.0.0", path = "modules/pallets/token-gateway", default-features = false }
274278
token-gateway-primitives = { version = "2512.0.0", path = "modules/pallets/token-gateway/primitives", default-features = false }
275279
substrate-state-machine = { version = "2512.0.0", path = "modules/ismp/state-machines/substrate", default-features = false }

evm/src/apps/BandwidthManager.sol

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
// Copyright (C) Polytope Labs Ltd.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
pragma solidity ^0.8.17;
16+
17+
import {Bytes} from "@polytope-labs/solidity-merkle-trees/src/trie/Bytes.sol";
18+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
19+
import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
20+
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
21+
import {ERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol";
22+
23+
import {PostRequest} from "@hyperbridge/core/libraries/Message.sol";
24+
import {DispatchPost, IDispatcher} from "@hyperbridge/core/interfaces/IDispatcher.sol";
25+
import {IHost} from "@hyperbridge/core/interfaces/IHost.sol";
26+
import {IncomingPostRequest, IApp} from "@hyperbridge/core/interfaces/IApp.sol";
27+
import {HyperApp} from "@hyperbridge/core/apps/HyperApp.sol";
28+
29+
30+
/// Wire payload dispatched by `purchase()` to `pallet-bandwidth`. The
31+
/// pallet credits a tier-bucket on `chain` for `app`, scaled by `months`.
32+
struct BandwidthPurchaseMsg {
33+
/// Recipient app whose bandwidth is being topped up.
34+
bytes app;
35+
/// Tier discriminant (matches `pallet_bandwidth::TierIndex`).
36+
uint256 tier;
37+
/// Number of tier-windows to credit. Bytes and duration both scale.
38+
uint256 months;
39+
/// UTF-8 chain id like `"EVM-8453"` or `"EVM-137"`.
40+
bytes chain;
41+
}
42+
43+
/// One row of a `SetTiers` governance batch.
44+
struct Tier {
45+
/// Tier discriminant (matches `pallet_bandwidth::TierIndex`).
46+
uint256 tier;
47+
/// Price in 18-decimal units; scaled at purchase time to fee-token decimals.
48+
uint256 price;
49+
}
50+
51+
/// Payload of a `Withdraw` governance message — recovers `amount` of
52+
/// `token` to `beneficiary`. `token` is named explicitly so stale
53+
/// fee-token balances after a host-side swap can still be drained.
54+
struct Withdrawal {
55+
address token;
56+
address beneficiary;
57+
uint256 amount;
58+
}
59+
60+
/// @title BandwidthManager
61+
/// @notice Per-chain prepaid bandwidth storefront. Buyers call
62+
/// `purchase()` to debit a fee-token and dispatch a credit message to
63+
/// `pallet-bandwidth` on hyperbridge; tier prices and treasury
64+
/// withdrawals are governed exclusively by the pallet via `onAccept`.
65+
contract BandwidthManager is HyperApp, ERC165 {
66+
using Bytes for bytes;
67+
using SafeERC20 for IERC20;
68+
69+
/// Must equal `pallet-bandwidth`'s `PalletId`. The pallet enforces
70+
/// this on inbound messages, so changing it on either side breaks
71+
/// the round-trip.
72+
bytes public constant PALLET_BANDWIDTH_MODULE_ID = bytes("BWMARKET");
73+
74+
/// Discriminants for the first byte of an `onAccept` body. Order
75+
/// must match `pallet_bandwidth::lib.rs::ACTION_*`.
76+
enum OnAcceptActions {
77+
SetTiers,
78+
Withdraw
79+
}
80+
81+
address public immutable host_;
82+
83+
/// tier → price in 18-decimal units. Zero = unconfigured (purchases
84+
/// against an unconfigured tier revert with `UnknownTier`).
85+
mapping(uint256 => uint256) public tierPrice;
86+
87+
/// Emitted on a successful `purchase()`. `commitment` is the
88+
/// hyperbridge dispatch commitment so callers can correlate with
89+
/// the pallet-side credit event.
90+
event BandwidthPurchased(
91+
address indexed payer,
92+
address feeToken,
93+
uint256 tier,
94+
uint256 months,
95+
uint256 amountPaid,
96+
bytes app,
97+
bytes chain,
98+
bytes32 commitment
99+
);
100+
/// Emitted once per tier in a `SetTiers` governance batch.
101+
event TierSet(uint256 indexed tier, uint256 price18d);
102+
/// Emitted by a `Withdraw` governance message after the transfer succeeds.
103+
event Withdrawn(address indexed token, address indexed beneficiary, uint256 amount);
104+
105+
/// `app`/`chain` empty, or `months == 0`.
106+
error InvalidPurchase();
107+
/// Tier price not configured (`tierPrice[tier] == 0`).
108+
error UnknownTier();
109+
/// 18-d tier price doesn't divide cleanly into `feeToken()` decimals.
110+
error PriceNotRepresentable();
111+
/// `onAccept` body came from a non-hyperbridge source, or the
112+
/// action discriminant is out of range.
113+
error UnauthorizedAction();
114+
115+
constructor(address host__) {
116+
host_ = host__;
117+
}
118+
119+
/// @inheritdoc HyperApp
120+
function host() public view override returns (address) {
121+
return host_;
122+
}
123+
124+
/// @inheritdoc ERC165
125+
function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) {
126+
return interfaceId == type(IApp).interfaceId || super.supportsInterface(interfaceId);
127+
}
128+
129+
/// @notice Pay for `months` of `tier` bandwidth on `chain` for `app`.
130+
/// @dev Pulls the scaled tier price from `msg.sender` in the host's
131+
/// fee token, then dispatches a `BandwidthPurchaseMsg` to
132+
/// `pallet-bandwidth` on hyperbridge. The pallet credits an
133+
/// `(chain, app)` bucket bounded by tier `bytes` × `months`.
134+
/// @param app Recipient app address (usually 20-byte EVM, packed as bytes).
135+
/// @param tier Tier discriminant; must be configured via `SetTiers`.
136+
/// @param months Number of tier-windows to credit; must be > 0.
137+
/// @param chain UTF-8 chain id (e.g. `"EVM-8453"`) of the credit chain.
138+
/// @return commitment Hyperbridge dispatch commitment for tracking.
139+
function purchase(bytes calldata app, uint256 tier, uint256 months, bytes calldata chain)
140+
external
141+
returns (bytes32 commitment)
142+
{
143+
if (app.length == 0 || chain.length == 0 || months == 0) revert InvalidPurchase();
144+
uint256 price18d = tierPrice[tier];
145+
if (price18d == 0) revert UnknownTier();
146+
147+
uint256 total18d = price18d * months;
148+
address feeToken = IHost(host_).feeToken();
149+
uint8 dec = IERC20Metadata(feeToken).decimals();
150+
uint256 scale = 10 ** (18 - dec);
151+
if (total18d % scale != 0) revert PriceNotRepresentable();
152+
uint256 amount = total18d / scale;
153+
154+
IERC20(feeToken).safeTransferFrom(msg.sender, address(this), amount);
155+
156+
BandwidthPurchaseMsg memory body = BandwidthPurchaseMsg({
157+
app: app,
158+
tier: tier,
159+
months: months,
160+
chain: chain
161+
});
162+
163+
commitment = IDispatcher(host_).dispatch(
164+
DispatchPost({
165+
dest: IHost(host_).hyperbridge(),
166+
to: PALLET_BANDWIDTH_MODULE_ID,
167+
body: abi.encode(body),
168+
timeout: 0,
169+
fee: 0,
170+
payer: address(this)
171+
})
172+
);
173+
174+
emit BandwidthPurchased({
175+
payer: msg.sender,
176+
feeToken: feeToken,
177+
tier: tier,
178+
months: months,
179+
amountPaid: amount,
180+
app: app,
181+
chain: chain,
182+
commitment: commitment
183+
});
184+
}
185+
186+
187+
/// @notice Inbound governance from `pallet-bandwidth`. The first
188+
/// body byte selects `OnAcceptActions`; the remainder is the
189+
/// action's ABI-encoded payload.
190+
/// @dev Only the configured host may invoke (`onlyHost`); the
191+
/// request's `source` must additionally equal hyperbridge.
192+
function onAccept(IncomingPostRequest calldata incoming) external override onlyHost {
193+
PostRequest calldata request = incoming.request;
194+
195+
if (!request.source.equals(IHost(host_).hyperbridge())) revert UnauthorizedAction();
196+
197+
OnAcceptActions action = OnAcceptActions(uint8(request.body[0]));
198+
if (action == OnAcceptActions.SetTiers) {
199+
Tier[] memory updates = abi.decode(request.body[1:], (Tier[]));
200+
for (uint256 i = 0; i < updates.length; i++) {
201+
tierPrice[updates[i].tier] = updates[i].price;
202+
emit TierSet(updates[i].tier, updates[i].price);
203+
}
204+
} else if (action == OnAcceptActions.Withdraw) {
205+
Withdrawal memory w = abi.decode(request.body[1:], (Withdrawal));
206+
IERC20(w.token).safeTransfer(w.beneficiary, w.amount);
207+
emit Withdrawn(w.token, w.beneficiary, w.amount);
208+
} else {
209+
revert UnauthorizedAction();
210+
}
211+
}
212+
}

0 commit comments

Comments
 (0)