PIP-57: Anchor Request URI
A pactus:anchor URI that hands PIP-50 anchor values to a wallet, which builds and signs the transaction.
| Authors | Midefos (@midefos) |
|---|---|
| Discussion | View Discussion PIP-57 |
| Category | Interface |
| Created | 2026-11-01 |
| Requires | PIP-42, PIP-50 |
Table of Contents
Abstract #
This PIP extends the pactus: URI scheme of PIP-42 with a pactus:anchor URI
that carries the values of a PIP-50 anchor transaction.
A website, app or QR code can hand those values to a wallet.
The wallet builds the transaction from the account the user picks, shows every field,
and signs only after the user approves.
Motivation #
PIP-50 anchors are created with a transaction (payload type 7), but wallets have no way to receive one from a website. Today a service can only give users an unsigned raw transaction. That has three problems:
- The wallet has to decode and trust a transaction someone else built, which is close to blind signing.
- The sender is fixed before the user has chosen an account.
- The transaction expires with its
lock_time(PIP-2).
A URI with only the anchor values avoids all three.
It fits in a link or a QR code, and any wallet can support it in the same way.
It does not replace the node’s GetRawAnchorTransaction (PIP-50):
it carries the user’s intent to the wallet, which then builds the transaction with that API or on its own.
Specification #
The key words “MUST”, “MUST NOT”, “SHOULD” and “MAY” are to be interpreted as described in RFC 2119.
Syntax #
pactus:anchor?<parameters>
- The URI uses the
pactusscheme of PIP-42 with the reserved pathanchor. PIP-42 payment URIs start with an address (pactus:pc1…), and no address equalsanchor, so a wallet tells them apart by the path. Wallets that support only PIP-42 will rejectanchoras an invalid address. - Parameters follow RFC 3986 query syntax and are UTF-8 before percent-encoding.
Values MUST be percent-encoded where needed (for example
ipfs%3A%2F%2F…).+is a literal plus sign, not a space. Many standard query parsers follow HTML form encoding and turn+into a space (for example JavaScript’sURLSearchParams, Python’surllib.parse.parse_qsand Go’surl.ParseQuery), so implementations SHOULD split the query on&and=and percent-decode each value themselves. - Parameter names MUST consist only of
atoz,0to9,-and_, as written in the URI: names are not percent-decoded. This keepsACTION=…or%61ction=…from slipping past as an unknown parameter next to the realaction. - Wallets MUST reject unknown parameters that start with
req-, and SHOULD ignore other unknown parameters.
Parameters #
| Parameter | Required | Value |
|---|---|---|
network |
always | mainnet or testnet. Wallets MAY accept other network names they support (for example localnet) |
action |
always | set or delete |
type |
set |
Anchor type, decimal integer from 0 to 255 (for example 1 for a service manifest) |
root |
set |
Root hash, 32 to 64 bytes as hex (64 to 128 characters; the PAC-ANCHOR-1 hash is 32 bytes). Wallets MUST accept upper or lower case; writers SHOULD use lower case |
uri |
no | Manifest URI (for example ipfs://<cid>), at most 128 bytes of UTF-8. Default: empty |
locked |
no | Total deposit the anchor should have locked after the transaction, in PAC as in PIP-42’s amount (for example 100 or 2.5). Default: the PIP-50 MinAnchorDeposit (1 PAC) |
memo |
no | Transaction memo, as in PIP-42; at most 64 bytes of UTF-8 |
sender |
no | Suggested account. The wallet MAY preselect it, but the user can change it |
A delete URI carries only network, action and, optionally, sender and memo.
It MUST NOT contain type, root, uri or locked, so that a deletion cannot look like an update.
Validation #
A wallet MUST reject the whole URI, and MUST NOT offer to sign anything, when:
- the path is not
anchor; - a parameter name has a character other than
a–z,0–9,-or_; - a parameter appears more than once;
networkoractionis missing or has an unknown value;- for a
set,typeorrootis missing; - for a
delete,type,root,uriorlockedis present; - a parameter is malformed or out of range:
typeis not an integer from 0 to 255;rootis not 64 to 128 hex characters, or has an odd length;uriormemois not valid UTF-8, is too long, or contains a control or formatting character: Unicode general categoriesCc(including ASCII0x00–0x1Fand0x7F),Cf(for example U+202E RIGHT-TO-LEFT OVERRIDE or zero-width characters),ZlorZp;lockedis not a plain decimal number of PAC (digits, optionally a.and 1 to 9 fractional digits; no sign, exponent, grouping or leading zeros), or is aboveMaxNanoPAC;senderis not a valid bech32m account address fornetwork(BLS, Ed25519 or secp256k1 account; validator addresses and the treasury cannot own an anchor).
Wallets MUST NOT silently fix or truncate a value.
Wallet Behavior #
A wallet that opens a valid pactus:anchor URI:
- MUST refuse it if
networkdoes not match the wallet’s network. - MUST let the user choose the sending account among its account addresses, and MUST show the account’s current anchor, if any: its type, root hash, manifest URI and locked deposit. Whenever the user changes the account, the wallet MUST redo steps 4 to 6 for the new account (deposit to add, balance check, replacement warnings) before it can sign.
- MUST build the transaction itself (payload type 7) from the parameters,
for example with the node’s
GetRawAnchorTransaction. It sets the fee andlock_timeas for any other transaction. - For a
set, MUST compute the transaction’sDepositas described in Deposit Semantics, and MUST check that the account’s balance covers it plus the fee. For adelete, the fee is taken from the refund (PIP-50), so the balance is not checked; the locked deposit must cover the fee (see step 6). - For a
set, MUST show, before signing: network and account; anchor type, root hash and manifest URI; the deposit locked now and the amount this transaction adds; fee and memo. When the amount to add is aboveMinAnchorDeposit, the wallet MUST highlight it. It MAY warn whentypeis a reserved value (4to254in PIP-50), but MUST NOT reject the URI because of it. If the account already has an anchor, it MUST say that the anchor will be replaced. If that anchor has a different type, it MUST warn prominently: the account has a single slot, and the previous anchor (for example a DID document) will no longer be on chain. - For a
delete, MUST refuse it if the account has no anchor, or if itsLockedDepositis below the fee (PIP-50 would reject the transaction). Otherwise it MUST show the anchor that will be removed (type, root hash, manifest URI) and the amount returned to the balance (the locked deposit minus the fee), as well as the fee and memo. It SHOULD warn when that amount is zero. - MUST NOT sign or broadcast without explicit user confirmation.
The URI has no return channel. The wallet broadcasts the signed transaction through its own node, and the service learns of it by watching the chain (for example the account’s anchor, or transactions carrying its memo). A memo is a hint, not proof: see Security Considerations.
Deposit Semantics #
In PIP-50, the Deposit of a set is added to the account’s LockedDeposit,
and only a new anchor needs MinAnchorDeposit.
The service that builds the URI does not know which account the user will pick,
so it cannot know whether the set creates or updates an anchor.
The URI therefore states a target total, and the wallet, which knows the account, computes the amount to add:
current = the account's LockedDeposit (0 if it has no anchor)
target = max(locked, MinAnchorDeposit) // locked defaults to MinAnchorDeposit
Deposit = max(0, target - current)
- New anchor,
lockedomitted: adds 1 PAC, the minimum. - Update,
lockedomitted: adds 0, so users never lock more PAC by accident. locked=100: adds only what is missing to reach 100 PAC, whatever the account already has locked.
A target below what is already locked never releases funds: PIP-50 has no partial withdrawal, only delete.
Design Notes #
- Values, not a raw transaction. The wallet builds what it signs,
so it can show meaningful fields, choose the account and set a fresh
lock_time. networkis required, so a testnet link cannot be signed on mainnet or the other way round.- Hex for
root. It matches how explorers display hashes and is URI-safe without encoding. Base64 is not. - A target total, not an amount to add. The same link works for creating and for updating, so services do not need to know the account. It avoids both a failed creation (adding 0 to a new anchor) and an accidental extra lock (adding 1 PAC to an existing one).
- PAC amounts, as in PIP-42.
lockeduses the same unit as the payment URI’samount, so wallets parse both the same way. The 9 fractional digits map exactly to nanoPAC, so there is no rounding. - Reserved types are allowed. PIP-50 says wallets must not reject a transaction because of its anchor type, so neither does this URI.
- Words for
action.setanddeleteare self-describing in a link, while the on-chain values (0 and 1) are not. - Reject, don’t repair. A malformed link is more likely a bug or an attack than something the wallet should guess at.
- No free-text label. Unlike payment URIs, there is no
labelormessage: text written by the link’s author could mislead the user (“official refund…”), and the real fields already describe the transaction.
Backwards Compatibility #
No backward compatibility issues found. This PIP adds a URI format and does not change transactions or consensus.
Test Cases #
The test vectors include a reference parser and check every case below and more. They use only the Python standard library.
Valid URIs #
Create or update a listing on mainnet. A new anchor locks 1 PAC; an existing one adds nothing:
pactus:anchor?network=mainnet&action=set&type=1&root=2727edf1004e656e34a07948dfccde9ae583b5f240c88f9f5dc1ae36c5ae9261&uri=ipfs%3A%2F%2Fbafk2bzaceatsp3prabhgk3ruub4urx6m32nola5v6jamrd47lxa24nwfv2jgc&memo=pactus.page
The same listing, featured (100 PAC locked in total):
pactus:anchor?network=mainnet&action=set&type=1&root=2727edf1004e656e34a07948dfccde9ae583b5f240c88f9f5dc1ae36c5ae9261&uri=ipfs%3A%2F%2Fbafk2bzaceatsp3prabhgk3ruub4urx6m32nola5v6jamrd47lxa24nwfv2jgc&locked=100&memo=pactus.page
Remove it (the locked deposit returns to the account, minus the fee):
pactus:anchor?network=mainnet&action=delete&memo=pactus.page
In these examples root is the BLAKE2b-256 digest of the manifest (PAC-ANCHOR-1),
which is also the digest inside its CIDv1 (raw codec, blake2b-256 multihash).
Deposit to Add #
With MinAnchorDeposit = 1 PAC. All amounts in PAC.
| Locked now | locked in the URI |
Transaction Deposit |
|---|---|---|
| none (no anchor) | omitted | 1 |
| none (no anchor) | 0.5 | 1 |
| none (no anchor) | 100 | 100 |
| 1 | omitted | 0 |
| 1 | 100 | 99 |
| 100 | omitted | 0 |
| 100 | 1 | 0 |
A delete on an account with 100 PAC locked returns 100 PAC minus the fee to the balance.
Rejected URIs #
| URI (abbreviated) | Reason |
|---|---|
pactus:anchor?network=testnet&action=set&… opened by a mainnet wallet |
Network mismatch |
pactus:anchor?action=set&type=1&root=<64 hex> |
network missing |
pactus:anchor?network=mainnet&action=update&… |
Unknown action |
pactus:anchor?network=mainnet&action=set&root=<64 hex> |
type missing |
pactus:anchor?network=mainnet&action=set&type=256&root=<64 hex> |
type out of range |
pactus:anchor?network=mainnet&action=set&type=1&root=<63 hex> |
root has an odd length |
pactus:anchor?network=mainnet&action=set&type=1&root=<62 hex> |
root shorter than 32 bytes |
pactus:anchor?network=mainnet&action=set&type=1&root=<64 hex>&uri=<129 bytes> |
uri longer than 128 bytes |
pactus:anchor?network=mainnet&action=set&type=1&root=<64 hex>&memo=<65 bytes> |
memo longer than 64 bytes |
pactus:anchor?network=mainnet&action=set&type=1&type=2&root=<64 hex> |
Parameter repeated |
pactus:anchor?network=mainnet&action=delete&ACTION=set |
Parameter name not lower case |
pactus:anchor?network=mainnet&action=delete&%61ction=set |
Parameter name percent-encoded |
pactus:anchor?network=mainnet&action=delete&type=1&root=<64 hex> |
delete with set parameters |
pactus:anchor?network=mainnet&action=set&type=1&root=<64 hex>&memo=a%0Ab |
Control character in memo |
pactus:anchor?network=mainnet&action=set&type=1&root=<64 hex>&uri=ipfs%3A%2F%2F%E2%80%AEfdp.x |
U+202E in uri |
pactus:anchor?network=mainnet&action=set&type=1&root=<64 hex>&locked=-1 |
locked negative |
pactus:anchor?network=mainnet&action=set&type=1&root=<64 hex>&locked=1e2 |
locked not a plain decimal number |
pactus:anchor?network=mainnet&action=set&type=1&root=<64 hex>&locked=0.0000000001 |
More than 9 fractional digits |
pactus:anchor?network=mainnet&action=set&type=1&root=<64 hex>&req-expiry=… |
Unknown req- parameter |
pactus:anchor?network=mainnet&action=set&type=1&root=<64 hex>&sender=<validator address> |
Validators cannot own an anchor |
pactus:anchor?network=mainnet&action=delete, account without an anchor |
Nothing to delete |
pactus:anchor?network=mainnet&action=delete, account whose locked deposit is below the fee |
PIP-50 would reject it |
Reference Implementation #
A reference parser, including bech32m address checks and the deposit computation, ships with the test vectors.
Pactus Pages produces these values for type-1 anchors today (currently as a GetRawAnchorTransaction request),
and will offer a pactus:anchor link and QR code once a wallet supports it.
Security Considerations #
- Anyone can publish a URI. Wallets MUST treat every value as untrusted text, display it without interpreting markup, and never open the manifest URI automatically.
- The confirmation screen is the security boundary. The user must see the full root hash, the manifest URI and the amount the transaction adds to the locked deposit. Services SHOULD show the same root hash and manifest URI next to the link or QR code, so users can check that what the wallet shows is what they asked for.
- Each account has a single anchor slot. A
setreplaces whatever is there, and adeleteremoves it. This is why wallets must show the current anchor and warn when its type changes. lockedcan lock a large amount, and getting it back takes adelete, which removes the anchor and costs a fee. This is why wallets must highlight the amount to add when it is above the minimum.- Control and formatting characters are rejected because they can change how the confirmation screen reads: a newline can push text out of view, and a right-to-left override can reverse part of a URI.
- The manifest URI is free text on chain, and PIP-50 does not restrict its scheme.
Wallets and explorers that render an anchor’s URI as a link SHOULD do so only for schemes on an allow list
(for example
ipfs://andhttps://), and show anything else, such asjavascript:ordata:, as plain text. - Memos are public and anyone can copy one, including from the mempool. A service that waits for a transaction MUST NOT link it to a user or session by its memo alone: it SHOULD check the resulting anchor (sender account, type, root hash and manifest URI) against what it expects.
- A link for another network is rejected rather than converted, so a testnet link can never move mainnet funds.
Copyright
Copyright and related rights waived via CC0.