Documentation
Deploying
Everything you can do to a keeper deployment, on any of the three networks, and what each one costs you if you get it wrong.
The short version
algokit localnet start
fledge lanes run local # everything, against a chain
fledge run deploy-localnet # a keeper of your own
fledge run deploy-testnet # needs .env.testnet
fledge run govern -- status --network testnet --app-id <id>
Contributing without deploying anything
You do not need a chain to work on most of this.
poetry install # Python 3.12 or 3.13, never 3.14
bun install # the console and the js package
fledge lanes run ci # build, tests, spec drift, console
fledge lanes run ci is exactly what CI runs, task for task, so a green lane
locally means a green pull request. CONTRIBUTING.md covers the conventions
that will otherwise bite you, of which spec-sync is the one nobody expects.
For anything touching a chain, LocalNet is enough and costs nothing:
algokit localnet start
fledge lanes run local # the above plus the e2e and every demo
Deploying
| Task | Network | Needs |
|---|---|---|
fledge run deploy-localnet | LocalNet | algokit localnet start, nothing else |
fledge run deploy-testnet | TestNet | .env.testnet with DEPLOYER_MNEMONIC |
fledge run deploy-mainnet | MainNet | .env.mainnet, and ARCRON_ALLOW_MAINNET=1 |
Each one rebuilds from source, deploys, funds the app account's base minimum
balance, and then verifies the deployed bytecode against a clean build before
telling you it worked. It prints the app id and the combined sha256 in the
shape releases.md wants recorded.
MainNet needs a second, deliberate act. ARCRON_ALLOW_MAINNET=1 is set
nowhere in this repository, so a mistyped --network cannot reach real money.
MainNet is also gated behind the rc clock; deploying there before that is a
decision, not a command.
The contract now needs two program pages. It compiles to just over 2,048 bytes, so a deployment allocates an extra page, and each page costs the creator 100,000 microAlgos of minimum balance permanently. Budget 0.2 ALGO locked rather than 0.1, for as long as the app exists. This is create-only: extra pages cannot be added by an update, so a contract that outgrows its pages needs a new deployment.
Funding the base minimum balance matters more than it sounds. An app account below 100,000 µALGO cannot hold a box or escrow anything, and the failure reads as a minimum balance error somewhere unrelated. It is the most common way a fresh deployment looks broken.
Updating, and giving it up
A new deployment starts unfrozen: its creator can replace the programs. That is deliberate, and temporary.
fledge run govern -- status --network testnet --app-id 769891898
fledge run govern -- update --network testnet --app-id <id>
fledge run govern -- freeze --network testnet --app-id <id>
status reads frozen from global state, which anybody can do without
trusting anyone:
app 769891898
creator E5M2OH5XNDMNABJ6VOFOUVR2IKRPCGQH43PVC5P3DWQQ2LV2VJV2FJZQ3E
approval 2219 bytes
combined sha256 c94c6e0cc561c028eeb3ccdd8c462c509ee106a28ba2e1d61469adbb62ffe124
frozen 0: the creator can still replace the programs
Anyone escrowing here is trusting that they will not.
An app deployed before governance shipped prints frozen absent instead,
which means it has no update path at all and nobody can replace its programs.
Absent is the stronger guarantee, not a missing one.
update compiles this tree, refuses if the deployment is frozen, replaces the
programs, and then re-reads them to confirm what landed is what was sent.
freeze gives the update path up permanently. It prints the digest the app
will be stuck with and makes you type the app id back. There is no undo, and
nothing can add an update path afterwards, because the only call that could is
an update.
Whether to freeze at all is a choice, and both answers are ordinary on
Algorand. Checked on MainNet: the Foundation's randomness beacon, the Reti
staking validator and Folks Finance pools accept NoOp only and can never be
updated. Tinyman AMM v2, Pact and AlgoFi all handle UpdateApplication and
can be. There is no single convention to follow, so the useful thing is to
pick deliberately and say which you picked.
A deployment that never calls freeze behaves exactly like Tinyman or Pact:
its admin can update it whenever it needs to. One that calls freeze behaves
exactly like the beacon. govern status tells anyone which they are dealing
with, which is the part that actually matters.
Why unfrozen at all
An upgradeable keeper contract is one where somebody can change the rules after you have escrowed funds, and no statement of intent removes the fact that they could. That is a real cost, and the reason the flag exists rather than a permanent update path.
The other side is that being unable to fix a bug is expensive while nobody depends on the deployment yet. Two earlier deployments were abandoned rather than repaired, which stranded box minimum balance and made every creator cancel and re-register by hand.
So the update path is temporary by construction, readable on-chain, and given
up before the network asks anyone to rely on it. docs/security.md has the
full reasoning.
Multisig control
A single mnemonic on a single machine is the wrong home for a key that can
rewrite a live contract. The creator can be an Algorand multisig address
instead. Nothing in the contract changes: it compares Txn.sender against
Global.creator_address, and a multisig address is an address like any other.
What changes is that producing a signature takes several people.
Configure it in .env.<network>:
ARCRON_MULTISIG_THRESHOLD=2
ARCRON_MULTISIG_ADDRESSES=ADDR1,ADDR2,ADDR3
With that set, govern update and govern freeze stop signing and start
writing an unsigned transaction instead:
fledge run govern -- update --network testnet --app-id <id> --out update.json
# each holder: read it first, then sign, wherever their key lives
fledge run govern -- show --file update.json --app-id <id>
SIGNER_MNEMONIC="..." fledge run govern -- sign --file update.json --app-id <id>
# anyone, once the threshold is met
fledge run govern -- submit --file update.json --app-id <id>
show before sign, always. The file is base64 msgpack, so signing it
blind is signing whatever somebody put in it, and a multisig whose holders do
not read what they sign is one person who clicked several times. show
decodes it:
on complete UpdateApplication
REPLACES THE PROGRAMS with 2219 + 4 bytes
combined sha256 c94c6e0cc561c028eeb3ccdd8c462c509ee106a28ba2e1d61469adbb62ffe124
approval sha256 433a0418cf37e97376258a79277f05636400fa153c1fa3a0b86aba049071896a
clear sha256 ed90f0d2da1f1d1abd773c45230651a292a90edbc12a7bf859a493a12a640ce7
Compare the combined digest against `poetry run python -m scripts.verify_build`
on the commit you expect. Do not compare against `fledge run verify`, which
does not rebuild.
The combined digest is the one to compare, and it is what verify_build
records. An approval-only hash lets an honest approval ship beside a hostile
clear program: it matches on inspection, and after freeze it cannot be
replaced. This guide told you to compare the approval hash against a task
that does not rebuild, which hashed committed artifacts rather than a compile
of the tag you thought you had.
For a create, show names every field that is fixed forever, because none
of them can be corrected by an update:
CREATES A NEW APPLICATION. Every field below is permanent:
the creator is this sender and can never be changed
extra pages 1
global state 2 uints, 0 byte slices
local state 0 uints, 0 byte slices
Extra pages and schema cannot be changed by `update`, ever.
CARRIES PROGRAMS of 2104 + 4 bytes
sign does not stop at printing the digest. It rebuilds this tree and refuses
a file whose programs are not the ones the tree compiles to, because printing
a hash asks somebody to compare it and this is the comparison. Pass
--no-rebuild to skip that, and know what you are skipping.
and shouts about the two things that would otherwise pass as routine:
!! REKEYS the sender to FKWV...ARHZU. Do not sign unless you meant this.
!! CLOSES the sender to ... Do not sign unless you meant this.
A zero-amount payment that quietly rekeys the multisig is exactly the transaction somebody would wave through.
A signature is not a secret, so the file can be emailed, committed to a private gist, or carried on a stick. Only the mnemonics stay put. Submitting below the threshold is refused locally, and would be refused by the network anyway.
Creating a MainNet app from the multisig
Use govern create. Everything an application-create sets is permanent: the
creator cannot be changed, the state schema cannot be resized, and extra
program pages can be neither added nor removed. update replaces code and
nothing else, so none of it has a way back.
poetry run python -m scripts.govern create --network mainnet \
--expect-creator LUH77ATPWS4ZTCO7OZ3YM2DP5M2BXN53CHPFFQCFBATRFCYEB3NKTGMBNI
It refuses unless a multisig is configured and hashes to exactly the address
you typed, refuses an uncommitted working tree, rebuilds from source, reads
the state schema out of the compiled spec rather than trusting a hand-typed
number, computes the extra pages from the real program sizes, prints the
whole permanent checklist, and asks you to type the creator address back
before it writes anything. Then it writes an unsigned transaction, which
carries app id 0, so holders sign it with --app-id 0.
Do not use scripts/multisig_e2e.py to create anything you intend to keep.
It is a LocalNet proof, and it generates three throwaway keys, funds them, and
drops them when the process exits. Run against a real network it produces an
app whose creator nobody holds, while govern status goes on reporting that
the creator can still replace the programs.
fledge run smoke-multisig proves the whole flow on LocalNet: a 2 of 3 creates
the app, one signature is rejected by the network, two are accepted, one holder
alone cannot update, and a different pair can.
scripts/deploy.py refuses to run when a multisig is configured, rather than
quietly deploying from the single-key DEPLOYER and leaving a contract whose
creator is not the multisig anyone was told to expect.
Not every Algorand account can be a member. A multisig subsignature is an ed25519 public key and an ed25519 signature, so a member has to be an account an ed25519 key can sign for. A post-quantum Falcon account's address is a hash rather than a point on the curve, so no such key exists and it can never produce a subsignature.
Nothing complains on its own. The address derives normally and the result
reads as a threshold of N while behaving as a threshold of N out of one fewer
signer, which silently changes who can act alone. scripts/multisig.py
refuses such a member rather than letting it through, and the check is a real
curve-membership test: about half of all 32-byte values happen to be valid
points, so a weaker check would pass some Falcon addresses and reject others,
which is worse than not checking.
Which holders, and how many. Three keys with a threshold of two is the
usual shape: any one can be lost without losing control, and any one can be
compromised without losing the contract. Keep them on different devices held by
different people; keys in one drawer are one key. MainNet uses three keys with a threshold of two (LUH77ATPWS4ZTCO7OZ3YM2DP5M2BXN53CHPFFQCFBATRFCYEB3NKTGMBNI); the LocalNet smoke test uses three with a threshold of two because it only has to prove the mechanism. Member order is part of the address, so the same keys in a different order are a different account holding nothing.
Checking a deployment you did not make
poetry run python -m scripts.verify_build --network testnet --app-id <id>
poetry run python -m scripts.govern status --network testnet --app-id <id>
The first compares compiled bytecode against a clean build of a given commit, so it answers "is this app really that source". The second answers "can its creator still change it". Together those are the whole trust question, and neither requires believing anything written here.
Continuous integration
.github/workflows/ci.yml runs the ci lane on every push and pull request,
and the LocalNet end-to-end on pushes. Pull requests from forks run the same
tasks on GitHub's own infrastructure with no secrets, because a self-hosted
runner executes whatever a workflow says.
Every CI step shells out to fledge.toml, so CI and a local run cannot drift.
Deployment is not automated, on any network. A deploy is a decision with a
permanent consequence, and verify_build exists so that decision can be
audited afterwards rather than trusted in advance.