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