Commands#
| Command | Does | Status |
|---|---|---|
pecu doctor |
Node reachability, chain tip, whether DeFi is switched off, config paths, build info | ✅ done |
pecu key gen|import|list|show|export|phrase |
Encrypted keystore (Argon2id + ChaCha20-Poly1305) | ✅ done |
pecu wallet balance|utxos|history |
Spendable, withheld, token and unconfirmed balances; the transaction log | ✅ done |
pecu tx explain |
Says what every output in a transaction actually is | ✅ done |
pecu send |
Transparent sends: native, token, or out of a VerusID's own funds | ✅ done |
pecu plan send / pecu sign / pecu broadcast |
The air-gap trio, over files or QR codes — the chain's own coins only, since no builder produces an unsigned token or identity spend | ✅ done |
pecu id show|register |
Read an identity; register one (two-phase, resumable) | ✅ done |
pecu id update|revoke|recover|unlock |
The rest of the lifecycle, including timelocks | ✅ done |
pecu id login|publish|read |
Sign-in with VerusID, and VDXF data | M8 |
pecu currency show|launch|mint|preconvert|convert |
Read a currency definition; launch a token or fractional basket; mint new supply of a centralized one; buy into a launching currency; convert through a launched one | ✅ done |
pecu completions <shell> |
Shell completion script | ✅ done |
pecu doctor#
The first thing to run. It answers the three questions in the order they go wrong: where are my files, what was this binary built from, and is the node answering.
┌─ LOCAL ─────────────────────────────────────────────┐
│ profile testnet │
│ node https://api.verustest.net │
│ currency VRSCTEST │
│ spending ✓ spending allowed │
│ config ~/.config/verus-pecu/config.toml │
│ keys ~/.config/verus-pecu/keys (0 keys) │
├─ BUILD ─────────────────────────────────────────────┤
│ pecu 0.1.0 │
│ verus-sdk ae279ea │
│ features network │
├─ NODE ──────────────────────────────────────────────┤
│ chain VRSCTEST │
│ daemon 1.2.17-3 │
│ tip ▸ 1,176,514 mined 82s ago │
│ sync ✓ in sync │
│ defi ▲ switching off at block 1,187,000 — │
│ launches and conversions still work │
│ mempool 0 transactions │
│ latency 232 ms │
└─────────────────────────────────────────────────────┘
▸ no config file yet — running on the built-in profiles
It exits non-zero when the node cannot be reached, but still prints the local
half — "my setting is being ignored" and "the node is down" are different
problems, and the output should tell them apart. pecu doctor --json gives the
same report as machine-readable data, including when the node is down.
The defi row is read from the chain's notification oracle — the VerusID
the daemon watches for signals, which for a chain is the chain's own id. While
the switch is in force the daemon refuses every currency launch and every
conversion with bad-txns-failed-precheck, whatever the transaction contains,
so the row is worth having before you sign one. Nothing is gated on it: a
chain can re-enable DeFi between the reading and the broadcast, so this is
information and never a refusal. No row at all means either that no such switch
is published — the ordinary case — or that the endpoint would not answer
getidentity.
pecu tx explain#
Says what every output in a transaction actually is. Offline for hex; a txid is
the one input that needs a node — to fetch the bytes, and then to ask what the
currencies in them are called. A name is display text and never replaces the
i-address beside it; a lookup that fails costs that one name and nothing else.
Naming is one request per currency and the currencies come from the transaction,
so the whole step is capped at roughly one request's worth of waiting — against a
slow node it stops early and prints the ids it did not get to, rather than
holding the answer back. --explain says how many it reached.
pecu tx explain <txid> # fetches the hex, decodes it locally
pecu tx explain <raw hex> # offline
pecu tx explain <output script hex> # offline, a bare scriptPubKey
cat tx.hex | pecu tx explain -
┌─ TRANSACTION ──────────────────────────────────────────────────────────────┐
│ txid df69640e4cfafe7cbe9cabd3c790ed3c556f7ee340e5f10ce73dd1b590f0556d │
│ expiry height 1,167,853 │
├─ INPUTS ───────────────────────────────────────────────────────────────────┤
│ #0 e740a3149f…600f15:0 │
│ #1 ec69f05ffd…728670:0 │
├─ OUTPUTS ──────────────────────────────────────────────────────────────────┤
│ 7 outputs — 105.00000000 in native satoshis │
│ #0 0.00000000 │
│ the VerusID verusrpc-test-mrhu3gpo3wws@ — 1-of-1, revocation │
│ iEiEX5Voi…nAyd, recovery iEiEX5Voi…nAyd │
│ #2 0.00000000 │
│ a CryptoCondition this SDK does not decode (eval 13) — ▲ IT MAY HOLD │
│ CURRENCY; do not treat this output as empty │
│ #5 100.00000000 │
│ reserves held for i9G2QgG74f7tErEyF3cWp2x1exBGbFa19t: 100.00000000 │
│ iJhCezBExJHvtyH3fGhNnt2NhU4Ztkf2yq │
│ #6 5.00000000 │
│ → i9G2QgG74f7tErEyF3cWp2x1exBGbFa19t held for a VerusID, not a key │
└────────────────────────────────────────────────────────────────────────────┘
Why an output needs decoding at all. On Bitcoin an output is a script and a number of satoshis, and the number is the value. On Verus that is true only for the plain ones. A token lives in the payload of a CryptoCondition output whose satoshi field is zero; an identity is an output; a conversion in flight is an output; a name commitment is an output. Reading the satoshi column of a Verus transaction and calling it the value is how a wallet reports that an address holds nothing while it holds a fortune in tokens.
So this says what each output is, and where it cannot tell, it says so — including whether the thing it cannot read is able to hold money. That last distinction is the one worth having: an undecodable output that provably cannot carry currency is safe to ignore, and one that can is not.
An output that fails to decode does not fail the transaction. It sits beside the ones that decoded fine, marked, because refusing the whole thing would throw away the answer you came for.
pecu wallet#
Read-only, and deliberately so: it takes an address, not a key. Watching a balance is the one wallet job that needs no secret, and a command that cannot unlock anything cannot spend anything.
pecu wallet balance --address iJhCezBExJHvtyH3fGhNnt2NhU4Ztkf2yq
pecu wallet balance --address bob@ # a VerusID name is resolved
pecu wallet balance --key demo # the address of a stored key
pecu wallet balance # the sole stored key, if there is exactly one
pecu wallet utxos --key demo
pecu wallet history --key demo --from-height 1176000
┌─ WALLET ───────────────────────────────────────────────┐
│ address iJhCezBExJHvtyH3fGhNnt2NhU4Ztkf2yq │
│ tip ▸ 1,176,594 │
├────────────────────────────────────────────────────────┤
│ SPENDABLE 0.00000000 VRSCTEST (0 outputs) │
│ WITHHELD 0.00000000 VRSCTEST (15 outputs) │
│ HELD BY ID 20155.03513344 VRSCTEST (11 outputs) │
│ IN CONDITIONS 1159.18038198 VRSCTEST (210 outputs) │
│ TOTAL 21314.21551542 VRSCTEST │
├─ TOKENS ───────────────────────────────────────────────┤
│ 9272.49511041 Kaiju@ iHBwQo7LU…dK9f │
└────────────────────────────────────────────────────────┘
That is a real VRSCTEST address, and it is the whole point: nothing a key can spend, 21,314 in native value, and 9272 in a token. A Verus balance is not one number, and a wallet that prints one is wrong.
| Row | What it is |
|---|---|
SPENDABLE |
Plain P2PKH outputs. What a transparent key can move right now |
WITHHELD |
Outputs the node reported as not spendable yet |
HELD BY ID |
Native value in pay-to-identity outputs — a VerusID's own funds, spendable by its authority rather than by a key |
IN CONDITIONS |
Native value in every other CryptoCondition output |
TOTAL |
The sum, which is what a block explorer shows |
A sixth section appears only when something is moving. A UTXO set and a delta list both agree that an unconfirmed payment does not exist, so an address that has just been paid reports its old balance until a block is mined — the one answer a wallet must not give while money is demonstrably on its way:
├─ PENDING ──────────────────────────────────────────────┤
│ in flight 1 transaction │
│ INCOMING 348.54919600 VRSCTEST │
│ OUTGOING 348.54929600 VRSCTEST │
│ NET -0.00010000 VRSCTEST │
└────────────────────────────────────────────────────────┘
▸ pending: in this node's mempool, not in any block, and excluded from the
totals above. It may confirm, be replaced, or never arrive, and another
node may not have seen it at all
That is a real self-send caught mid-flight: everything leaves the address and
comes back, so the net is exactly the fee. It costs one request —
ChainReader::address_mempool — where the alternative is mempool() plus one
raw_transaction() per txid, scanning outputs.
- It is never added to
TOTAL. Confirmed figures and mempool figures answer different questions, so they get different sections; a reader who adds them up should have to mean it. - A failed mempool read says so. Like the token lookup,
Errmeans unknown, and the panel prints⚠ unknown: …rather than nothing at all — silence here reads as "nothing pending", which is the wrong answer stated as a fact.--jsoncarries"known": false. wallet utxosmarks the outputs a pending transaction already claims. The chain still shows them unspent, so coin selection still offers them; funding a second payment from one builds a double spend the node refuses withbad-txns-inputs-spent. Unconfirmed arrivals appear there too, at0confirmations, and are never counted as spendable.
HELD BY ID is where an i-address keeps everything it owns. The SDK deliberately
keeps those outputs out of the spendable bucket — the native builders would
destroy what they carry — but they are still the balance, and there is a test
that reconciles TOTAL against the node's own getaddressbalance, satoshi for
satoshi.
WITHHELD, not "immature". Coinbase maturity is the usual cause, but the SDK routes any output the node reports as unspendable into that bucket. An output with a million confirmations labelled "immature" is a wrong answer printed confidently.- A failed token lookup means "unknown", never "none". It is a separate call
from the native figure so that one bucket being uncountable cannot take the
other down with it, and
--jsonsays"known": falserather than an empty list. The same rule applies to a token's name: a name is asked for one currency at a time, so one currency this build cannot read cannot blank the names of the others — which it used to, leaving every token on screen saying(unnamed). A row whose lookup failed says(name unknown), andwallet balance --jsoncarries"name": {"known": false, "error": …}with the reason. The words are deliberately not "unreadable": a timeout, a refused connection and a node that does not serve the method all land here, and in none of them did an answer arrive to be read, so the panel does not guess at a cause it has not got. A node that answers-5or-8— "I know no such currency" — did say something, and that row prints(no such currency)and{"known": true, "name": null, "reason": …}. Not(unnamed): a node denying the currency exists has not told anyone the currency is nameless, and the row beside those words is printing a balance in it.
This shape is wallet balance --json only, under .tokens.balances[].name
and .pending.tokens[].name. wallet history --json carries no name field
at all — its net_currencies entries are {currency, satoshis} — and neither
does wallet utxos --json. Both of those name fields changed shape: they
were a bare string or null, and are now this object. A string cannot say
"unknown", which is the whole point, but a consumer reading .name as a
string needs .name.name.
- Currency names are untrusted. They come from the node and Verus permits
more in a name than it looks like it does, so they are stripped of control
characters and of the invisible and direction-changing ones that let two
different names print identically, folded onto one line and capped before
printing — and the currency id is always shown next to the name, because
the id is the part that identifies anything. On the TOKENS rows that id is
re-encoded from the currency id the SDK parsed rather than reprinted from the
node's text, and shortened to fit the column — except where there is no name
beside it, when the id is the only handle left and is printed whole. Whether
there is room for it is measured on the finished table rather than guessed at
per row, because the column is shared: a sibling row's long name moves the
id right, and the PENDING table has a label column TOKENS has not. Where
the whole id will not fit, every id in that table stays elided — a shortened
id is better than a row that runs out through the frame. wallet history has
no such luxury: it falls back to the node's own key for a currency it has no
name for, and that string is filtered the same way the name is. Because that
column holds every leg of a transaction in one cell, it cannot say why any
one leg is a bare id — so the panel's note names the ids whose lookup failed
rather than implying the same cause for the ones the node answered about.
- Addresses are parsed before the node sees them. A typo'd address comes back
from a node as an empty balance, which reads as "no funds" — the one wrong
answer a wallet must never give.
- Several stored keys are refused, not guessed between. Picking one silently
would report the wrong address's balance.
A VerusID name works wherever an address does, and the panel says what it
resolved to — an i-address alone does not tell you whether bob@ meant the
identity you had in mind:
│ address iDxZS81ZCdqgdFVF6H1BfW43uov8ZUe222 (jbratchet.VRSCTEST@) │
The cost is that a typo'd address is no longer refused offline: anything that
does not parse as one is looked up as a name first. The refusal is the same, one
request later. What is not conflated is a missing identity and an
unreachable node — only the daemon's own -5 and -8 mean "that names
nothing", and anything else says the node failed rather than denying the
identity exists.
pecu wallet history is the other view: what happened, rather than what is
left.
┌─ HISTORY ──────────────────────────────────────────────────────┐
│ address RComfCn4wHHsGR8vWBAU7T1r3tHHyxN9Hm │
│ found 19 transactions │
├────────────────────────────────────────────────────────────────┤
│ HEIGHT WHEN CHANGE TRANSACTION │
│ 1,177,072 1h 44m ago -0.00010000 VRSCTEST 97985193dc…242fe5 │
│ 1,177,079 1h 35m ago -0.00010000 VRSCTEST e0c5c6972e…beb532 │
│ 1,177,175 4m 40s ago -1.00010000 VRSCTEST ea47ee557a…b5b8b3 │
│ 1,177,178 3m 13s ago +0.40000000 VRSCTEST 2e10e7944e…7a8bc5 │
└────────────────────────────────────────────────────────────────┘
- Net per transaction, not gross. An output spent and mostly returned as change counts as what actually moved, which is the number a reader wants.
- A
+0.00000000that still spent something is a transfer to yourself — the value came back and only the fee left. The panel says so when it happens, because a zero that means "nothing happened" and a zero that means "you paid a fee" are different answers. - A token-only transfer moves no native value at all, so the change column
shows the token leg instead of a misleading
0. - An open-ended
--from-heightis closed at the tip, not atu32::MAX— the daemon refuses that with-1: JSON integer out of range, which reads as a broken node rather than as an argument it dislikes. --limitdrops from the front: a terminal scrolls, so the most recent entry should be the one still on screen. When it truncates, it says how many it hid.
A long-lived mining address can have a UTXO set far past the SDK's 8 MiB reply
ceiling. That is a memory bound against a hostile node, not a bug; raise it for a
profile with max_response_mb and the error says so.
The same address on a busy node can take longer than the 20-second RPC timeout to
answer at all — the node does reply, just not in time, and past the ceiling the
read fails outright rather than slowly. Raise timeout_secs for the profile, and
the error says so.
pecu send#
pecu send --to bob@ --amount 0.1 # a VerusID name resolves
pecu send --to RXyz…7Qa4 --amount 0.1 --from cold # or an address
pecu send --to bob@ --amount 5 --currency pecu@ # a token
pecu send --to bob@ --amount 0.1 --dry-run # build and sign, send nothing
The order is deliberate: unlock the key, build and sign locally, show you the
finished transaction decoded output by output, and only then offer to broadcast.
Nothing leaves the machine until you have seen what it says, and the last step
wants the word yes typed out rather than a keystroke.
┌─ REVIEW ──────────────────────────────────────────────────────┐
│ from RComfCn4wHHsGR8vWBAU7T1r3tHHyxN9Hm (faucet) │
│ to bob@ (bob.VRSCTEST@) │
│ amount 0.10000000 VRSCTEST │
│ fee 0.00010000 VRSCTEST │
│ change 9.89990000 VRSCTEST │
│ txid a3f1… │
│ expiry height 1,176,620 │
├─ OUTPUTS AS BUILT ────────────────────────────────────────────┤
│ #0 0.10000000 VRSCTEST │
│ → iBob… held for a VerusID, not a key │
│ #1 9.89990000 VRSCTEST │
│ → RComfCn4wHHsGR8vWBAU7T1r3tHHyxN9Hm │
└───────────────────────────────────────────────────────────────┘
type `yes` to broadcast:
Those outputs are decoded from the bytes that would go out, not printed back
from the arguments — re-showing what you typed would confirm nothing. It is the
same decoder pecu tx explain uses, so --dry-run hands you hex you can read
straight back:
pecu send --to bob@ --amount 0.1 --dry-run --json | jq -r .hex | pecu tx explain -
A token send names the token. With --currency, the amount row is labelled
with the currency you asked for rather than the chain's own, and a currency id
row carries the i-address that name resolved to — a currency name is untrusted
display text somebody registered, so the id is the part that identifies
anything, and it is what the truncated currency on the OUTPUTS AS BUILT line
can be matched against by eye. fee and change stay labelled with the chain's
own currency because they really are native: a token moves as a reserve output
while the miner is still paid in the chain's coins. So do the OUTPUTS AS BUILT
figures, which are each output's native value — a reserve output's
0.00000000 is the truth about that output, and the token it holds is on the
line beneath it. --json carries currency (the name as given) and
currency_id (the i-address); both are null when native coins are moving, so
a consumer can tell the two apart instead of reading a ticker that might be
either.
The to row carries two names: the one you typed and the one the node says
it resolved to. The node's half is untrusted display text like any other name —
stripped of control characters and of the invisible and direction-changing ones
that let two different names print identically, folded onto one line and capped
before it goes inside the frame — and the pair is budgeted together, so an
ordinary sub-identity still prints in full rather than losing its middle to an
ellipsis.
Paying out of a VerusID's own funds is the other half of the HELD BY ID
row in wallet balance. Money flows into an identity with an ordinary
--to alice@; getting it back out needs --from-identity, because the inputs
are pay-to-identity outputs and each carries a fulfillment rather than a
scriptSig:
pecu send --from-identity alice@ --to RXyz…7Qa4 --amount 1 --from alicekey
┌─ REVIEW ──────────────────────────────────────────────────────┐
│ from pecucli7@ (the identity's own funds) │
│ signed by RComfCn4wHHsGR8vWBAU7T1r3tHHyxN9Hm (faucet) │
│ to RJ7gsKDjUjPS8XZzENqmQMmWJRLuTnw5hp │
│ amount 0.10000000 VRSCTEST │
│ change 0.49980000 VRSCTEST │
├─ OUTPUTS AS BUILT ────────────────────────────────────────────┤
│ #0 0.10000000 VRSCTEST │
│ → RJ7gsKDjUjPS8XZzENqmQMmWJRLuTnw5hp │
│ #1 0.49980000 VRSCTEST │
│ → i7r29bDQ… held for a VerusID, not a key │
└───────────────────────────────────────────────────────────────┘
The identity owns the money and the key proves the authority, so the panel names
both — from is the payer and signed by is the signer, and conflating them on
a spend-confirmation panel would misstate whose balance is about to drop. The
change goes back to the identity, not to the key. --json carries
from_identity for the same reason.
The SDK refuses ahead of time everything the chain would refuse later with a
message naming nothing: a revoked identity, a key the identity does not list, or
fewer distinct keys than its minimumsignatures. That last one currently means
pecu send cannot spend from a multi-signature identity at all — it signs with
one key — and the error says so rather than building something that dies at the
daemon.
The dry run is enforced by the SDK's types, not by remembering.
flows::prepare_send takes a ChainReader and no Broadcaster, so what it
returns is incapable of being sent; broadcasting is a separate, explicit step.
Mainnet cannot spend until you say so. allow_spend is false there by
default — see Configuration.
--json is output, not consent. It will not broadcast without --yes. The
confirmation prompt writes to the stream you are parsing and there is nobody to
answer it, so consent has to be passed in rather than assumed from the fact that
you asked for machine-readable output.
send --json prints at most one document, and never more than one. There is
a plan on every path that reached the broadcast, including the one where the
broadcast fails — that path is where the signed hex matters most, since it is
the only field that cannot be recovered afterwards.
The paths that refuse before then print the bare error object instead: no key
was named, the profile is not allowed to spend, the amount does not parse.
--json without --yes is in that group too, and deliberately so — the
transaction has been built and signed by the time consent is checked, and the
hex is withheld rather than absent. Printing it would hand a script the one
artefact it could broadcast itself, on the path whose whole purpose is that
nobody agreed to spend. See Failures, in
--json; either way .error.code is there
to switch on.
broadcast is a tri-state, because a broadcast that did not come back is not the
same as one that was refused:
outcome |
broadcast |
What it means |
|---|---|---|
not_broadcast |
false |
A dry run. Built, signed, deliberately not sent |
accepted |
true |
The node took it. txid, fee and change are the node's figures |
rejected |
false |
The daemon read it and refused. It is in no mempool |
unknown |
null |
The request did not complete, or the node answered without saying yes or no. It may still have reached the mempool — check the txid before rebuilding |
null rather than false on that last row is the whole point: a timed-out
broadcast that is reported as "not sent" invites a second payment, and telling
someone their money is safe when it may already be moving is the wrong answer to
be confident about.
On the two failing rows the document also carries error, the same object every
other failing --json run prints. In releases before this one that field was a
sentence; it is now the object described under Failures, in
--json, so a consumer that read .error
as text wants .error.message now.
The exit code answers a narrower question than outcome does — whether anything
may be in flight. rejected exits 1. An unknown whose bytes may already be
propagating exits 4; an unknown that never reached the wire, because the
endpoint would not serve sendrawtransaction at all, exits 3. So 3 here
still means nothing was sent, and the unknown beside it is the document being
careful rather than the exit code being wrong.
The air gap#
Three commands, because there are three machines' worth of trust.
# 1. where the node is. No key on this machine at all.
pecu plan send --address RComf…N9Hm --to RJ7gs…w5hp --amount 0.4 --qr-out plan.png
# 2. where the key is. This one opens no socket.
pecu sign --qr-in plan-1.png --key cold --qr-out signed.png
# 3. back where the node is. Carries no key.
pecu broadcast --qr-in signed-1.png
Files and pipes work just as well — --out plan.hex, then pecu sign @plan.hex,
then pecu broadcast @signed.hex. Everything accepts hex as an argument, @file,
or - for stdin.
┌─ PLAN ─────────────────────────────────────────────────────────┐
│ from RComfCn4wHHsGR8vWBAU7T1r3tHHyxN9Hm │
│ spending 449.74990000 VRSCTEST across 1 input │
│ paying out 449.74980000 VRSCTEST │
│ fee and burn 0.00010000 VRSCTEST │
│ expiry height 1,176,653 │
│ commits ✓ every input covers every output (SIGHASH_ALL) │
├─ OUTPUTS ──────────────────────────────────────────────────────┤
│ #0 0.30000000 VRSCTEST │
│ → RJ7gsKDjUjPS8XZzENqmQMmWJRLuTnw5hp │
│ #1 449.44980000 VRSCTEST │
│ → RComfCn4wHHsGR8vWBAU7T1r3tHHyxN9Hm │
└────────────────────────────────────────────────────────────────┘
sign genuinely needs no network. Not "does not usually use one" — there is
a test that signs a plan with --node https://127.0.0.1:1 and succeeds. If that
ever stops being true, the suite fails.
commits is the row to read. Whoever planned the transaction chose the
outputs, and a signature is the irreversible step. Outputs are only binding on
your input if your input commits to them: under SIGHASH_NONE they are not
covered at all, and whoever holds the partial can redirect the money after you
sign. sign refuses without --yes when that check fails.
A partial that still needs another signature is never dressed up as finished.
It comes back as a partial, with a non-zero exit and instructions to pass it on.
Under --json that is one document carrying both the partial to hand on and
the error saying why it is not done — see Failures, in
--json.
The guards sit on the two commands that touch the chain. plan send and
broadcast both read allow_spend, and broadcast also honours --dry-run and
refuses --json without --yes — the same three rules pecu send follows,
because between them these two are send's native mode taken apart. A plan
cannot be broadcast, so plan send has nothing to stop short of. sign is
exempt on purpose: it opens no socket and a signature alone moves nothing, so
the machine holding the key needs no profile that is allowed to spend.
The gap carries the chain's own coins, and nothing else. pecu send has
three modes and the trio takes one of them apart. plan send accepts the other
two flags and refuses each by name, before it opens a socket:
| flag | code | why |
|---|---|---|
--currency |
pecu::plan_has_no_token_path |
prepare_unsigned_send is the only SDK builder that hands back a partial transaction, and it writes a plain native output. A token rides in an output's script; the SDK's token builders each sign as they build, so there is no unsigned form of a token payment to carry offline. A partial is started with whatever outputs it is handed — what is missing is a builder, not a byte shape |
--from-identity |
pecu::plan_has_no_identity_path |
what a VerusID holds sits in pay-to-identity outputs, and those inputs are unlocked by a fulfillment rather than by a signature and public key. The partial format has an input kind for exactly that — what is missing is a builder, not a byte shape |
Both exit 1, the code for a request understood and answered no, and under
--json both are a document on stdout carrying .error.code. They used to be
clap's unexpected argument, which exits 2, prints on stderr and reads as a
misspelling — see Exit codes. pecu send moves
both, signing on the machine that talks to the node, which is exactly the
property the gap exists to keep. There is no offline path to either yet, and the
refusals go when the SDK grows the builders.
QR framing#
A QR code holds at most 4296 alphanumeric characters, so payloads are split into numbered frames:
PECU1:2/5:A1B2C3…
They reassemble in any order and duplicates are ignored — a stack of photographs is rarely tidy — but a missing frame is refused by number, because a payload silently short by one is a transaction that fails at the daemon for no visible reason. Hex is upper-cased so QR uses alphanumeric mode at 5.5 bits per character rather than byte mode at 8.
--qr draws in the terminal; --qr-out <stem> writes <stem>-1.png, -2.png, …
On the human path the bytes are written to a file, not to the screen. An
unsettled broadcast is diagnosed with the same sentence everywhere — never run
pecu doctor, because the node is not what went wrong — and it names
<config>/unsent/<txid>.hex, where the signed transaction has just been saved.
pecu tx explain <txid> only answers while some node still holds the
transaction, and a -25 usually means it was refused before reaching any
mempool, so the txid alone is often a dead end at exactly the moment the advice
is printed. Often, not always — a -25 does not say the transaction was
refused, which is why the check comes first and the bytes are kept rather than
resent. The file is what makes both possible: pecu tx explain - < … decodes it
with no node at all, and pecu broadcast @… resends precisely those bytes once
the check says nothing landed. It is a file rather than part of the message
because a launch is kilobytes of hex and every terminal renderer wraps it into
something that cannot be copied.
Nothing prunes unsent/. A file there is a transaction whose fate somebody has
not established yet, and deleting it on a schedule would throw away the only
copy of the bytes at the point they are still needed; pecu doctor does not
report on it either. Clear it out by hand once the check has answered.
pecu id#
pecu id show VRSCTEST@ # read any identity off the chain
pecu id register alice --from cold # run it again to carry on where it left off
┌─ IDENTITY ───────────────────────────────────────────────┐
│ name pecucli7.VRSCTEST@ │
│ i-address i7r29bDQfrwjkTxjv4bcYD6B1ZV7WZ4kGo │
│ status ✓ active │
│ registered block 1,176,650 │
├─ CONTROL ────────────────────────────────────────────────┤
│ signatures 1-of-1 │
│ ▸ RComfCn4wHHsGR8vWBAU7T1r3tHHyxN9Hm │
│ revocation i7r29bDQfrwjkTxjv4bcYD6B1ZV7WZ4kGo (itself) │
│ recovery i7r29bDQfrwjkTxjv4bcYD6B1ZV7WZ4kGo (itself) │
└──────────────────────────────────────────────────────────┘
(itself) is the row worth reading. A freshly registered VerusID is its own
revocation and recovery authority, so both roles answer to the same primary
keys listed above — there is no independent guardian to fall back on if those
keys are lost.
That is a default, not a life sentence: an identity update can repoint either
authority at another VerusID. RegistrationOptions has no field to set them at
registration, so this build cannot offer the choice up front, but it says what
the default means before you pay.
Every string on that panel is the node's word, the i-addresses included —
the i-address row, the status, the primary addresses and the two
authorities all arrive as JSON, and nothing between the socket and the frame
checks their shape. So they all go through the same filter a registrant's name
goes through: the address rows budgeted at an address's exact width — an
i-address is exactly 34 characters, so a well-formed one prints whole — and the
status word on the same name budget the name row uses. An answer that is
neither cannot repaint the row you were told to compare against.
Registration is two transactions#
Step one commits to the name under a salt. Step two reveals it and pays. Between them sits a confirmation — and a salt that exists nowhere else. Lose it and the name is unclaimable and the commitment fee is gone.
So the Pending is written to <config>/pending/<name>.json before anything
is broadcast, and re-running the same command picks it up:
┌─ WAITING ────────────────────────────────────────────────────────────────────┐
│ name pecucli7@ │
│ commitment bc1e12add8d97582a0814ed79afa094c5cbb5d5ad165239baf265fe923c5 │
│ a07d │
│ confirmations 0 (still in the mempool) │
└──────────────────────────────────────────────────────────────────────────────┘
▸ run the same command again in a minute
One command runs both phases. pecu id register alice broadcasts the
commitment, waits for it to confirm, then reveals and pays — polling every 30
seconds up to --timeout minutes (20 by default). --no-wait gives the old
one-step-at-a-time behaviour, which is what a script wants.
Interrupting the wait costs nothing. The reservation is written to disk before the commitment is broadcast, so Ctrl-C, a timeout, or a closed laptop all leave a registration the same command picks up later. The waiting is a convenience on top of that, not a replacement for it.
ok step 1 of 2 — commitment bb0a644e…
▸ waiting for it to confirm. Interrupting is safe — the file above survives
waiting — in the mempool, 30s elapsed
waiting — in the mempool, 60s elapsed
ok broadcast — txid a290c464…
A commitment carries the expiry height it was built at, so one that never
confirms eventually stops being broadcastable — the signed bytes are refused
before they reach the mempool, and re-anchoring cannot move an expiry that is
already inside the signature. The saved reservation is then worth nothing but is
still enough to wedge every later attempt at that name, so --restart discards
it and claims the name again. Nothing was spent on the dead one.
A bad --primary or --min-sigs is refused before the commitment. Both are
first checked where the SDK builds step two, which runs only after the
commitment has confirmed — so an i-address in --primary, or a --min-sigs
above the number of primaries, used to cost a commitment and up to --timeout
minutes of polling before anything said so, and left a reservation escapable
only with --restart. Neither needs a node or a key, so both are refused before
the passphrase prompt. Primary addresses are transparent R-addresses: a
registration writes its primary condition as bare key hashes, so a VerusID
cannot be one — delegating control to another identity is what the revocation
and recovery authorities are for. A reservation already on disk that names an
impossible threshold says so before the poll, and points at --restart, rather
than failing at the reveal with advice about the node. Both refusals sit above
the --dry-run gate as well as the poll: pricing step two of a registration
that can never reach it would be a lie, so a dry run errors rather than
printing an estimate.
A referral makes you pay less, not more. Each referrer receives
fee / (levels + 2) and your outlay is fee * (levels + 1) / (levels + 2);
whatever the payouts do not consume is burned. On VRSCTEST — 100 coins, 3
levels — that is 80 out of pocket rather than 100:
│ fee 80.00000000 VRSCTEST reduced from 100.00000000 by the referral │
│ referral pecucli7@ │
│ to referrers 20.00000000 VRSCTEST across 1 level │
│ burned 60.00000000 VRSCTEST │
A referrer who was itself referred is paid too, one output per level, nearest first — and the registrant's outlay does not change with depth. Only the split between payouts and burn does.
Both depths proven on chain rather than argued:
| depth | transaction | payout outputs | burned | outlay |
|---|---|---|---|---|
| 0 | 9129ede5… |
none | 100 | 100 |
| 1 | 0ccfe028… |
1 × 20 | 60 | 80.000224 |
| 2 | 6ab375a6… |
2 × 20 | 40 | 80.000244 |
| 3 | 60a76a8b… |
3 × 20 | 20 | 80 |
| 4+ | — | 3 × 20, capped | 20 | 80 |
The depth-2 transaction carries two payout outputs, in that order, and both identities' balances moved by exactly 20. The outlay differs only by miner fees.
Registration works at every depth, and the last row is not an assumption.
idreferrallevels is 3 on VRSCTEST, and the chain walk truncates to that
before the transaction is built — so a request at depth 4 hands the builder the
same three-entry chain a depth-3 request does. There is no distinct depth-4
transaction to fail. Measured: asking for a fourth level under pecudepth3@
produces the same 60/20 split against the same 80.
The cap is silent, though. Anyone further back receives nothing and is told nothing, so the panel says so when a chain reaches it — a referrer who was quietly dropped has no other way to find out.
The walk reads each referrer's own registration transaction and takes its payout outputs, which is how a chain is discovered at all rather than declared.
Both numbers come from the currency being registered under, not the chain,
so they differ per currency. The SDK's registration_fee is policy before the
discount — showing that beside a referral overstated the cost by a fifth and
called money burned when part of it is a payment to somebody.
--dry-run costs nothing and writes nothing. It prepares the registration,
prints what it would cost, and stops before both the commitment and the saved
file — a saved registration whose commitment was never broadcast would send the
next run to poll for a transaction nobody made. A --primary or --min-sigs
the reveal could never accept is the exception: it is refused above that gate,
so there is no estimate to print and the run exits non-zero.
--json will not register without --yes. Same rule as pecu send, and
this one burns a hundred coins rather than moving them.
Both hold on the resumed half too. A run that finds a saved reservation
answers to the same two flags: --dry-run prints what finishing would cost,
straight off the file, and stops without polling, broadcasting, or touching the
reservation — including under --restart, which reports what it would discard
rather than discarding it. Saved controls the reveal could never accept are the
same exception: refused above the gate rather than priced. --json still
refuses to reveal the name and burn the hundred without --yes. The one thing
--json may do unconsented is read: --no-wait --json reports whether the
commitment has confirmed, which spends nothing.
The SDK makes the ordering hard to get wrong: complete exists only on
Pending<ReadyToRegister>, and the only way to hold one is a poll that saw the
commitment confirm. Running step two early is a compile error, not a spent
commitment.
The success message says broadcast, not registered — the identity does not
exist until the transaction is mined, and id show will say nothing is called
that until then.
pecu id update · revoke · recover · unlock#
pecu id update i7r29bDQ… --recovery guardian@ --allow-authority-change
pecu id revoke i7r29bDQ… --from guardiankey
pecu id recover i7r29bDQ… --from guardiankey --primary RNew… --min-sigs 1
Who may change what. The identity output's condition is 1-of-3, and
consensus validates the three branches independently, each guarding its own
fields:
| changing | needs |
|---|---|
primary_addresses, min_sigs |
the primary condition |
revocation_authority |
the revocation condition |
recovery_authority |
the recovery condition |
A freshly registered identity is all three at once, so its own keys can point either authority elsewhere. Once an authority names another identity, those keys can no longer move it and cannot take it back. That direction has no undo.
An identity that is its own recovery authority cannot be revoked. This is a
consensus rule, not a policy — identity.cpp refuses a revocation nobody could
undo. The trigger is recovery, not revocation: an identity may revoke itself
perfectly well as long as somebody else can recover it. It is refused here
before a signature exists:
Error: pecu::flow_failed
× building the revocation failed
╰─▶ recovery authority is the identity itself; revoking it would strand it
permanently
help: an identity that is its own recovery authority cannot be revoked:
nobody could undo it. Point recovery at another VerusID first with
`pecu id update --recovery <name@> --allow-authority-change`
Every failure here is caught locally, because consensus will not explain
itself. A revocation signed by the wrong authority comes back as
-26: 16: mandatory-script-verify-flag-failed — after the fee is spent, naming
neither which condition failed nor which authority was needed. So the flows read
the named authority, compare your keys against its primary addresses and
threshold, and refuse with both named. When the identity is still its own
authority that check is offline, decoded from the output script.
That pre-check is advisory whenever the authority is a different identity: every fact in it comes from the node, so a lying node can fail a valid revocation or pass an invalid one. It is a usability guard, not a security boundary.
Prefer the i-address for anything destructive. Naming an identity by
i-address is verified against the decoded object with no node involved. A name@
can only be checked against what the node itself reported, which catches a node
inconsistent with itself but not one that lies consistently. The panel says which
one is in play.
An update restates the whole identity, so everything you do not name is
carried through — decoded from the output script consensus reads, not from a
rendering of it. Verified rather than asserted: the no-op update at
00fecccd36f46e77a423de3f1027c31077a7452b3768dcf8cc65ae202eb5275c came back with
its contentmultimap intact.
--allow-authority-change is required for anything that touches control, and
is checked before the passphrase prompt. Publishing addresses nobody holds, or a
threshold nobody can meet, is the one mistake with no remedy — not for the
holder, not for the recovery authority, not for anyone.
Timelocks: two forms, and one of them cannot be unlocked by hand.
pecu id update i7r29bDQ… --lock-until 1200000 # absolute height
pecu id update i7r29bDQ… --unlock-delay 100 # locked until someone asks
pecu id update i7r29bDQ… --clear-timelock # remove one
pecu id unlock i7r29bDQ… --extra-blocks 100 # ask, and wait longer than the floor
timelock on an identity is either an absolute height or a relative delay,
and which one it is depends on FLAG_LOCKED. id show prints whichever it is,
and only when there is one:
TIMELOCK
unlock delay 10 blocks
state ! locked, and no unlock requested
An absolute height counts down from when it is mined and cannot be paused. A delay counts down from nothing at all until an unlock is requested — so it is locked indefinitely rather than until some height, and only the revocation and recovery authorities can act meanwhile.
id unlock is its own command because the height is not the caller's to
compute. Consensus measures the countdown from the transaction's own
nExpiryHeight, not from the tip, so the floor is delay + expiry — and the
expiry belongs to the transaction being built. Measured on VRSCTEST with a
10-block delay:
| tip when signed | 1,177,377 |
naive tip + delay |
1,177,387 — refused, 20 blocks short |
| what the flow published | 1,177,407 = delay + tip + expiry |
The refusal for a wrong height is mandatory-script-verify-flag-failed, naming
nothing, after the transaction is built and signed. A stolen key cannot shorten
a lock either — that was measured too, and it is the property the whole feature
rests on.
A timelocked identity cannot spend its own funds, and that is refused before
the transaction is built. send --from-identity on a locked identity used to
build and sign a perfectly good transaction and then collect
mandatory-script-verify-flag-failed, which names neither the identity nor the
height. Reported as chainvue/verus-rust-sdk#107 and fixed in the SDK, so the
refusal now costs no extra request and distinguishes the two forms: a height to
wait for, or a delay nobody has started — which has no height at all, and whose
remedy is pecu id unlock.
An over-long delay is refused rather than clamped. Consensus caps it at
MAX_UNLOCK_DELAY (~22 years). The daemon's own helper silently clamps instead
of erroring, which can hand back a lock decades shorter than the one asked for;
this refuses, before a key is unlocked.
Recovery without --primary brings the identity back under exactly the keys it
had when it was revoked — including any that were compromised, which is usually
not what a recovery is for. The panel says so either way.
pecu currency#
pecu currency show TST@
pecu currency launch mytoken@ --from mykey --supply 1000000
pecu currency launch mytoken@ --from mykey --mintable --preallocate iAlice…:500
pecu currency mint mytoken@ --to RComfCn4w…N9Hm --amount 1000
--register creates the defining identity if it is missing. A currency is
defined by an identity, so launching on a name that does not exist yet takes two
registrations' worth of waiting and 300 VRSCTEST. --register does the whole
thing in one command: register, wait for the commitment, reveal, wait to be
mined, then launch. --register-timeout bounds each of those two waits
separately, so a slow chain can take up to twice it. The start block is
measured after that wait, not before it. --start-in counts from the tip the
definition is actually built against, which with --register is the tip once
the registration is mined — so a registration that takes eight blocks does not
eat eight blocks of the offset and leave the launch refused for starting in the
past.
It stops rather than waiting for a registration it did not send. Two cases
end the command early instead of polling. A dry run stops: registering burns 100
VRSCTEST, so --dry-run will not do it, and with no identity on chain there is
nothing for the launch to be defined by and nothing to price a preview against —
pecu id register <name> --dry-run prices the registration on its own, and the
launch preview works once the identity exists. And a registration that does not
finish inside the one run stops too — --json stops at the commitment, and the
commitment may not confirm inside --register-timeout — because past that point
there is no reveal on its way to a block to wait for. Nothing is lost either
way: the reservation is on disk before anything is broadcast, and the same
command carries on from it.
It is opt-in and stays that way. A misspelled name is a plausible mistake and
registration burns 100 VRSCTEST — creating pecubaskt1@ because somebody typed
their basket's name wrong would be an expensive convenience. Without the flag a
missing identity is refused, and the refusal names the flag:
× reading the defining identity failed
help: a currency is defined by an identity, and that name is not on this
chain yet. Add --register to create it first, for 100 VRSCTEST on top
of the launch fee, or register it separately with `pecu id register`
A currency is something an identity becomes. There is no separate object: the currency's id is the defining identity's i-address, and consensus marks that identity so it can never define another. Closer to claiming a name than to deploying a contract.
┌─ CURRENCY ──────────────────────────────────────────────────────┐
│ name TST │
│ currency id iK2k8YH1jfR7RLmEZ3zac2Mkx5rxSgbMqg │
│ parent iJhCezBExJHvtyH3fGhNnt2NhU4Ztkf2yq │
│ kind token │
│ control centralized — the defining identity can mint more │
│ starts block 879,130 │
├─ PREALLOCATED ──────────────────────────────────────────────────┤
│ 200.0 VRSCTEST iK2k8YH1j…bMqg │
└─────────────────────────────────────────────────────────────────┘
options and proofprotocol are decoded, not printed. They are a bitfield
and an enum, they are what the currency is, and neither is inferable from the
name. options: 32 on a panel tells a reader nothing they can act on; token
does. The raw values are still in --json alongside the decoded ones.
The currency id row is the node's word too, and it is the row the rest of
the panel is meant to be checked against — so it goes through the same filter
the name above it does, budgeted at an address's exact width. An i-address is
exactly 34 characters, so a well-formed one prints whole; an answer longer than
that is not an address, whatever field it arrived in, and cannot forge a row
inside the box. The currency id on MINT and PRECONVERT and the into row
on CONVERT are filtered the same way, because each of those panels precedes a
spend. The launch panels are a different case: their id is re-encoded from the
currency id the launch itself produced, not reprinted from anything a node
said.
--supply becomes a preallocation to the defining identity, and has to. A
token's supply is the sum of its preallocations — initial_supply is read
only for a fractional currency, so setting it on a token produces one with no
supply at all. The panel shows the resulting preallocation rather than hiding
the substitution.
Decentralized by default. --mintable sets proofprotocol = 2, letting the
identity mint more later. It cannot be undone, and a fixed supply is the
property a holder can actually verify, so it is an opt-in.
Fractional baskets are reserve-backed: a share of each reserve, priced against an initial supply.
pecu currency launch mybasket@ --from key --supply 100 \
--reserve VRSCTEST:50 --reserve TST:50
┌─ WOULD LAUNCH ───────────────────────────────────────────────────────┐
│ identity pecudepth2@ │
│ currency id iSHPgvF7f4huHK5WZ52tURDkZxbkCvsYke │
│ kind fractional basket, token │
│ control decentralized — supply moves as reserves convert in │
│ and out │
│ starts block 1,178,783 │
│ fee 200.00000000 VRSCTEST │
│ txid 731eaf355203611e3dd69488ed6b4c535ac1d5d629c0585e0ea274 │
│ 699653a090 │
│ supply 100.00000000 the reserves are priced against this │
├─ RESERVES ───────────────────────────────────────────────────────────┤
│ 62.5% iJhCezBEx…f2yq │
│ 37.5% iK2k8YH1j…bMqg │
└──────────────────────────────────────────────────────────────────────┘
Percentages, not the raw weights consensus stores — those are fractions of
SATOSHIDEN, and asking for 25000000 instead of 25 invites an
off-by-a-factor that prices the basket wrongly forever. They must total exactly
100, checked before a node is reached or a key unlocked.
A basket reads --supply from a different field than a token does. A
token's supply is the sum of its preallocations; a basket's is initial_supply,
which every reserve price divides by — so a basket without one gets a price of
zero on every reserve, and is refused.
--mintable and --reserve do not compose. A basket mints and burns by
conversion; --mintable is the token idea of an issuer topping up a supply.
--contribute is refused, and the thing it sounded like is preconvert.
Seeding a reserve at launch means an extra value-bearing output funding it. The
SDK's launch builder emits seven outputs and never that one, and the launch
notarization it publishes in the same transaction states the reserves hold
nothing — so a definition naming contributions would claim backing nothing put
there, permanently, while not one satoshi left the signing key. This repo has
the artefact: pecudepth2@ (0b08811f…) went out with exactly such a
declaration and nothing behind it. pecu now refuses the flag before a key is
unlocked or a node is asked, and names pecu currency preconvert — which does
spend — as the way to put coins into a reserve before the start block. That is
also the answer to the --max-preconvert trap two paragraphs down: the
contribution a fractional basket needs from every reserve comes from
preconvert, not from the definition. The SDK has since learnt to build the
funding output (#129),
and the pin has moved — so what the refusal now waits on is one basket launched
with a seeded reserve and checked at the start block, not a dependency.
--conversion is refused too, and permanently. A fractional basket's
pre-launch price is not a number in its definition: consensus derives it at
launch as SATOSHIDEN³ / (initial supply × weight) and writes it into the
launch notarization published in the same transaction. The conversions field
the flag wrote is read by nothing, and the daemon zeroes it on the way in — a
definition created by passing [4.0] comes back carrying [0.0], and every
fractional definition in the SDK's captures of real daemon output has an
all-zero vector. The old flag was worse than useless: it required --reserve,
which is the one configuration where the field is derived and ignored, and the
confirmation panel printed the number back as rate beside genuinely effective
rows, right before the prompt that spends 200 VRSCTEST on something
unchangeable. The figure that does move the price is --supply, the
denominator every reserve price divides by — the one the panel labels the
reserves are priced against this. Unlike --contribute, this refusal has no
expiry: it is a consensus fact rather than an SDK gap.
The prelaunch economics, and the sub-identity policy. All of it is per reserve, and all of it is keyed by the reserve's name:
pecu currency launch mybasket@ --from key --supply 100 \
--reserve VRSCTEST:60 --reserve TST:40 \
--min-preconvert VRSCTEST:1 --min-preconvert TST:1 \
--max-preconvert VRSCTEST:1000 --max-preconvert TST:1000 \
--prelaunch-discount 5 --prelaunch-carveout 10 \
--id-registration-fee 25 --id-referral-levels 2 --id-import-fee 0.02
RESERVES
60% iJhCezBEx…f2yq min 1.00000000 max 1000.00000000
40% iK2k8YH1j…bMqg min 1.00000000 max 1000.00000000
discount 5% to anyone converting before launch
carveout 10% of the launch, to this identity
SUB-IDENTITIES
registration 25.00000000
referrals 2 levels optional
import 0.02000000
Keyed by name rather than by position, deliberately. The definition stores
these as vectors indexed by the reserve list, and serialize_definition refuses
one whose length disagrees — but a vector of the right length in the wrong
order is accepted, and prices the basket against the wrong currencies. Naming
the reserve removes the possibility instead of checking for it, and a reserve
you say nothing about gets zero rather than somebody else's number.
For --max-preconvert, name every reserve or name none. This one is a trap
worth spelling out, because it cost a real launch. A cap of zero is "nothing
accepted", not "no limit" — consensus refunds anything over the cap
(GetRefundTransfer, not a rejection), and once the vector exists at all a
reserve nobody named is a zero rather than an absence. Meanwhile a fractional
basket refunds the entire launch unless every one of its reserves receives a
contribution (notarization.cpp:1474). So capping one reserve of two silently
guarantees the launch fails — hours later, at the start block, with the 200
VRSCTEST gone.
Naming none is safe and common: an empty vector is never consulted, so every
reserve stays uncapped. pecu refuses the half-named case at launch, which is
the last moment anything can be changed:
× --max-preconvert names some reserves but not `dude-test-centralized`,
│ which caps it at zero
help: a cap of zero means nothing is accepted into that reserve, not that it
is unlimited — and a fractional basket refunds the entire launch unless
every reserve receives a contribution…
preconvert refuses the same thing from the paying side, and its panel lists
what each reserve holds so far — an empty leg is the difference between a
contribution working and coming back.
--id-referral-levels sets the referral option bit on its own: consensus pays
referrals only when the bit says to, so a level count without it publishes a
policy that never applies.
Everything above appears on the panel before you confirm, because none of it can be changed afterwards.
Still absent: notaries and min_notaries_confirm, which only matter
cross-chain, and gateway_converter_issuance, which belongs to the gateway case
the SDK refuses outright.
It costs currencyregistrationfee, 200 VRSCTEST at the time of writing, read
from the parent's chain policy rather than assumed — except for an NFT, which is
charged the parent's idimportfees instead, 0.02. Consensus picks between the
two on the tokenized-control bit, so --nft pins the fee rather than taking the
one the flow would read.
pecu currency mint#
pecu currency mint mytoken@ --to RComfCn4wHHsGR8vWBAU7T1r3tHHyxN9Hm --amount 1000
Only for a currency launched --mintable. proofprotocol = 2 is the whole
permission system, and it is decided once, at launch.
The identity pays, not the signing key. This is the part that catches people, so it is on the panel in its own row. Consensus accepts new supply only from a transaction that spends an output the controlling identity holds — the controlling identity being the currency itself, same i-address. A wallet with a well-funded key and an empty identity cannot mint, and the refusal says so:
× minting failed
╰─▶ i49TaUGBXA4ZHbybQe3tw1r58BhCW361SC holds no spendable outputs; a mint is
paid for by the identity — send() it some coins first
help: a mint is paid for by the identity, not by the signing key — consensus
accepts new supply only from a transaction that spends what the
identity holds. Send it some native coins first: `pecu send --to
pecurefcur1@ --amount 1`
┌─ MINT ──────────────────────────────────────────────────────────────────┐
│ currency pecuref9 │
│ currency id iKh6DBXjPVU72BBD4sq5qbdFFeQGVcYokg │
│ amount 1000.00000000 pecuref9 new supply, created by this │
│ to RComfCn4wHHsGR8vWBAU7T1r3tHHyxN9Hm │
│ paid by pecuref9 the identity's own coins, not the key's │
│ signed by faucet RComfCn4w…N9Hm │
│ fee 0.00020000 VRSCTEST │
└─────────────────────────────────────────────────────────────────────────┘
▸ this currency is centralized — its supply is whatever its identity
decides, and every holder is trusting that
The recipient must be a transparent R-address, and that is an SDK limit
rather than a protocol one, and it is a limit pecu now keeps on its own.
Consensus treats DEST_ID as a first-class reserve transfer destination —
sendcurrency pays identities routinely — and build_conversion used to write
every recipient as Destination::PubKeyHash, discarding the address kind. An
i-address run through that would have paid the R-address sharing its hash, which
nobody holds a key to.
The SDK maps AddressKind::Identity to Destination::Identity now
(chainvue/verus-rust-sdk#115),
so the refusal here is no longer describing the SDK — it is waiting for somebody
to pay an identity a token and read it back off the chain as the identity's
holdings rather than a key holder's.
The same limit means a token cannot be paid to a VerusID at all — pecu send
--to <name@> --currency <token> is refused for the same reason, while the same
command without --currency sends native coins to an identity fine. So an
identity can hold native coins but not tokens, which is awkward exactly where it
matters most: the issuing identity is the natural place for a centralized
currency's treasury, and it is the one destination a mint cannot name.
There is no workaround that means the same thing, and the error text says so. Minting to one of the identity's primary addresses puts the tokens with whoever holds that key — a different owner with different authority, not the identity's own holdings.
The two refusals that are not about permissions are worth keeping distinct. A decentralized currency has no authority that could add to it, which is the property its holders can verify — not a lock to be worked around. A fractional basket has no issuer at all: its supply grows when reserves convert in and shrinks when they convert out.
pecu currency preconvert#
pecu currency preconvert mybasket@ --amount 10 --from mykey
pecu currency preconvert mybasket@ --amount 10 --spend TST --from mykey
Buys into a currency before it launches, at the launch price. --spend
defaults to the chain's own currency and must name one of the target's reserves;
--to defaults to the paying key.
There is no estimate, and the panel does not pretend otherwise. A launching currency has no reserves, so there is nothing to price against — what a contribution pays out is settled at the start block, from the final ratio of everyone's contributions together. Two identical commands a day apart can pay out differently because other people contributed in between. The SDK refuses a slippage floor here by name for the same reason, so the command never offers one; a floor could only be checked against a number nobody produced.
┌─ PRECONVERT ─────────────────────────────────────────────────────────────┐
│ into pecubask1 │
│ currency id i9dpvtcsH6FRD4UmNVur75cLXj7rUx9iD1 │
│ spending 5.00000000 VRSCTEST │
│ you receive settled at launch from the final ratio of every │
│ contribution │
│ to RComfCn4wHHsGR8vWBAU7T1r3tHHyxN9Hm │
│ launches block 1,179,161 153 blocks to go │
│ fee 0.00020000 VRSCTEST │
└──────────────────────────────────────────────────────────────────────────┘
▸ if the launch misses its minimum, every contribution is refunded —
including this one, to the paying key
▸ over the maximum is refunded too, rather than refused, so this can come
back even if the launch succeeds
Preconvert and convert are never both valid. Before the start block a plain conversion is refused for want of reserves; after it a preconversion is refused in turn. Which one applies is decided entirely by the height, so this refuses locally and names the block rather than letting the chain answer:
× `pecudepth2` launched at block 1178834, and the tip is 1179008
help: a preconversion buys at the launch price and is only accepted before
the start block. Afterwards the currency has reserves and an ordinary
conversion is the thing that works — the two are never both valid
Two ways a contribution comes back, both worth knowing before sending one.
A launch that misses its min_preconversion refunds everyone. And a
contribution that pushes a reserve past its max_preconversion is refunded
rather than refused — consensus calls GetRefundTransfer rather than
rejecting the transaction, so it can come back even when the launch succeeds.
The panel shows both figures when the definition sets them.
Paying in a currency the target is not backed by is also refunded rather than refused, so that one is checked here against the definition's reserve list — a mistake that would otherwise cost a wait rather than an error.
pecu currency convert#
Once a basket has launched, value moves three ways — and consensus writes all
three as the same CReserveTransfer, differing only in which currency each slot
names:
pecu currency convert mybasket@ --amount 10 # a reserve into the basket
pecu currency convert VRSCTEST --amount 10 --spend mybasket@ # the basket back into a reserve
pecu currency convert SPORTS --amount 1 --via bankroll # one reserve into another
Which shape you mean is inferable from the definitions, so it is inferred rather than asked for — and then stated on the panel, because guessing silently would be worse than asking, and saying which guess was made is better than both.
┌─ WOULD CONVERT ──────────────────────────────────────────────────────────┐
│ spending 1.00000000 VRSCTEST │
│ into SPORTS iGhBps9rmbN7U544dZY7nx2rfg26QTh1zY │
│ through bankroll one reserve into another, priced by the basket │
│ you receive 15897.04750000 SPORTS estimated, not guaranteed │
│ at least 15000.00000000 SPORTS checked now, not by the chain │
│ fee 0.00020000 VRSCTEST │
└──────────────────────────────────────────────────────────────────────────┘
Unlike a preconversion, this has a price — a launched basket has reserves,
so the node can estimate. That is what makes --min-out meaningful here and
impossible before launch. The floor is checked before signing and never
again: the chain does not enforce it, so if the price moves after broadcast
the conversion still happens at whatever the reserves make it.
Three refusals worth having, all local:
- Not launched yet — points at
preconvert, which is the thing that works before the start block. The exact mirror ofpreconvert's own check; the two are never both valid. - Launch refunded — a basket whose launch failed still reads as a live currency definition but holds nothing and never will. Without this the only signal is an estimate of zero.
- Neither side is a basket — names the three shapes rather than reporting that the chain refused.
Launched on VRSCTEST rather than argued about, each on its own identity because a slot is one-shot:
| combination | identity | transaction |
|---|---|---|
| decentralized token, fixed supply | pecudepth3@ |
2fecffbb… |
centralized token, proofprotocol 2 |
pecuref9@ |
8764a045… |
| fractional basket, min/max preconvert, 5% discount, 10% carveout | pecudepth2@ |
0b08811f… |
| centralized, governs sub-identity registration: 25 fee, 3 referral levels, mandatory | pecurefcur1@ |
3205c03f… |
| NFT | pecunft1@ |
refused — see below |
| mint — 1,000 new supply on a centralized token | pecuref9@ |
e8c9d409… |
| preconvert — 5 VRSCTEST into a pre-launch basket | pecubask1@ |
0bb8a7ae… |
convert — 1 VRSCTEST into a live basket, --min-out floor honoured |
triccrypto2 |
68c8363c… |
--nft is built, and the chain has not accepted one yet. An NFT is a
currency-mapped token: options 2080, one satoshi of supply preallocated to
the defining identity, and — non-obviously — currencies = [parent] despite not
being fractional, because consensus requires maxPreconvert.size() == 1 and the
per-reserve vectors are indexed by the reserve list. pecu builds all of that,
and the transaction decodes field-for-field identical to a working on-chain NFT
across all seven outputs.
The launch that was tried came back -25: bad-txns-failed-precheck, and pecu
used to answer that with one cause: an identity with tokenized control carries a
second destination on its recovery condition — the key hash of the
EVAL_IDENTITY_RECOVER contract pubkey, a constant — and the SDK's identity
output script did not emit it, so consensus derived a different script.
That answer is no longer honest, so pecu no longer gives it.
identity_primary_script takes the tokenized-control flag now
(chainvue/verus-rust-sdk#111)
and tests/upstream.rs asserts offline that the destination really is emitted;
meanwhile DeFi is switched off chain-wide on VRSCTEST, so -25 is what the chain
answers to any currency launch while that lasts, whatever it contains. Either
would make "the SDK builds the identity output wrong" a confident guess. So the
diagnostic names both candidates and picks neither.
It also stops claiming nothing was spent. Since the SDK pin moved, a -25 comes
back as BroadcastUncertain rather than a rejection — a -25 says a check
failed, not that the transaction was refused — so pecu keeps the txid, keeps
the signed bytes, and tells you to check before resending. That is the same
advice every broadcast in this tree gives when the node does not settle the
outcome; see the tri-state table under pecu send.
--explain#
Any command takes it. It prints the verus-sdk calls that command actually
made, with the arguments it passed and a summary of what came back:
┌─ SDK CALLS ────────────────────────────────────────────────────────┐
│ verus_sdk::network::prepare_send(&node, &key, "iJhCe…", "0.1") │
│ → Unsent<Sent> { txid: a3f1…, fee: 0.0001, change: 9.8999 } │
│ │
│ unsent.broadcast(&node) │
│ → Sent { txid: a3f1… } │
└────────────────────────────────────────────────────────────────────┘
It prints on the failure path too, which is when it is most useful.
Note this is not a tracing layer. verus-sdk emits no spans, so the events
would have to be written at the call site regardless — and then a subscriber is
pure ceremony between a debug! and a println!. The cost of recording
explicitly is that it is only as accurate as the call sites keep it.
pecu key#
Keys live in an encrypted keystore: one file per key at
~/.config/verus-pecu/keys/<label>.json, mode 0600.
pecu key gen --label demo # a random key
pecu key gen --label paper --from-phrase --show-phrase # recoverable from paper
pecu key list
pecu key show demo
pecu key export demo --yes # prints the private key
pecu key phrase # a phrase, stored nowhere
┌─ RECOVERY PHRASE ───────────────────────────────┐
│ 1. pudding 7. caution 13. away 19. pizza │
│ 2. elite 8. nest 14. level 20. use │
│ 3. nothing 9. crumble 15. spell 21. sauce │
│ 4. rent 10. focus 16. pair 22. dwarf │
│ 5. solution 11. action 17. first 23. nasty │
│ 6. device 12. aim 18. try 24. camp │
└─────────────────────────────────────────────────┘
▸ write this down, on paper, now — it is shown once and is not stored
How it is protected. Argon2id (19 MiB, 2 passes, 1 lane — the OWASP interactive figure) derives a key from your passphrase; ChaCha20-Poly1305 seals the 32 private key bytes under it. The envelope's metadata — version, label, address, compression flag — is authenticated as associated data, so editing the address in a key file produces a decryption failure rather than a key that silently belongs to a different address than it claims. The KDF parameters travel with each file, so raising the cost later never strands an old key.
That defends a stolen file against an offline guess. It does not defend a running
process: once unlocked, the key is in memory, held in Zeroizing wrappers and
wiped on drop, which narrows the window without closing it.
Where the entropy comes from. verus-keys deliberately offers no
PrivateKey::generate — where the bytes come from is the most security-critical
decision a wallet makes, and a library that picks quietly moves it somewhere
nobody reviews. So it is in src/keystore.rs, in the open, and it is the OS
CSPRNG via getrandom.
Two key schedules, one phrase. The same 24 words drive both sides of Verus by
different routes: the shielded side goes BIP-39 → seed → ZIP-32, and the
transparent side ignores BIP-39 entirely and hashes the phrase text verbatim.
pecu key phrase shows all three so the difference is visible. Because the text
is hashed verbatim, an imported phrase is never trimmed.
Secrets never go on the command line. A WIF or a phrase in argv lands in
your shell history and in the process list of every other user on the machine, so
key import reads from a no-echo prompt, or from stdin when it is piped:
pecu key export demo --yes --json | jq -r .wif | pecu key import --label copy
PECU_PASSPHRASE supplies the encryption passphrase for scripts and tests. It
deliberately does not supply the imported key — key import needs two
different secrets in one run, and one variable cannot provide both without
silently using the same value for each.
key export refuses to run without --yes, and says why first.