feat(arrr): validated JSON send contract with typed errors (send protocol v1) - #340
Open
QuickMythril wants to merge 2 commits into
Open
QuickMythril wants to merge 2 commits into
QuickMythril wants to merge 2 commits into
Conversation
…ocol v1)
PR1 of the ARRR send plan (AGENTS projects/qortium-arrr-send/PLAN.md):
synchronous send stays (HTTP 200 with txid) but every input is validated
before wallet work and every refusal carries a stable reason token.
- ApiError: FOREIGN_WALLET_NOT_READY (1205/503), FOREIGN_SEND_NOT_FOUND
(1206/404), FOREIGN_SEND_CONFLICT (1207/409), FOREIGN_SEND_STORAGE_ISSUE
(1208/500) with messages in the root bundle and all 23 locales; the
messages carry stable tokens (WALLET_NOT_READY, SEND_NOT_FOUND, ...).
- PirateChainSendRequest is now all-String {entropy58, receivingAddress,
arrrAmount (decimal text), memo, idempotencyKey (canonical lowercase
UUID, required), feePerByte (deprecated: any non-null value -> 125)}.
New PirateChainSendResult {txid, feeAtomic "10000", feePolicy FIXED,
sendProtocolVersion 1} and GET /crosschain/arrr/sendcontract
(PirateChainSendContract; apiKey, no entropy).
- PirateChainAmountAdapter.parseAtomic: plain decimal regex, at most 8
decimals, movePointRight(8).longValueExact(), rejects zero and anything
above the 2e16-atomic max supply; the shared AmountTypeAdapter is untouched.
- PirateChain.isCanonicalSaplingAddress (lowercase, 78 chars, zs hrp,
Bech32 not Bech32m, 43-byte payload); isValidAddress now delegates, which
tightens the trade paths too. validateMemo (<=512 UTF-8 bytes, no lone
surrogates, no controls except tab/CR/LF, no DEL/C1). sendCoins(entropy58,
address, amountAtomic, memo): legacy backend / disabled wallet ->
WalletNotReadyException instead of NPE; in-lane verified-funds check
(verified balance must be known and cover Math.addExact(amount, fee));
native error text reduced to ARRR_INSUFFICIENT_VERIFIED_FUNDS /
ARRR_NATIVE_SEND_FAILED and never echoed.
- ForeignBlockchainException.WalletNotReadyException (stable-message
subclass like WalletBusyException).
- Resource /send: JSON in/out with OpenAPI schemas; static
validateSendRequest in the documented order (body 115 -> entropy 128 ->
feePerByte 125 -> idempotencyKey 125 -> amount 125 -> address 102 with
RECIPIENT_UNSUPPORTED for non-zs prefixes -> memo 115); legacy backend
-> 125; disabled -> 1205; busy -> 9/409; insufficient -> 1202; not-ready
-> 1205; everything else -> 1201. apiKey only (no loopback, decision D8).
Tests: PirateChainAmountAdapterTests, PirateChainSendValidationTests
(in-test Bech32 vectors incl. bech32m/uppercase/checksum/hrp/42-44 byte/
non-zero padding, memo bytes/controls/surrogates, funds incl. addExact
overflow, sanitized native errors), PirateChainSendApiSerializationTests
(MOXy accepts "1.5" and 1.5 into the String field), resource tests
(validation matrix order, null body 400, legacy 125, disabled 1205 not
NPE, sendcontract). Keep-green list from the plan passes; package builds.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ient check, honest outcomes Review fixes for the v1 send contract (PR #340, Codex money-path review): 1. Strict JSON reader (PirateChainSendRequestReader, package-scanned @Provider) replaces the generic MOXy binding for PirateChainSendRequest: arrays/objects/booleans, duplicate and unknown fields, trailing content and oversized bodies are 400 INVALID_DATA "MALFORMED_BODY"; a JSON number for arrrAmount/feePerByte is kept as its original lexical text, so 1e0 is now rejected by the amount parser. Reviewer vectors ([1,2], 1e0, [], ["first","last"]) are tested through the reader + validateSendRequest and through a real Jersey stack (reader wins over MOXy/Jackson). 2. Amount cap corrected to 20_000_000_000_000_000 atomic (2e16 = 200M ARRR): "200000000" accepted, "200000000.00000001" rejected, asserted as literals. 3. Native recipient validation in-lane before any spend: the bundled Stashi v1.2.4 library exposes invokeJson {"method":"validate_address","address"} (probed: needs no wallet/storage/network; 43x0xff, all-zero and uppercase vectors -> is_valid:false). is_valid:false or a non-Sapling type -> InvalidRecipientException -> 102; no native answer -> 1205. Native send errors naming an invalid recipient also map to 102, sanitized. 4. Unified get_balance without a spendable figure is a typed BalanceUnavailableException from parseTypedBalance (reads: 1204); sendCoins maps it to WalletNotReadyException(ARRR_VERIFIED_BALANCE_UNKNOWN) -> 1205. 5. Outcome honesty: SendOutcomeUnknownException(ARRR_SEND_OUTCOME_UNKNOWN) for a thrown native send, a lane timeout/interrupt after the send started (sendStarted/sendAnswered flags around the native call), or a reply with neither txid nor error; the resource keeps 1201 but appends explicit "may already have been broadcast; do NOT retry until history checked" guidance. Only an explicit native error reply stays a definitive ARRR_NATIVE_SEND_FAILED. idempotencyKey docs now say v1 does not deduplicate and retrying an unknown outcome can pay twice. 6. PirateChainSendNativeFlowTests: scripted native adapter behind the REAL coordinator (adapter swapped by reflection), FakeController with real ownership/lane logic and a PirateSendTestWallet whose balance/unlock/ export/send all go through the adapter. Proves activation -> resource -> synchronized gate (height/tip/syncStatus) -> validate_address -> balance -> unlock -> export -> exactly ONE send with the exact atomic amount, fixed fee, input address and verbatim memo, returning the JSON result; plus invalid point -> 102, missing spendable -> 1205, insufficient -> 1202, native error -> 1201 sanitized, garbage reply -> outcome unknown, native throw -> SendOutcomeUnknownException, validation outage -> 1205, other account -> 409. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
QuickMythril
force-pushed
the
feat/arrr-send-contract-v1
branch
from
September 28, 2026 13:50
9b8cd48 to
dc12886
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PR1 of the ARRR send plan (
~/AGENTS/projects/qortium-arrr-send/PLAN.md, derived fromreports/wallet-review-2026-09-20/arrr-c1-design.md§1–2). The send stays synchronous (POST /crosschain/arrr/send→ HTTP 200 with the txid,sendProtocolVersion: 1); this PR makes the contract explicit and validates everything before any wallet work. No journal, no async, no activation/readiness changes (PR2/PR3). Rebased onto #339.FOREIGN_WALLET_NOT_READY1205/503,FOREIGN_SEND_NOT_FOUND1206/404,FOREIGN_SEND_CONFLICT1207/409,FOREIGN_SEND_STORAGE_ISSUE1208/500 — root bundle + 23 locales, each message carrying a stable token, same pattern as feat(arrr): truthful structured sync status, verified-balance semantics, cross-wallet 409 (read contract) #328's 1204. 1206–1208 are reserved for PR3 (D1).PirateChainSendRequestReader(@Provider, package-scanned likeApiExceptionMapper) replaces the generic MOXy binding forPirateChainSendRequest: every field must be a scalar;arrrAmount/feePerByteaccept a JSON string or a JSON number whose original lexical text is kept (so1e0reaches — and fails — the amount parser); arrays, objects, booleans, duplicate/unknown fields, trailing content and bodies over 16 KiB are 400/115MALFORMED_BODY: …(never echoing the body); an empty body reads as null →MISSING_BODY.PirateChainSendRequest(idempotencyKeyrequired canonical lowercase UUID — validated, not deduplicated in v1, docs say retrying an unknown outcome can pay twice;feePerBytedeprecated, any non-null → 125). NewPirateChainSendResult {txid, feeAtomic:"10000", feePolicy:"FIXED", sendProtocolVersion:1}andGET /crosschain/arrr/sendcontract(apiKey, no entropy).PirateChainAmountAdapter.parseAtomic:^(0|[1-9][0-9]*)(\.[0-9]{1,8})?$,movePointRight(8).longValueExact(), rejects zero/exponent/rounding/overflow and anything above the ARRR max supply20_000_000_000_000_000atomic = 200,000,000 ARRR ("200000000"accepted,"200000000.00000001"rejected, asserted as literals). SharedAmountTypeAdapteruntouched.PirateChain.isCanonicalSaplingAddress(lowercase, 78 chars,zshrp, Bech32 not Bech32m, 43-byte payload) for the pre-admission check;isValidAddressdelegates — this tightens the trade paths (CrossChainTradeBotResource,PirateChainACCTv3TradeBot), noted in the changelog. Then, in-lane, the native wallet validates the recipient semantically viainvokeJson {"method":"validate_address","address":…}before any spend;is_valid:falseor a non-Saplingaddress_type→InvalidRecipientException→ 102; no usable native answer → 1205ARRR_RECIPIENT_VALIDATION_UNAVAILABLE. Native send errors naming an invalid recipient also map to 102, sanitized.validateMemo: ≤512 UTF-8 bytes, no lone surrogates, no controls except tab/CR/LF (DEL and C1 included), never trimmed or truncated.PirateChain.sendCoins(entropy58, address, amountAtomic, memo): legacy backend → 125 (D5); disabled wallet → 1205ARRR_WALLET_DISABLED(was an NPE); in-lane after the synchronized gate: native recipient validation →wallet.getWalletBalances(nativeAdapter)(a Unifiedget_balancewith missing/nullspendableis now a typedBalanceUnavailableExceptionfromparseTypedBalance, mapped to 1205ARRR_VERIFIED_BALANCE_UNKNOWNhere and to the existing 1204 on reads) →Math.addExact(amount, MAINNET_FEE) <= verifiedelse 1202 → unlock → export → exactly one nativesend.SendOutcomeUnknownException(ARRR_SEND_OUTCOME_UNKNOWN)for: the native send call throwing, a lane timeout/interrupt/degrade after the send started (sendStarted/sendAnsweredflags around the native call — the coordinator's started-timeout path lands in the genericForeignBlockchainExceptionbranch ofwithWallet, which is exactly what these flags intercept), or a reply with neither txid nor error. The resource keeps 1201/500 but the message isARRR_SEND_OUTCOME_UNKNOWN: the payment may already have been broadcast; do NOT retry until the wallet history has been checked for this send (protocol version 1 does not deduplicate). Only an explicit nativeerrorreply is a definitiveARRR_NATIVE_SEND_FAILED./sendJSON in/out with OpenAPI schemas and@ApiErrors; staticvalidateSendRequestorder: body 115 → entropy 128 → feePerByte 125 → idempotencyKey 125 → amount 125 → address 102 (RECIPIENT_UNSUPPORTEDfor non-zs1prefixes) → memo 115; then legacy 125 → disabled 1205; mapping outcome-unknown → 1201(+guidance), busy → 9/409, invalid recipient → 102, insufficient → 1202, not-ready → 1205, other → 1201.checkApiCallAllowedkept; norequireLoopbackRequest(D8).D2 finding (input address / Ironwood)
Checked
PirateWallet.java:1285-1311and the bundled v1.2.4qortal-handoff.md. In Unified mode Core's sendinputcomes fromexport[0].address, and the handoff statesexport"returns Sapling key material before Ironwood and the matching Ironwood address and keys afterward" andsend"selects the key group identified by the supplied wallet-owned input address; note selection also includes that group's internal change so post-Ironwood funds remain spendable". So after Ironwood activation the input can be thepirate1…address of the same key group. Whether the native wallet then restricts note selection to the input's pool is not stated in any local source (Rustqortal.rs/tx_flow.rsnot available offline), so the input is not pinned to the Sapling address: pinning could silently exclude post-activation Ironwood notes if pool follows input. The funds check uses the wallet-wide verified/spendable total, which over-approximates only when imported key groups exist; the native wallet's own per-key-group insufficiency then maps to 1202. Gap to resolve at PR2/upstream: confirm pool/key-group selection semantics forsendin Stashi v1.2.4.Native
validate_address(observed, review finding 3)Probed the bundled
librust-linux-x86_64.so(v1.2.4) throughLiteWalletJniAdapter.invokeJsonwith no storage, wallet or network:{"method":"validate_address","address":"zs1ra3g8…f60s"}→{"ok":true,"result":{"address_type":"Sapling","is_valid":true,"reason":null}}; 43×0xff, all-zero payload, uppercase andt1…→{"ok":true,"result":{"address_type":null,"is_valid":false,"reason":"Invalid shielded address. Supported formats start with \"zs1\" or \"pirate1\"."}}; missing field →{"ok":false,"error":"Invalid request JSON: missing fieldaddress"}. Core does not hand-roll curve math; it calls this in-lane before every send.Deviations / notes against PLAN.md
MiscTestsmatches three classes (assets/group/naming); all run.parseTypedBalancecontract refined: a reply withtotalbut nospendableis now the typedBalanceUnavailableException(message containsBALANCE_UNAVAILABLE) instead of a generic "Unable to determine balance" —/walletbalancetherefore returns 1204 for that case (was 1201). Malformed values still fail closed generically.isValidAddresstightening is a small behavior change for trade-bot address checks (uppercase/Bech32mzsstrings no longer accepted)./feekb,/feerequiredremain trade-only); D4 blocking scope of an UNRESOLVED operation (PR3).Tests (surefire, per class, on the rebased head)
PirateChainSendNativeFlowTestsproves: activation →/send→ synchronized gate (height/info/syncStatus) →validate_address→get_active_wallet/get_balance→encryptionstatus(unlock) →export→ exactly onesendwithinput= export address,fee10000,amount150000000 for"1.5", memo (héllo "quoted" back\slash\nsecond line\t😀) round-tripped verbatim,PirateChainSendResultwith the native txid; plus invalid-point recipient → 102 with zero sends, missingspendable→ 1205, insufficient → 1202, native error → 1201 sanitized, txid-less reply → outcome unknown, native throw →SendOutcomeUnknownException, validation outage → 1205, other account → 409.mvn -DskipTests packagebuildstarget/qortium-1.8.0.jar.🤖 Generated with Claude Code