npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@d13co/puya-ts-utils

v0.2.0

Published

Algorand TypeScript subroutines: creating escrow accounts, reading the transaction counter, RSA signature verification, MBR credit accounting, base32 encoding

Readme

@d13co/puya-ts-utils

Algorand TypeScript subroutines for things the AVM will not hand your contract directly: keyless accounts, the network transaction counter, RSA signature verification, MBR credit accounting, and base32 encoding.

import { createFundedAccount, createUnfundedAccount } from '@d13co/puya-ts-utils/createAccount'
import { getTxnCounter } from '@d13co/puya-ts-utils/getTxnCounter'
import { parseRsaDnskey, verifyRsaSha256 } from '@d13co/puya-ts-utils/rsa'
import { MbrManager } from '@d13co/puya-ts-utils/mbrManager'
import { base32Encode, encodeAddress } from '@d13co/puya-ts-utils/base32'

Each utility is imported from its own subpath. There is no root import: the Puya compiler rejects re-export barrels, so a single entry point cannot serve more than one of them.

Contents

Install

npm install @d13co/puya-ts-utils

For the contract subpaths, @algorandfoundation/algorand-typescript >=1.3.0 <2 is a peer dependency, and you need @algorandfoundation/puya-ts 1.3.0 or later to compile. The peer is optional, so an off-chain project that only uses the SDK subpaths does not need it.

The contract subpaths (createAccount, getTxnCounter, rsa, mbrManager, base32) ship as TypeScript source only: the Puya compiler consumes the source and turns it into TEAL along with your own contract, so require()-ing them from Node will not work and is not meant to.

The off-chain subpaths (rsaHint, rsaSdk, mbrManagerSdk) are ordinary compiled JavaScript, CommonJS and ESM, with type declarations. They need algosdk and @algorandfoundation/algokit-utils, and rsaSdk needs Node.

Unit testing

@algorandfoundation/algorand-typescript-testing runs contracts as JavaScript, and it recognises a contract class only if it extends the same Contract your own contracts do. Version 1.2.0 pins @algorandfoundation/algorand-typescript to 1.2.0, which lands a second copy in the tree and quietly breaks that check with Cannot create a contract for class as it does not extend Contract. Pin the version down to one copy:

{
  "pnpm": {
    "overrides": {
      "@algorandfoundation/algorand-typescript": "1.3.0"
    }
  }
}

In a pnpm workspace, the override goes under overrides: in pnpm-workspace.yaml instead.

The testing transformer also has to reach this package's source, which the stock AlgoKit vitest.config.mts does not guarantee. @rollup/plugin-typescript only transforms files under your project directory by default, and Vite resolves symlinks to real paths. In a pnpm workspace the package's real path is in <workspace root>/node_modules/.pnpm/…, outside your project, so the source is compiled without the transformer, and the test fails to load with This method is intentionally not implemented … check the configuration of your test transformer. Add the three lines marked below to the template's config: they widen the filter to take in this package, and nothing else from node_modules.

import { puyaTsTransformer } from '@algorandfoundation/algorand-typescript-testing/vitest-transformer'
import typescript from '@rollup/plugin-typescript'
import { defineConfig } from 'vitest/config'

export default defineConfig({
  esbuild: {},
  test: {
    setupFiles: ['vitest.setup.ts'],
  },
  plugins: [
    typescript({
      tsconfig: './tsconfig.test.json',
      include: ['**/*.ts', '**/*.mts'], // added
      exclude: [/^(?!.*\/node_modules\/@d13co\/puya-ts-utils\/).*\/node_modules\//], // added
      filterRoot: false, // added
      transformers: {
        before: [puyaTsTransformer],
      },
    }),
  ],
})

pnpm run test:pack checks this: it installs the packed package into a scratch pnpm workspace and runs a unit test there with the config above.


createAccount

Mints keyless accounts — ordinary Algorand addresses whose private key was never generated, and which answer only to your contract.

What it does

Calling either subroutine creates an application and deletes it again inside a single inner application call. While that application briefly exists it rekeys its own escrow to your contract. When the call returns, the application is gone but its address survives as a plain account — one nobody holds a key for — signed over to you.

Never close the account out. A payment with closeRemainderTo deletes the account record, auth address included, and with no key and no application left to sign for it, nothing can authorise that address again. Anything sent to it afterwards is lost for good. Empty it with a plain payment down to its minimum balance instead.

Usage

Both subroutines are called like any other, from anywhere inside a contract:

The example below has no access control. Anyone can call spend to drain the minted account, or call open/openFrom repeatedly to spend the application's balance on new accounts and overwrite escrow, stranding the previous one. Gate these methods (e.g. assert(Txn.sender === Global.creatorAddress)) before using this pattern for real.

import { createFundedAccount, createUnfundedAccount } from '@d13co/puya-ts-utils/createAccount'
import { Account, Contract, Global, GlobalState, itxn, uint64 } from '@algorandfoundation/algorand-typescript'

export class Vault extends Contract {
  escrow = GlobalState<Account>({ key: 'escrow' })

  /** Mint an account and settle its minimum balance yourself. */
  public open(): Account {
    const account = createUnfundedAccount()

    itxn.payment({ receiver: account, amount: Global.minBalance, fee: 0 }).submit()

    this.escrow.value = account
    return account
  }

  /** Or let the subroutine settle it, out of an account you can send from. */
  public openFrom(fundingAccount: Account): Account {
    this.escrow.value = createFundedAccount(fundingAccount)
    return this.escrow.value
  }

  /** Either way, the minted account is yours to spend from. */
  public spend(receiver: Account, amount: uint64): void {
    itxn.payment({ sender: this.escrow.value, receiver, amount, fee: 0 }).submit()
  }
}

API

createUnfundedAccount(): Account

Mints the account and hands it back empty.

An account carrying an auth address owes the 0.1 ALGO minimum balance, and this one has nothing yet, so you have to fund it before your call returns. There is no rush within the call itself: minimum balances are checked once, when the top-level application call ends, rather than after each inner transaction, so the account is free to sit below its minimum in between.

Costs 2 inner transactions — the application call and the rekey — both submitted with fee: 0, so cover them out of the fee pool.

createFundedAccount(fundingAccount: Account): Account

The same, plus a payment of Global.minBalance from fundingAccount, so the account comes back ready to use.

fundingAccount has to be one your contract can send from: either its own escrow (Global.currentApplicationAddress) or an account rekeyed to it. Anything else is rejected by the AVM when the payment is submitted.

Costs 3 inner transactions.

CreateAccount

The contract behind both subroutines, exported for tests and tooling. You never deploy it — the subroutines create and delete it on demand — but you may want it on hand to stub the inner call in unit tests:

import { CreateAccount } from '@d13co/puya-ts-utils/createAccount'

const spy = new ApplicationSpy(CreateAccount)
spy.on.createAccount((itxnContext) => itxnContext.setReturnValue(someAccount))
ctx.addApplicationSpy(spy)

getTxnCounter

Reads the network transaction counter — the number the next transaction on the network will be given. Side-effect: increments the transaction counter by 1.

What it does

Every application on Algorand is numbered out of one ledger-wide counter that advances by one for each transaction, inner transactions included. So an application created right now is handed the counter's current value.

getTxnCounter creates a throwaway application and deletes it in the same inner transaction, purely to see which id it was given, and returns the value one past it. No application is left behind; the only trace is the counter itself, one further on.

The application it creates is just a three-byte always-approve program 0x0a8101 (#pragma version 10, pushint 1.) It never runs anything; it only needs to exist long enough to be numbered.

Usage

import { getTxnCounter } from '@d13co/puya-ts-utils/getTxnCounter'
import { Account, Contract, Global, GlobalState, uint64 } from '@algorandfoundation/algorand-typescript'

export class Ticket extends Contract {
  issued = GlobalState<uint64>({ key: 'issued' })

  /** Read the counter, and let the caller pay for it out of the fee pool. */
  public issue(): uint64 {
    // Only "next" until something else runs: any later inner transaction, or
    // a later transaction in the group, takes this number first.
    this.issued.value = getTxnCounter(Global.zeroAddress)
    return this.issued.value
  }

  /** Or charge it to an account this contract can send from. */
  public issuePaidBy(feePayer: Account): uint64 {
    // feePayer must be rekeyed to this contract's escrow address.
    // WARNING: as written, anyone can call this and spend feePayer's balance,
    // one minimum fee per call. Check the caller before trusting feePayer.
    this.issued.value = getTxnCounter(feePayer)
    return this.issued.value
  }
}

API

getTxnCounter(feePayer: Account): uint64

Returns the id one past the application it created, which is what the next transaction on the network will be numbered.

That only holds until something else runs. Any inner transaction your contract sends afterwards, or any later transaction in the same group, takes the number first. Read the counter last if you need it to match what comes next.

feePayer decides who covers the one inner transaction this costs:

  • Global.zeroAddress submits it with fee: 0, sent from your application's own escrow. Nothing is spent, and the caller covers it out of the fee pool.
  • any other account submits it with fee: Global.minTxnFee, sent from that account, which pays the fee out of its own balance.

A feePayer other than the zero address has to be an account your contract can send from: either its own escrow (Global.currentApplicationAddress) or an account rekeyed to it. Anything else is rejected by the AVM when the inner transaction is submitted.

Costs 1 inner transaction either way.

There is no contract class behind this one, so there is nothing to stub with a typed ApplicationSpy. To fix the counter in a unit test, catch the bare create and fill in the application it would have been given:

const spy = new ApplicationSpy()
spy.onBareCall([OnCompleteAction.DeleteApplication], (itxnContext) => {
  Object.assign(itxnContext, { createdApp: someApplication })
})
ctx.addApplicationSpy(spy)

ApplicationSpy.onBareCall is overloaded, and the Puya test transformer rejects any function type with more than one call signature, so this has to live in a plain .ts file rather than in your .algo.spec.ts. See smart_contracts/txn_counter/stub-probe.ts for the worked version.


rsa

Experimental. RSA verification is new and has not been audited, and its API may change in a minor release. Review it yourself before it guards real value.

Verifies RSASSA-PKCS1-v1_5 signatures — DNSSEC algorithms 8 (RSASHA256) and 10 (RSASHA512) — for keys up to 512 bytes, with exponents up to 4 bytes.

What it does

The AVM has no modular exponentiation, and its byte math takes operands of at most 64 bytes. So the signature is raised to the exponent with multi-precision Montgomery arithmetic over 512-bit limbs.

The expected block, 00 01 FF..FF 00 || DigestInfo || digest, is then built in full and compared byte-for-byte with the result. The decrypted block is never parsed, since a lenient parser is what lets a forgery through when e = 3.

Everything is pure: all input is passed in as bytes, and nothing reads state or boxes. That means the same code compiles into a contract or a logic signature.

It is tested against Wycheproof's RSASSA-PKCS1-v1_5 vectors for 2048-bit keys with SHA-256 and SHA-512: 518 cases, where only the valid signatures verify.

Program size

Measured by compiling a contract with one ABI method around each, minus the same contract without the RSA code:

| What the contract calls | Bytes added | |---|---| | verifyRsaSha256 | 1.45 KB | | verifyRsaSha256 and verifyRsaSha512 | 1.6 KB | | rsaStart, rsaStep and rsaFinish | 1.6 KB |

An app's approval program gets 2 KB per page, with up to 3 extra pages: 8 KB in all, shared with the clear program. So RSA takes most of a one-page app. Plan on one extra page (extraProgramPages: 1) once your own code is in.

The logic signature example compiles to 1.5 KB. Logic signatures get 1 KB per transaction, pooled across the group, so size alone needs a group of 2. Budget needs more.

Usage

The subroutines take a digest, not the signed data, so hashing is up to you. For a DNSSEC RRSIG, hash the RRSIG rdata without its signature field, followed by the RRset in canonical form:

import { parseRsaDnskey, verifyRsaSha256 } from '@d13co/puya-ts-utils/rsa'
import { assert, Global, LogicSig, op, TransactionType, Txn } from '@algorandfoundation/algorand-typescript'

/**
 * arg 0: SHA-256 of the signed data, arg 1: RRSIG signature,
 * arg 2: DNSKEY public key field, arg 3: Montgomery hint, or empty
 */
export class RrsigVerifier extends LogicSig {
  program(): boolean {
    // Approve only an inert transaction, and say in its note what was checked.
    assert(
      Txn.typeEnum === TransactionType.Payment &&
        Txn.amount === 0 &&
        Txn.fee === 0 &&
        Txn.rekeyTo === Global.zeroAddress &&
        Txn.closeRemainderTo === Global.zeroAddress,
    )
    assert(Txn.note === op.sha256(op.arg(2)).concat(op.arg(0)))
    const [exponent, modulus] = parseRsaDnskey(op.arg(2))
    return verifyRsaSha256(op.arg(0), op.arg(1), modulus, exponent, op.arg(3))
  }
}

The Montgomery hint

Montgomery form needs one constant per key, R² mod n, where R = 2^(512·k) and k is the modulus length in 64-byte limbs, rounded up. Working it out on chain is about 50k of the RSA-2048 budget. For a modulus that does not fill its top limb, such as RSA-1280 or a 2047-bit key, it costs far more.

It depends only on the key, so the prover can compute it off chain and pass it in as hint: R² mod n, left-padded to k limbs, followed by the quotient ⌊R / n⌋, which the check needs. Checking a hint costs about a tenth of computing it. @d13co/puya-ts-utils/rsaHint computes it off chain:

import { rsaMontgomeryHint } from '@d13co/puya-ts-utils/rsaHint'

const hint = rsaMontgomeryHint(modulus) // Uint8Array

A hostile hint can only make the call fail, never make a signature verify. The hint comes from whoever submits the transaction, so it's untrusted. Its first part is used only once the contract has proved it equals R² mod n. For h below n, h·R⁻¹ mod n is R mod n only when h is R² mod n, and q·n + (h·R⁻¹ mod n) = R holds only when it is R mod n. Any other h or q fails an assert. A hint that passes is the value the contract would have computed itself, so the result is the same with it or without it.

Pass empty bytes instead to have R² mod n computed on chain.

Opcode budget

One verification costs far more than the 700 a single application call gets. Measured on LocalNet:

| Key | Budget, with hint | Budget, R² computed | |---|---|---| | RSA-2048, e = 3 | 20k | 65k | | RSA-1024, e = 65537 | 29k | 41k | | RSA-1280, e = 65537 | 53k | 292k | | RSA-2047, e = 65537 | 90k | — | | RSA-2048, e = 65537 (the root zone KSK) | 92k | 139k | | RSA-3072, e = 65537 | 189k | 285k | | RSA-4096, e = 65537 | ~320k: split over groups, below | — |

The cheapest host is a logic signature. Logic signature budget is 20,000 per transaction, pooled across the whole group, and program size pools the same way. The root KSK check passes in a group of 5 transactions with the hint, or 7 without it, and the other transactions can be plain zero-amount payments.

The args, the key among them, come from whoever submits the transaction, and an application can't read them. So a verified signature on its own proves only that some key signed some digest. That's what the note is for: an application confirms the check by matching gtxn N Sender against the verifier's address and gtxn N Note against sha256(trusted key) ‖ digest. The transaction checks keep the verifier's address from being rekeyed or drained by anyone who can produce a valid signature with a key of their own.

Hosted in an application instead, the budget has to come from OpUp inner transactions. At 700 each, RSA-2048 needs about 130 of them.

RSA-4096: across several groups

RSA-4096 needs more budget than one group can pool, so the check comes in pieces as well. rsaStart validates the input and sets up Montgomery form, and returns a state. rsaStep processes a number of exponent bits at a time. rsaFinish compares the result with the expected block. rsaPkcs1v15Verify is those three calls in a row.

Keep the state in a box between groups: 2136 bytes for RSA-4096. That rules out a logic signature host, since it cannot read or write boxes. The steps trust the state: whoever can write it can make anything verify. Keep it where only your contract writes it, one per caller, so no one can overwrite a verification in progress. rsaFinish takes the key again and asserts that the state was started with it, so a caller can't start with a key of their own and have the result pass for yours.

Each box costs about 0.87 ALGO in minimum balance. If the app paid for it, anyone could drain it by starting verifications and never finishing them. The example extends MbrManager so the sender pays out of their MBR credits, and finish refunds them.

import { rsaBitsLeft, rsaFinish, rsaStart, rsaStep, SHA256_DIGEST_INFO } from '@d13co/puya-ts-utils/rsa'
import { MbrManager } from '@d13co/puya-ts-utils/mbrManager'
import { Account, BoxMap, bytes, Global, Txn, uint64 } from '@algorandfoundation/algorand-typescript'

export class Verifier extends MbrManager {
  state = BoxMap<Account, bytes>({ keyPrefix: 'rsa' })

  public start(signature: bytes, modulus: bytes, exponent: bytes, hint: bytes): void {
    const mbrBefore = Global.currentApplicationAddress.minBalance
    this.state(Txn.sender).delete()
    this.state(Txn.sender).value = rsaStart(signature, modulus, exponent, hint)
    this.manageMbrCredits(mbrBefore)
  }

  public step(bits: uint64): uint64 {
    const state = this.state(Txn.sender)
    state.value = rsaStep(state.value, bits)
    return rsaBitsLeft(state.value)
  }

  public finish(digest: bytes, modulus: bytes, exponent: bytes): boolean {
    const valid = rsaFinish(this.state(Txn.sender).value, digest, modulus, exponent, SHA256_DIGEST_INFO)
    const mbrBefore = Global.currentApplicationAddress.minBalance
    this.state(Txn.sender).delete()
    this.manageMbrCredits(mbrBefore)
    return valid
  }
}

For RSA-4096 with a hint and e = 65537, measured on LocalNet:

| Piece | Budget | |---|---| | rsaStart | 35k | | rsaStep, per bit | 17k for each clear bit, 33k for the last |

That comes to about 320k.

An application group pools at most about 190k: 700 for each of 16 app calls, plus 700 for each of 256 inner OpUp transactions. Two groups are enough:

  • start plus 8 bits
  • the last 8 bits plus finish

smart_contracts/rsa/consumer.algo.ts has the worked version, RsaSplitConsumer.

API

parseRsaDnskey(publicKey: bytes): [bytes, bytes]

Splits the public key field of an RSA DNSKEY record (RFC 3110) into [exponent, modulus]. It reads the one-byte exponent length and the three-byte form alike, and asserts that a modulus follows.

verifyRsaSha256(digest, signature, modulus, exponent, hint): boolean

verifyRsaSha512(digest, signature, modulus, exponent, hint): boolean

Verify a signature over a 32-byte SHA-256 or 64-byte SHA-512 digest. Computing the digest with op.sha512 needs AVM 13.

rsaPkcs1v15Verify(digest, signature, modulus, exponent, digestInfo, hint): boolean

The same, with the DER DigestInfo prefix supplied by you: SHA256_DIGEST_INFO and SHA512_DIGEST_INFO are exported.

rsaStart(signature, modulus, exponent, hint): bytes

rsaStep(state, bits): bytes

rsaBitsLeft(state): uint64

rsaFinish(state, digest, modulus, exponent, digestInfo): boolean

rsaPkcs1v15Verify in pieces, for a check that spans groups. rsaFinish asserts if the state has bits left, or was started with a key other than modulus and exponent. Pass the key you trust there, not one taken from the caller's state.

All three return false for a well-formed signature that does not match. They assert on malformed input:

  • a modulus that is over 512 bytes, even, has a leading zero byte, or is too short to hold the block
  • a signature whose length differs from the modulus, or that is not below it
  • an exponent that is over 4 bytes, even, or below 3
  • a Montgomery hint that is wrong, the wrong length, or whose R² mod n is not below the modulus

Each assert's message is an error code, such as ERR:BADHINT, listed with its meaning in src/rsaErrors.algo.ts. They are plain asserts, not loggedAssert, because a logic signature cannot log. In an app, algokit reports the code from the ARC-56 source info. For a logic signature, the SDK below maps the failing pc back to it.

rsaMontgomeryHint(modulus: Uint8Array): Uint8Array — off chain

From @d13co/puya-ts-utils/rsaHint: the hint for modulus. It's plain TypeScript with no AVM types, for your client or prover.

RSA SDK — off chain

@d13co/puya-ts-utils/rsaSdk drives the example verifiers in smart_contracts/rsa. It needs algosdk and @algorandfoundation/algokit-utils.

  • RsaVerifierSDK runs the RsaSha256Verifier logic signature, with its bytecode built in. verify({ digest, signature, publicKey, hint? }) simulates once to find the smallest group that pools enough budget, then sends it. The writer pays every fee.
  • RsaSplitSDK drives an RsaSplitConsumer app, the RSA-4096 verifier above. It provides depositCredits({ amount }) and run([{ start }, { step }, { finish }]). Each run sends one group, padded with pool calls, and returns each call's result. credits(accounts) reads MBR credits by simulating, with no signer.

RsaSplitConsumerFactory is exported too, with the app's compiled programs built in, so you can deploy your own RsaSplitConsumer without its source:

import { RsaSplitConsumerFactory, RsaSplitSDK } from '@d13co/puya-ts-utils/rsaSdk'

const factory = algorand.client.getTypedAppFactory(RsaSplitConsumerFactory, { defaultSender: sender })
const { appClient } = await factory.deploy()
// The app account's own minimum balance.
await algorand.send.payment({ sender, receiver: appClient.appAddress, amount: AlgoAmount.Algo(0.1) })

const sdk = new RsaSplitSDK({ algorand, appId: appClient.appId, writerAccount: { sender, signer } })

Both register errorTransformer, which rewrites any rejection carrying a code as Error BADHINT: Montgomery hint is wrong, and sets code and description. A logic signature rejection only reports a pc, so the transformer first looks that pc up in the verifier's assembly. npm run build regenerates the bytecode, the pc map, the error messages and the client in src/generated, and npm run build:sdk compiles the off-chain subpaths to dist (packing runs it too).


mbrManager

An abstract contract that makes callers pay for the boxes they create. Each account deposits MBR credits up front. When a method changes the app account's minimum balance, the difference is charged to the sender's credits, or refunded to them if it went down.

What it does

The AVM checks the minimum balance only when an app call ends, so a method may go over it partway through. Snapshot minBalance before the state changes, then call manageMbrCredits afterwards. It charges the exact difference, and you never have to work out box sizes by hand.

Usage

import { MbrManager } from '@d13co/puya-ts-utils/mbrManager'
import { Account, BoxMap, bytes, Global, Txn } from '@algorandfoundation/algorand-typescript'

export class Registry extends MbrManager {
  entries = BoxMap<Account, bytes>({ keyPrefix: 'e' })

  public put(value: bytes) {
    const mbrBefore = Global.currentApplicationAddress.minBalance
    this.entries(Txn.sender).value = value
    this.manageMbrCredits(mbrBefore)
  }
}

Growing or shrinking a value in place is charged or refunded too, at 400 µALGO per byte.

Credit boxes are named 'c' + the account's 32-byte public key. Keep your own box prefixes distinct from that.

Warning: users own their boxes

MbrManager is built for a model where each box belongs to one account, and only that account's calls create, resize or delete it. Key your boxes by Txn.sender, as above, or store an owner and assert it.

Mixed ownership breaks down. A refund goes to whoever triggers the decrease, not to whoever paid for the box. If any account can delete a box another account paid for, the deleter collects the refund. Use settleMbrCredits to refund the owner instead.

Entries can get stuck, including shared ones. A refund needs a credit box to land in (rcv). So an account that has called withdrawCredits can't delete its remaining boxes until it deposits again, which costs another 18 900 µALGO for the credit box (it gets that back on its next withdrawal). The same goes for a box shared between accounts: whoever deletes it must have a credit box. Clean up boxes before withdrawing.

API

depositCredits(creditor: Account, txn: gtxn.PaymentTxn) — ABI

Credits txn.amount to creditor, who can be any account, not just the sender. The creditor's credit box costs 18 900 µALGO, and a first deposit pays for it out of the deposit itself. Fails with rcv if the payment does not go to the app, amt if the amount is zero, and crd if a first deposit is too small to pay for the box.

withdrawCredits() — ABI

Pays the sender's credits back to them, plus the MBR freed by deleting their credit box. The inner payment has zero fee, so send extraFee: 1000. Fails with amt if the sender has no credit box. Refunds for boxes deleted after this fail until the sender deposits again: see the warning above.

logCredits(accounts: Account[]) — ABI, readonly

Logs each account's balance as a big-endian uint64, in input order. An account with no credit box logs an empty line. Simulate it with allowMoreLogging to read many balances in one call.

manageMbrCredits(mbrBefore: uint64) — protected

Charges the MBR increase since mbrBefore to the sender's credits (crd if they do not have enough), or refunds a decrease to them (rcv if they have no credit box). Call it last, after the state changes.

settleMbrCredits(account: Account, mbrBefore: uint64) — protected

The same as manageMbrCredits, but it charges or refunds account instead of the sender.

Errors are raised with loggedAssert, so they show up as ERR:crd, ERR:rcv and ERR:amt.

Off chain: mbrManagerSdk

@d13co/puya-ts-utils/mbrManagerSdk works with any app built on MbrManager. It builds calls from the ABI signatures above rather than a typed client, so it does not care what else the app does. It needs algosdk and @algorandfoundation/algokit-utils.

import { addDepositCredits, addWithdrawCredits, getAllCredits, getCredits } from '@d13co/puya-ts-utils/mbrManagerSdk'

const app = { algorand, appId }
const group = algorand.newGroup()
await addDepositCredits(group, { ...app, sender, amount: AlgoAmount.MicroAlgo(100_000) })
await group.send()

const [credits] = await getCredits(app, [sender]) // bigint, or undefined with no credit box
  • addDepositCredits(composer, { algorand, appId, sender, signer?, amount, creditor? }) adds the payment and the depositCredits call to composer, with the creditor's credit box referenced. It is async, since building the payment fetches suggested params, so await it. A first deposit below CREDIT_BOX_MBR_MICROALGOS (18 900) reverts with ERR:crd.
  • addWithdrawCredits(composer, { appId, sender, signer? }) adds the withdrawCredits call, with the sender's credit box referenced and 1000 µALGO extra fee for the inner refund.
  • getCredits({ algorand, appId, reader?, concurrency? }, accounts) simulates logCredits, 63 accounts to a call and two calls to a group, up to concurrency groups at once (2 by default). It returns each balance in input order, undefined for an account with no credit box, and needs no signer.
  • getAllCredits({ algorand, appId, reader?, concurrency? }) finds every credit box by name and reads them all, as a Map of address to balance.
  • creditBoxName(account) is 'c' and the 32-byte public key. MbrErrorMessages holds the three codes and their messages.

The add… helpers only add to the group you pass in and never send it, so you can put your own calls, or an op-up, around them.


base32

Experimental. base32 encoding is new and has not been audited, and its API may change in a minor release. Review it yourself before it guards real value.

Encodes bytes as base32 (RFC 4648) without the trailing =: the form Algorand uses for addresses, transaction ids and block hashes. encodeAddress writes an account out as its 58-character address, checksum included.

What it does

Every five bytes become eight characters. A tail shorter than five bytes is padded out with zero bits, and the = RFC 4648 would add on top of that is dropped, so n bytes encode to ceil(n * 8 / 5) characters. Empty input encodes to an empty string.

The result is a string only in the sense that the AVM has no separate character type. It is the same byte sequence either way, and Bytes(...) takes it back if you need to join it to binary data.

Usage

import { base32Encode, encodeAddress } from '@d13co/puya-ts-utils/base32'
import { Account, bytes, Contract, ensureBudget, OpUpFeeSource } from '@algorandfoundation/algorand-typescript'

export class Directory extends Contract {
  public encode(data: bytes): string {
    return base32Encode(data) // up to 30 bytes on one call's budget
  }

  public address(account: Account): string {
    // 36 bytes (key + checksum) costs 840, so buy another 700 opcodes first.
    ensureBudget(1200, OpUpFeeSource.GroupCredit)
    return encodeAddress(account) // 58 chars
  }
}

GroupCredit takes the inner call's fee out of the group's excess, so the caller covers it with extraFee.

Cost

About 94 opcodes per five bytes. Measured on LocalNet for a whole ABI call that takes the bytes and returns the encoding, which spends about 88 on its own:

| Input | Budget (about) | |---|---| | 10 bytes | 276 | | 20 bytes | 464 | | 30 bytes | 652 | | 35 bytes | 746 | | 36 bytes | 840 |

An application call starts with 700, so that call encodes up to 30 bytes without help. Inside a method that does more, the headroom is smaller. Anything longer needs the budget raised, and the subroutine won't raise it for you: that spends an inner transaction and its fee, which is the caller's call.

The AVM caps a byte value at 4096 bytes, and the cap applies to the encoding too, so inputs above 2560 bytes can't be encoded at all. Returning the result from an ABI method logs it, and a log line is capped at 1024 bytes, so a returned encoding stops at about 636 bytes of input.

API

base32Encode(data: bytes): string

Returns data in unpadded base32. Adds no checksum: 32 bytes give 52 characters.

encodeAddress(account: Account): string

Returns the account's 58-character address: the public key plus the last four bytes of its sha512_256 digest, encoded as base32. Costs about 840 opcodes, so raise the budget first.


Fees

None of these subroutines pays for itself, with one exception: getTxnCounter given a real feePayer charges that account directly, and needs nothing extra from the caller.

Everywhere else, budget the inner transactions into the fee your caller sends — from an off-chain client that is extraFee:

await appClient.send.open({ args: [], extraFee: AlgoAmount.MicroAlgo(2000) })   // createUnfundedAccount
await appClient.send.issue({ args: [], extraFee: AlgoAmount.MicroAlgo(1000) })  // getTxnCounter
await appClient.send.withdrawCredits({ args: [], extraFee: AlgoAmount.MicroAlgo(1000) }) // mbrManager
await appClient.send.encodeAddress({ args: { account }, extraFee: AlgoAmount.MicroAlgo(1000) }) // base32, with ensureBudget

Development

This repository is an AlgoKit workspace. The package lives in projects/puya-ts-utils; everything it publishes is in src/, and the rest of the project is the test bench around it.

algokit project bootstrap all   # install dependencies
pnpm run build                  # compile contracts to TEAL and generate clients
pnpm run test:unit              # algorand-typescript-testing suite
pnpm run test:e2e               # LocalNet suite (needs `algokit localnet start`)
pnpm run test:pack              # the packed package, installed in a scratch workspace
pnpm run check-types

src/ is the whole library: one file per utility, one utility per import subpath. Each has a worked example under smart_contracts/ that the suites drive — create_account/consumer.algo.ts, txn_counter/consumer.algo.ts, rsa/consumer.algo.ts and base32/consumer.algo.ts under both, mbr_manager/consumer.algo.ts under e2e only.

License

MIT