-
Notifications
You must be signed in to change notification settings - Fork 60
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: abs-compatible subscription payment system
Description ----------- - SubscriptionRegistry: Manages plans and subscriptions - SubscriptionAccount: Smart wallet for automated payments - Full test coverage. Includes ECDSA validation, reentrancy protection, and OpenZeppelin security features. Usage ----- Please check `README.md`.
- Loading branch information
1 parent
cf1c587
commit 0009628
Showing
18 changed files
with
1,085 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
node_modules | ||
.env | ||
.env.local | ||
|
||
# Hardhat files | ||
cache | ||
cache-zk | ||
artifacts | ||
artifacts-zk | ||
artifacts-abstract | ||
deployments | ||
deployments-zk | ||
deployments-abstract | ||
anvil-zksync.log | ||
anvil-abstract.log | ||
|
||
# TypeChain files | ||
typechain | ||
typechain-types | ||
|
||
# solidity-coverage files | ||
coverage | ||
coverage.json | ||
|
||
# Hardhat Ignition deployment files | ||
ignition/deployments | ||
ignition/deployments/chain-31337 | ||
|
||
# IDE files | ||
.vscode | ||
.idea | ||
*.swp | ||
*.swo | ||
|
||
# OS files | ||
.DS_Store | ||
Thumbs.db | ||
|
||
# Debug logs | ||
debug.log | ||
npm-debug.log* | ||
yarn-debug.log* | ||
yarn-error.log* |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
loglevel=error | ||
audit=false | ||
fund=false | ||
update-notifier=false |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
# Dependencies | ||
node_modules | ||
|
||
# Build artifacts | ||
artifacts | ||
artifacts-zk | ||
artifacts-abstract | ||
cache | ||
cache-zk | ||
coverage | ||
typechain-types | ||
dist | ||
deployments-zk | ||
deployments-abstract | ||
|
||
# Logs and environment | ||
.env | ||
*.log | ||
anvil-zksync.log | ||
anvil-abstract.log | ||
|
||
# IDE and system files | ||
.DS_Store | ||
.idea | ||
.vscode |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
{ | ||
"semi": true, | ||
"tabWidth": 2, | ||
"printWidth": 100, | ||
"singleQuote": true, | ||
"trailingComma": "all", | ||
"bracketSpacing": true, | ||
"useTabs": false, | ||
"arrowParens": "avoid", | ||
"overrides": [ | ||
{ | ||
"files": "*.sol", | ||
"options": { | ||
"printWidth": 100, | ||
"tabWidth": 4, | ||
"useTabs": false, | ||
"singleQuote": false, | ||
"bracketSpacing": false | ||
} | ||
} | ||
] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
{ | ||
"extends": "solhint:recommended", | ||
"rules": { | ||
"compiler-version": ["error", "^0.8.24"], | ||
"func-visibility": ["error", { "ignoreConstructors": true }], | ||
"not-rely-on-time": "off", | ||
"no-empty-blocks": "off", | ||
"no-inline-assembly": "off", | ||
"avoid-low-level-calls": "off" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
# Abstract Subscription Payments example | ||
|
||
This project demonstrates a subscription payment system built on Abstract. It includes smart contracts for managing subscriptions and payments. | ||
|
||
## Flow: | ||
|
||
1. Registry owner creates subscription plans | ||
2. Users deploy SubscriptionAccount | ||
3. Account owner subscribes to plans | ||
4. External services/automation can trigger subscription payments when due | ||
5. Payments are processed through the account and sent to the registry | ||
|
||
## Architecture | ||
|
||
### SubscriptionRegistry | ||
|
||
- Central contract managing all subscription plans | ||
- Handles plan creation and management | ||
- Tracks all active subscriptions | ||
- Processes payments and withdrawals | ||
- Owned by a central administrator | ||
|
||
### SubscriptionAccount | ||
|
||
- Smart contract wallet for subscription management | ||
- zk compatible account implementation | ||
- Secure transaction validation using `ECDSA` | ||
- Only owner can modify subscription status | ||
|
||
## Security Considerations | ||
|
||
1. All contracts use OpenZeppelin's security primitives | ||
2. Reentrancy protection on critical functions | ||
3. Proper access control with ownership | ||
4. Signature validation for all transactions | ||
5. Balance checks before payments | ||
|
||
|
||
## Setup | ||
|
||
1. Install dependencies: | ||
|
||
```bash | ||
npm install | ||
``` | ||
|
||
2. Set your deployment private key: | ||
Make to go to faucet and get testnet funds | ||
|
||
```bash | ||
npx hardhat vars set DEPLOYER_PRIVATE_KEY <your_private_key_here> | ||
``` | ||
|
||
## Available Commands | ||
|
||
```bash | ||
# Clean the project | ||
npm run clean | ||
|
||
# Compile contracts for Abstract | ||
npm run compile | ||
|
||
# Run tests (uses local Hardhat network) | ||
npm test | ||
|
||
# Deploy contracts to Abstract testnet | ||
npm run deploy | ||
|
||
# Verify contract on Abstract Explorer (replace CONTRACT_ADDRESS with your deployed contract address) | ||
npm run verify CONTRACT_ADDRESS | ||
|
||
# Check deployer balance | ||
npm run check-balance | ||
``` | ||
|
||
## Important Notes | ||
|
||
1. Make sure to get testnet funds from the Abstract faucet before deployment | ||
2. Use a new wallet for testing - don't use your real private key associated with real real trading wallet. | ||
3. Tests run on local network, and deployment goes to Abstract testnet |
186 changes: 186 additions & 0 deletions
186
subscription-payments/contracts/SubscriptionAccount.sol
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,186 @@ | ||
// SPDX-License-Identifier: MIT | ||
pragma solidity ^0.8.24; | ||
|
||
import {SubscriptionRegistry} from "./SubscriptionRegistry.sol"; | ||
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; | ||
import {IAccount} from "@matterlabs/zksync-contracts/l2/system-contracts/interfaces/IAccount.sol"; | ||
import {Transaction, TransactionHelper} from "@matterlabs/zksync-contracts/l2/system-contracts/libraries/TransactionHelper.sol"; | ||
import {SystemContractsCaller} from "@matterlabs/zksync-contracts/l2/system-contracts/libraries/SystemContractsCaller.sol"; | ||
import {BOOTLOADER_FORMAL_ADDRESS, NONCE_HOLDER_SYSTEM_CONTRACT} from "@matterlabs/zksync-contracts/l2/system-contracts/Constants.sol"; | ||
import {INonceHolder} from "@matterlabs/zksync-contracts/l2/system-contracts/interfaces/INonceHolder.sol"; | ||
|
||
/** | ||
* @title SubscriptionAccount | ||
* @notice Smart contract account that handles automated subscription payments | ||
*/ | ||
contract SubscriptionAccount is IAccount { | ||
using TransactionHelper for Transaction; | ||
using ECDSA for bytes32; | ||
|
||
bytes4 constant VALIDATION_SUCCESS_MAGIC = 0x1626ba7e; | ||
|
||
error OnlyOwner(); | ||
error InvalidSignature(); | ||
error TransactionFailed(); | ||
error SubscriptionNotActive(); | ||
error PlanNotActive(); | ||
error PaymentProcessingFailed(); | ||
error PaymentTransferFailed(); | ||
error OnlyBootloader(); | ||
|
||
SubscriptionRegistry public immutable REGISTRY; | ||
address public immutable OWNER; | ||
|
||
event PaymentFailed(uint256 indexed planId, string reason); | ||
|
||
constructor(address payable _registry, address _owner) { | ||
REGISTRY = SubscriptionRegistry(_registry); | ||
OWNER = _owner; | ||
} | ||
|
||
modifier onlyBootloader() { | ||
if (msg.sender != BOOTLOADER_FORMAL_ADDRESS) revert OnlyBootloader(); | ||
_; | ||
} | ||
|
||
modifier onlyOwner() { | ||
if (msg.sender != OWNER) revert OnlyOwner(); | ||
_; | ||
} | ||
|
||
function validateTransaction( | ||
bytes32, | ||
bytes32 _suggestedSignedHash, | ||
Transaction calldata _transaction | ||
) external payable override onlyBootloader returns (bytes4 magic) { | ||
return _validateTransaction(_suggestedSignedHash, _transaction); | ||
} | ||
|
||
function executeTransactionFromOutside( | ||
Transaction calldata _transaction | ||
) external payable override onlyBootloader { | ||
_executeTransaction(_transaction); | ||
} | ||
|
||
function _validateTransaction( | ||
bytes32 _suggestedSignedHash, | ||
Transaction calldata _transaction | ||
) internal returns (bytes4 magic) { | ||
// Increments nonce | ||
SystemContractsCaller.systemCallWithPropagatedRevert( | ||
uint32(gasleft()), | ||
address(NONCE_HOLDER_SYSTEM_CONTRACT), | ||
0, | ||
abi.encodeCall(INonceHolder.incrementMinNonceIfEquals, (_transaction.nonce)) | ||
); | ||
|
||
// Validates signature | ||
bytes32 txHash = _suggestedSignedHash == bytes32(0) | ||
? _transaction.encodeHash() | ||
: _suggestedSignedHash; | ||
if (!_validateSignature(txHash, _transaction.signature)) revert InvalidSignature(); | ||
|
||
// Checks balance | ||
uint256 totalRequiredBalance = _transaction.totalRequiredBalance(); | ||
require( | ||
totalRequiredBalance <= address(this).balance, | ||
"Not enough balance for fee + value" | ||
); | ||
|
||
magic = VALIDATION_SUCCESS_MAGIC; | ||
} | ||
|
||
function executeTransaction( | ||
bytes32, | ||
bytes32, | ||
Transaction calldata _transaction | ||
) external payable override onlyBootloader { | ||
_executeTransaction(_transaction); | ||
} | ||
|
||
function _executeTransaction(Transaction calldata _transaction) internal { | ||
address to = address(uint160(_transaction.to)); | ||
uint256 value = _transaction.value; | ||
bytes memory data = _transaction.data; | ||
|
||
bool success; | ||
assembly { | ||
success := call(gas(), to, value, add(data, 0x20), mload(data), 0, 0) | ||
} | ||
if (!success) revert TransactionFailed(); | ||
} | ||
|
||
function subscribe(uint256 planId) external onlyOwner { | ||
REGISTRY.subscribe(planId); | ||
} | ||
|
||
function cancelSubscription(uint256 planId) external onlyOwner { | ||
REGISTRY.cancelSubscription(planId); | ||
} | ||
|
||
function processSubscriptionPayment(uint256 planId) external { | ||
// Gets subscription details | ||
SubscriptionRegistry.Subscription memory sub = REGISTRY.getSubscription( | ||
address(this), | ||
planId | ||
); | ||
if (!sub.active) revert SubscriptionNotActive(); | ||
|
||
// Gets plan details | ||
SubscriptionRegistry.SubscriptionPlan memory plan = REGISTRY.getPlan(planId); | ||
if (!plan.active) revert PlanNotActive(); | ||
|
||
// Processes the payment | ||
bool success = REGISTRY.processPayment(address(this), planId); | ||
if (!success) revert PaymentProcessingFailed(); | ||
|
||
// Transfers the subscription amount | ||
(bool transferred, ) = payable(address(REGISTRY)).call{value: plan.amount}(""); | ||
if (!transferred) revert PaymentTransferFailed(); | ||
} | ||
|
||
function _validateSignature(bytes32 hash, bytes memory signature) internal view returns (bool) { | ||
if (signature.length != 65) { | ||
return false; | ||
} | ||
|
||
uint8 v; | ||
bytes32 r; | ||
bytes32 s; | ||
assembly { | ||
r := mload(add(signature, 0x20)) | ||
s := mload(add(signature, 0x40)) | ||
v := and(mload(add(signature, 0x41)), 0xff) | ||
} | ||
|
||
if (v != 27 && v != 28) { | ||
return false; | ||
} | ||
|
||
if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) { | ||
return false; | ||
} | ||
|
||
address recoveredAddress = ecrecover(hash, v, r, s); | ||
return recoveredAddress == OWNER && recoveredAddress != address(0); | ||
} | ||
|
||
function payForTransaction( | ||
bytes32, | ||
bytes32, | ||
Transaction calldata _transaction | ||
) external payable override onlyBootloader { | ||
bool success = _transaction.payToTheBootloader(); | ||
require(success, "Failed to pay bootloader operator fee"); | ||
} | ||
|
||
function prepareForPaymaster( | ||
bytes32, | ||
bytes32, | ||
Transaction calldata _transaction | ||
) external payable override onlyBootloader { | ||
_transaction.processPaymasterInput(); | ||
} | ||
|
||
receive() external payable {} | ||
} |
Oops, something went wrong.