Back to blog

How Vowena's pull-based billing works under the hood

A technical deep dive into the token allowance mechanism, Soroban's auth tree, and the critical design decision behind pre-checking balances.

A

Amara Osei

How Vowena's pull-based billing works under the hood

If you have ever wondered how a smart contract can charge someone every month without them signing a new transaction each time, this post is for you. I am going to walk through the exact mechanism that powers Vowena's recurring billing, line by line.

The allowance primitive

Every SEP-41 token on Stellar implements four functions that matter for subscriptions:

fn approve(from, spender, amount, expiration_ledger)
fn allowance(from, spender) -> i128
fn transfer_from(spender, from, to, amount)
fn balance(id) -> i128

approve() lets a token holder say: "I authorize this address to spend up to X of my tokens, and this authorization expires at ledger Y."

transfer_from() lets the authorized spender actually move the tokens. The spender authenticates - not the token holder.

This is the pull model. The subscriber sets the permission once. The contract pulls funds repeatedly within that permission. No new signatures needed.

What happens when someone subscribes

When a subscriber calls subscribe() on the Vowena contract, the contract does two things in a single transaction:

1. Sets a token allowance:

let allowance = plan.price_ceiling * periods_for_approval;
token.approve(
    &subscriber,
    &contract_addr,
    &allowance,
    &expiration_ledger
);

2. Creates a subscription record:

let sub = Subscription {
    id: sub_id,
    plan_id,
    subscriber: subscriber.clone(),
    status: SubscriptionStatus::Active,
    next_billing_time: now + plan.period,
    periods_billed: 0,
    // ...
};

The allowance is calculated as price_ceiling * max_periods (or price_ceiling * 120 for unlimited plans). This is the maximum the contract could ever charge over the subscription lifetime. The subscriber's wallet displays this clearly before they sign.

The auth tree - one signature for two operations

This is the part that confuses most Soroban developers.

subscribe() requires subscriber.require_auth() for the contract call. Inside subscribe(), token.approve() also requires subscriber.require_auth(). That is two auth requirements for the same address in nested calls.

Soroban handles this with an auth tree. When Contract A calls Contract B, and both need auth from the same address, the wallet sees the full tree:

subscriber signs: -> vowena.subscribe(subscriber, plan_id) -> token.approve(subscriber, contract, allowance, expiry)

One tree. One signature. The wallet shows both the contract invocation and the token approval in a single confirmation dialog. The subscriber sees: "This transaction will subscribe you to Plan #1 and authorize up to 1,499 USDC of spending."

How charge() works

After the allowance is set, billing is fully automated. Here is the simplified flow:

pub fn charge(env: Env, sub_id: u64) -> bool {
    let sub = storage::get_sub(&env, sub_id);
    let plan = storage::get_plan(&env, sub.plan_id);
    let now = env.ledger().timestamp();

    // Is it active? Is it due?
    if sub.status != Active || now < sub.next_billing_time {
        return false;
    }

    // Pre-check balance and allowance
    let balance = token.balance(&sub.subscriber);
    let allowance = token.allowance(&sub.subscriber, &contract);

    if balance < plan.amount || allowance < plan.amount {
        sub.failed_at = now;
        return false;  // Graceful failure
    }

    // Pull the funds
    token.transfer_from(&contract, &sub.subscriber, &plan.merchant, &plan.amount);

    sub.periods_billed += 1;
    sub.next_billing_time += plan.period;
    return true;
}

Notice something critical: there is no require_auth() in charge(). This function is permissionless. Anyone can call it. The caller does not receive the funds - only the merchant does. This is by design.

The pre-check: why we look before we leap

The most important design decision in the entire contract is the balance and allowance check before transfer_from().

Why not just call transfer_from() and handle the error?

Because Soroban has no try/catch.

If transfer_from() fails (insufficient balance or allowance), the entire transaction reverts. Every state change the contract made up to that point is rolled back. The contract cannot record when the failure happened. It cannot emit an event. It cannot start the grace period timer.

By checking first:

  • We record failed_at with the exact timestamp
  • We emit a ChargeFailed event (indexable by any observer)
  • We return false instead of panicking
  • The grace period state machine begins

Without the pre-check, a failed charge would be completely invisible to the system. The subscriber would not know their payment failed. The merchant would not know revenue was missed. The keeper would not know to retry.

The grace period state machine

When a charge fails, the subscription does not immediately cancel. That would be hostile UX. Instead:

State 1: Active with failed_at set. The charge failed for the first time. The clock starts ticking on the grace period (configurable per plan, typically 30 days). Any subsequent charge() call retries the billing.

State 2: Paused. The grace period expired without a successful charge. Billing stops. The subscriber can call reactivate() to set a new allowance and resume.

State 3: Cancelled. The subscription remained paused for one more billing period. It auto-transitions to cancelled. This is irreversible.

At any point during States 1 or 2, if the subscriber funds their wallet, the next charge() call succeeds and billing resumes as if nothing happened. The failed_at timestamp is cleared.

What makes this different from traditional billing

| | Stripe | Vowena | |---|---|---| | Who holds the funds? | Stripe holds them temporarily | Funds stay in subscriber's wallet until charge | | Can the merchant overcharge? | Possible with saved card | Impossible - contract enforces plan amount | | What happens on failure? | Automatic retry with backoff | Grace period with permissionless retry | | Can the subscriber see the authorization? | Buried in terms | Displayed in wallet approval dialog | | Can a third party trigger billing? | No - only Stripe's systems | Yes - charge() is permissionless |

The fundamental difference is transparency. Every authorization, every charge, every failure is on-chain, auditable, and verifiable by anyone.

Start building

Install the SDK and try it yourself:

npm install vowena

Read the full API reference for every contract function. The source code is open and has 29 passing tests covering every edge case described in this post.