
Part 2: Writing the Move Smart Contract for On-Chain RBAC & Monetization
We shift focus to the blockchain, writing the Move smart contract that powers the API gateway's access control. This post covers configuring the Move.toml for safe deployment, building the core logic for tiered subscriptions and automated fee splitting, and implementing inline unit tests to ensure security.
1) Write Movement smart contract
Movement smart contract wtitten in the Move language will handle subscription tiers, rate limiting and cooldown and monitisation.
Move.toml
We need to add deployer addresses to the Move.toml.
Path: packages/contracts/Move.toml
Values:
[addresses]
deployer = "_"
[dev-addresses]
deployer = "0xcafe"
Why?
[addresses] deployer = "_": The underscore is a placeholder. It tells the compiler, "This is a production address, but I am not going to hardcode it here. I will provide the actual address via the CLI at the exact moment I deploy to the testnet/mainnet." This prevents us from accidentally deploying a mainnet contract to a testnet address if we forget to update the file.[dev-addresses] deployer = "0xcafe": Whenever you run a test command (movement move test), the compiler automatically ignores the[addresses]block and uses the[dev-addresses]block. It assigns the dummy hexadecimal address0xcafeto@deployerpurely in memory so our unit tests can execute safely.
API Gateway contract
Path: packages/contracts/sources/api_gateway.move
Code (self explanitory):
module deployer::api_gateway {
use std::signer;
use aptos_framework::coin;
use aptos_framework::aptos_coin::AptosCoin;
use aptos_framework::timestamp;
use aptos_framework::event;
const TIER_FREE: u8 = 0;
const TIER_PRO: u8 = 1;
const TIER_PREMIUM: u8 = 2;
const PRO_PRICE: u64 = 10_000_000; // 0.1 tokens per month
const PREMIUM_PRICE: u64 = 50_000_000; // 0.5 tokens per month
const SUB_DURATION_SEC: u64 = 2592000; // 30 Days in seconds
const RATE_LIMIT_COOLDOWN_SEC: u64 = 60;
const E_RATE_LIMIT_EXCEEDED: u64 = 1;
const E_INVALID_TIER: u64 = 2;
const E_SUBSCRIPTION_EXPIRED: u64 = 3;
const E_DOWNGRADE_NOT_SUPPORTED: u64 = 4;
const E_INVALID_DURATION: u64 = 5;
struct PlatformConfig has key {
fee_collection_address: address,
}
struct Subscription has key {
tier: u8,
expires_at: u64,
last_request_time: u64,
}
struct SessionKey has key {
ephemeral_public_key: vector<u8>,
expires_at: u64,
}
#[event]
struct SubscriptionEvent has drop, store {
user: address, tier: u8, cost_paid: u64, expires_at: u64,
}
#[event]
struct SessionKeyAuthorizedEvent has drop, store {
user: address, ephemeral_public_key: vector<u8>, expires_at: u64,
}
public entry fun initialize(admin: &signer, fee_collection_address: address) {
move_to(admin, PlatformConfig { fee_collection_address });
}
public entry fun subscribe(user: &signer, target_tier: u8, duration_months: u64) acquires PlatformConfig, Subscription {
assert!(duration_months == 1 || duration_months == 3 || duration_months == 6 || duration_months == 12, E_INVALID_DURATION);
let user_addr = signer::address_of(user);
let current_time = timestamp::now_seconds();
let cost: u64;
let monthly_price = if (target_tier == TIER_PRO) { PRO_PRICE }
else if (target_tier == TIER_PREMIUM) { PREMIUM_PRICE }
else if (target_tier == TIER_FREE) { 0 }
else { abort E_INVALID_TIER };
let total_base_price = monthly_price * duration_months;
let added_duration = SUB_DURATION_SEC * duration_months;
if (exists<Subscription>(user_addr)) {
let sub = borrow_global_mut<Subscription>(user_addr);
// Block downgrades completely if active
if (sub.expires_at > current_time) {
assert!(target_tier >= sub.tier, E_DOWNGRADE_NOT_SUPPORTED);
};
if (sub.expires_at > current_time && target_tier > sub.tier) {
// UPGRADE: Prorate remaining value
let time_remaining = sub.expires_at - current_time;
let old_monthly_price = if (sub.tier == TIER_PRO) { PRO_PRICE } else { 0 };
let credit_value = (old_monthly_price * time_remaining) / SUB_DURATION_SEC;
cost = total_base_price - credit_value;
sub.expires_at = current_time + added_duration; // Reset clock for new duration
} else {
// RENEWAL (Same Tier)
cost = total_base_price;
let start_time = if (sub.expires_at > current_time) { sub.expires_at } else { current_time };
sub.expires_at = start_time + added_duration;
};
sub.tier = target_tier;
} else {
// NEW SUBSCRIPTION
cost = total_base_price;
move_to(user, Subscription {
tier: target_tier,
expires_at: current_time + added_duration,
last_request_time: 0,
});
};
process_payment(user, cost);
event::emit(SubscriptionEvent {
user: user_addr, tier: target_tier, cost_paid: cost,
expires_at: borrow_global<Subscription>(user_addr).expires_at
});
}
fun process_payment(user: &signer, cost: u64) acquires PlatformConfig {
let platform_fee = cost * 5 / 100;
let dev_revenue = cost - platform_fee;
let config = borrow_global<PlatformConfig>(@deployer);
coin::transfer<AptosCoin>(user, config.fee_collection_address, platform_fee);
coin::transfer<AptosCoin>(user, @deployer, dev_revenue);
}
// Authorize an ephemeral session key for frictionless Web3 interactions
public entry fun authorize_session_key(user: &signer, ephemeral_public_key: vector<u8>, duration_sec: u64) acquires SessionKey {
let user_addr = signer::address_of(user);
let current_time = timestamp::now_seconds();
let expiration = current_time + duration_sec;
if (exists<SessionKey>(user_addr)) {
let session = borrow_global_mut<SessionKey>(user_addr);
session.ephemeral_public_key = ephemeral_public_key;
session.expires_at = expiration;
} else {
move_to(user, SessionKey {
ephemeral_public_key,
expires_at: expiration,
});
};
event::emit(SessionKeyAuthorizedEvent {
user: user_addr,
ephemeral_public_key,
expires_at: expiration,
});
}
// 3. Record an API Request - DEPRECATED - Kept for educational and testing purposes only
public entry fun log_api_request(user: &signer) acquires Subscription {
let user_addr = signer::address_of(user);
let current_time = timestamp::now_seconds();
let sub = borrow_global_mut<Subscription>(user_addr);
// Enforce Rate Limits based on Tier
if (sub.tier == TIER_FREE) {
assert!(current_time >= sub.last_request_time + RATE_LIMIT_COOLDOWN_SEC, E_RATE_LIMIT_EXCEEDED);
} else if (sub.tier == TIER_PRO) {
// Pro tier might have a shorter cooldown, e.g., 5 seconds
assert!(current_time >= sub.last_request_time + 5, E_RATE_LIMIT_EXCEEDED);
};
// TIER_PREMIUM has no assert block—it bypasses rate limiting completely!
// Update the timestamp for the next call
sub.last_request_time = current_time;
}
}
#[test_only]
module deployer::api_gateway_tests {
use std::signer;
use aptos_framework::account;
use aptos_framework::coin;
use aptos_framework::aptos_coin::{Self, AptosCoin};
use aptos_framework::timestamp;
use deployer::api_gateway;
// Helper to setup the test environment and fund wallets
fun setup_env(aptos_framework: &signer, deployer: &signer, user: &signer, treasury: &signer) {
timestamp::set_time_has_started_for_testing(aptos_framework);
// Initialize the native coin for testing
let (burn_cap, mint_cap) = aptos_coin::initialize_for_test(aptos_framework);
account::create_account_for_test(signer::address_of(deployer));
account::create_account_for_test(signer::address_of(user));
account::create_account_for_test(signer::address_of(treasury));
coin::register<AptosCoin>(deployer);
coin::register<AptosCoin>(user);
coin::register<AptosCoin>(treasury);
// Give user 1000 tokens to test with
coin::deposit(signer::address_of(user), coin::mint(1000_000_000, &mint_cap));
coin::destroy_burn_cap(burn_cap);
coin::destroy_mint_cap(mint_cap);
// Initialize our contract
api_gateway::initialize(deployer, signer::address_of(treasury));
}
#[test(aptos_framework = @0x1, deployer = @deployer, user = @0x123, treasury = @0x456)]
fun test_multi_month_purchase_and_renewal(aptos_framework: signer, deployer: signer, user: signer, treasury: signer) {
setup_env(&aptos_framework, &deployer, &user, &treasury);
// User buys 3 months of Pro
api_gateway::subscribe(&user, 1, 3); // 1 = Pro, 3 = Months
// Fast forward time by 60 days
timestamp::update_global_time_for_test_secs(60 * 86400);
// They should still be able to log a request because they bought 3 months
api_gateway::log_api_request(&user);
// User renews for 1 more month early
api_gateway::subscribe(&user, 1, 1);
// Fast forward another 45 days (total 105 days).
// Original 90 days + 30 new days = 120 days total. Should still be active.
timestamp::update_global_time_for_test_secs(105 * 86400);
api_gateway::log_api_request(&user);
}
#[test(aptos_framework = @0x1, deployer = @deployer, user = @0x123, treasury = @0x456)]
#[expected_failure(abort_code = 4, location = deployer::api_gateway)] // Expect E_DOWNGRADE_NOT_SUPPORTED
fun test_blocks_active_downgrades(aptos_framework: signer, deployer: signer, user: signer, treasury: signer) {
setup_env(&aptos_framework, &deployer, &user, &treasury);
// Buy 1 month premium
api_gateway::subscribe(&user, 2, 1);
// Try to buy Pro while premium is active -> Should Abort
api_gateway::subscribe(&user, 1, 1);
}
#[test(aptos_framework = @0x1, deployer = @deployer, user = @0x123, treasury = @0x456)]
fun test_prorated_upgrade(aptos_framework: signer, deployer: signer, user: signer, treasury: signer) {
setup_env(&aptos_framework, &deployer, &user, &treasury);
// Check starting balance
let start_balance = coin::balance<AptosCoin>(signer::address_of(&user));
// Buy 1 month of Pro (Costs 10_000_000)
api_gateway::subscribe(&user, 1, 1);
// Fast forward exactly 15 days (half a month)
timestamp::update_global_time_for_test_secs(15 * 86400);
// Upgrade to 1 month of Premium
// Base Premium is 50_000_000. They have 15 days of Pro left (value: 5_000_000).
// Upgrade should cost exactly 45_000_000.
api_gateway::subscribe(&user, 2, 1);
let final_balance = coin::balance<AptosCoin>(signer::address_of(&user));
let total_spent = start_balance - final_balance;
// 10m (initial) + 45m (upgrade) = 55m total spent
assert!(total_spent == 55_000_000, 100);
}
#[test(aptos_framework = @0x1, deployer = @deployer, user = @0x123, treasury = @0x456)]
fun test_free_tier_subscription(aptos_framework: signer, deployer: signer, user: signer, treasury: signer) {
setup_env(&aptos_framework, &deployer, &user, &treasury);
// User should be able to "buy" 12 months of free tier without an abort
api_gateway::subscribe(&user, 0, 12);
}
#[test(aptos_framework = @0x1, deployer = @deployer, user = @0x123, treasury = @0x456)]
fun test_authorize_session_key(aptos_framework: signer, deployer: signer, user: signer, treasury: signer) {
setup_env(&aptos_framework, &deployer, &user, &treasury);
let dummy_pubkey = x"1234567890abcdef";
api_gateway::authorize_session_key(&user, dummy_pubkey, 3600); // 1 hour
// Fast forward 30 minutes
timestamp::update_global_time_for_test_secs(1800);
// Re-authorize updates the key
let new_pubkey = x"abcdef1234567890";
api_gateway::authorize_session_key(&user, new_pubkey, 7200); // 2 hours
}
}
Unit Tests
In the Move language, inline testing is the official, production-standard best practice for Unit Tests.
Why?
- Inline (
#[test_only]inside the module): In Move, we often write private internal functions (likeprocess_payment). If we put our unit tests in a completely separate file inside thetests/directory, those tests cannot access our private functions. By putting unit tests inside the module itself, we can test the internal micro-logic of the contract securely. When we compile for production (without thetestflag), the compiler physically strips out everything tagged#[test_only], meaning it costs use exactly €0 in extra deployment gas. - The
tests/Directory: This folder is strictly reserved for Integration Tests. If we eventually build a massively complex protocol with 5 different smart contracts (e.g., an API gateway, a staking contract, and a token launcher) that all talk to each other, we write the multi-contract integrationtestsin thetests/directory.
Running Tests
- Path:
packages/contracts/ - Command:
movement move test
What's Next?
With our Move smart contract written and our local unit tests passing, we have an access control engine. But it's just sitting on our local machine.
In Part 3, we will deploy the contract to the Movement Bardock Testnet, generate distinct test wallets, and execute transactions via the CLI to prove our monetization and rate limiting work on-chain. We will also break down a major architectural decision: why relying on pure on-chain execution for a high-frequency API is a UX nightmare.
Community Discussion
0 Comments
Found this helpful?
If you enjoyed this technical tale, consider supporting my work.