Private money you can send as a link, to someone who has never heard of Starknet. An AI agent can pay another agent that has no wallet and no address.
Envelope is a STRK20 anonymizer contract and app. You shield tokens, seal an amount into an envelope, and hand over a URL. A pool-funded envelope can only be opened into the recipient's shielded balance. An envelope funded from a normal wallet keeps the fallback that pays any Starknet address.
The pool hides who paid. A recipient opening a pool-funded envelope needs a registered STRK20 wallet because that route is private-claim-only. Normal-wallet funding retains the no-registration public-address fallback.
shield → seal an envelope → send a link → claimed
(pool hides you) (never hits (into a private note,
a server) never a public address)
An agent can pay another agent that has no wallet, no address, and no account with anyone. That is not a framing of the product, it is the property the contract has: an envelope is sealed against a key that exists only in a link, so the payee is decided by whoever holds the link rather than by an address chosen in advance. There is nothing to look up, nothing to register, and no exchange of identifiers before value can move.
npm install -g strk20-envelope-cliNothing works until this exists. Create a file called .env.local in your
project and put three variables in it:
STARKNET_ACCOUNT=0x… # the account that signs
STARKNET_PRIVATE_KEY=0x… # its key
ENVELOPE_NETWORK=sepolia # or mainnet
No export, no quotes needed, though both are tolerated if you paste them. The
file is found on its own: the directory you run in, then its parents up to the
repository root, then one level down, which is where a framework tends to leave
one. Exported shell variables work too and always win over a file, which is how
a container should inject a key.
envelope whoami prints which file it read, so an agent is never signing with a
key it cannot account for.
envelope seal --amount 1 --expiry 1h # returns a link
envelope open "<link>" # takes the contents
envelope status "<link>" # funded, claimed, expired
envelope whoami # this account, and its limitsThat is the whole setup: no browser, no wallet extension, no popup to click. The output shape follows its destination, prose to a terminal and JSON to a pipe, so a caller never parses sentences.
LINK=$(envelope seal --amount 1 | jq -r .claimLink)That variable is now a bearer payment. It travels through anything an agent already has: a webhook, a message, a tool result, a return value. The receiving agent needs no relationship with the sender, only the string.
Round trip on Sepolia, end to end:
sealed and
claimed,
after which the contract reports claimed and refuses a second claim.
--dry-run builds and prints a transaction without signing it, which is the
flag to reach for before handing an agent an account that spends on its own.
Details in packages/envelope-cli/ and on the
agent page.
An agent signs with an account key, which reaches every part of this that an
ordinary Starknet account can reach. Proving a STRK20 action needs a wallet, so
funding through the pool, claiming into a shielded balance and reclaiming an
expired envelope stay in the app. An agent-sealed envelope is still claimable
into a shielded balance by a recipient who has a STRK20 wallet, so the reach and
the privacy compose rather than exclude each other. envelope whoami prints
that division beside the account in use.
A STRK20 private transfer needs a registered recipient: someone who has set a viewing key on-chain and can be sent an encrypted note. That is fine between two people already inside the pool, and useless for the case that actually matters: paying a contributor, a bounty winner, or a friend who has never used Starknet.
The documented way around it is an escrow keyed by a hash commitment: park value
against poseidon(secret), release it to whoever presents the preimage. That
construction is front-runnable. The preimage travels in public calldata, so
anyone watching the mempool can lift it, resubmit first, and take the money. The
reference escrow helper in the STRK20 docs
has exactly this shape.
Envelopes commit to a stark-curve public key instead. Releasing requires a
signature over (anonymizer, mode, envelope, destination), so the authorisation
is welded to one destination. A front-runner sees the signature, and it is
useless to them: re-targeting it invalidates it, and forging a new one needs the
private key, which never leaves the claimant's browser.
// cairo/src/types.cairo
pub fn release_message_hash(
contract: ContractAddress, mode: felt252, claim_pubkey: felt252, target: felt252,
) -> felt252The property is a test, not a claim:
a_claim_signature_cannot_be_retargeted_by_a_front_runner.
Overclaiming is the fastest way to build something that quietly hurts people, so here is the whole truth.
| Hidden | Visible | |
|---|---|---|
| Funding an envelope | Who funded it. The pool spends a shielded note; the funder's address appears nowhere. | That the pool paid the anonymizer, the token, and the amount. |
| Claiming to a private note | Who claimed it, and where the value went next. | That the anonymizer released an envelope, and for how much. |
| Claiming a publicly funded envelope to an address | Nothing further. | The funder, recipient, and amount. Pool-funded envelopes reject this route. |
| Refunding | That the refund went back to the original funder. | That an expired envelope was reclaimed. |
Amounts are public throughout. An envelope's funding leg and its claim leg carry the same figure, so the two are trivially linkable to each other. What the pool hides is the funder, which is the property the product is built on: "pay someone without revealing who you are or what else you hold". It is not "pay someone without anyone knowing how much".
A distinctive amount is therefore its own de-anonymiser. The app nudges toward round denominations for this reason. A 10 STRK envelope hides in the crowd of other 10 STRK envelopes; a 13.7204 STRK envelope does not.
The link is a bearer instrument. Whoever holds it can claim. It is carried in the URL fragment, so it never reaches this app's server, its logs, or its analytics. But a link pasted into a group chat is money pasted into a group chat.
| Path | |
|---|---|
cairo/ |
The EnvelopeAnonymizer contract and its test suite |
packages/envelope-sdk/ |
strk20-envelope: keys, links, signing, STRK20 action builders |
packages/envelope-cli/ |
strk20-envelope-cli: the envelope command, for terminals and agents |
web/ |
The app |
docs/ |
Protocol notes, mainnet addresses, open questions |
One privacy_invoke entry point, dispatching on an operation, plus one public
entry point for recipients who are not in the pool:
| Operation | Driven by | Effect |
|---|---|---|
Fund |
the pool | Parks the value the pool just withdrew and marks the envelope private-claim-only. Returns an empty span, so nothing is credited yet. |
Claim |
the pool | Releases into an open note. Returns one OpenNoteDeposit. |
Refund |
the pool | After expiry only, returns the value to the funder as an open note. |
claim_to_address |
anyone | Releases a publicly funded envelope as a plain ERC-20 transfer. Pool-funded envelopes reject it. |
Envelopes carry a time lock, an expiry, a refund key, and a memo, so the same primitive covers a payment link, a vesting cliff, a bounty with a deadline, and a payout run.
import {
generateEnvelopeKey, encodeClaimLink, buildFundActions,
} from "strk20-envelope";
const claim = generateEnvelopeKey();
const refund = generateEnvelopeKey();
await account.strk20InvokeTransaction(
buildFundActions({
anonymizer: ENVELOPE_ANONYMIZER,
token: STRK_ADDRESS,
amount: 10_000000000000000000n,
claimPublicKey: claim.publicKey,
refundPublicKey: refund.publicKey,
expiry: Math.floor(Date.now() / 1000) + 7 * 86_400,
memo: "bounty-142",
}),
);
const link = encodeClaimLink("https://envelope.example", claim.privateKey);The SDK is deliberately usable without the app: it builds action lists and returns them, so any STRK20 dapp can add claim links without adopting our UI. Its message construction is pinned to the Cairo contract by a shared test vector generated from the Cairo suite. If the two ever drift, that test fails rather than every signature silently being rejected on-chain.
cd cairo && scarb build && snforge testnpm install && npm testThat runs the SDK suite, the CLI suite and the Cairo tests. A single package on its own:
npm test --workspace strk20-envelope
npm test --workspace strk20-envelope-cliBuilt for the STRK20 Private Sprint,
14 to 31 August 2026. Mainnet addresses and transaction hashes are in
strk20.json; what is deployed and what is not is tracked in
docs/STATUS.md.
This code has not been audited. It moves real money on mainnet. Read it before you trust it, and do not park more in it than you would hand to a stranger.
Apache 2.0, matching the STRK20 protocol repositories so the contract can be lifted into them without a licence change.