Skip to content

feat(arrr): validated JSON send contract with typed errors (send protocol v1) - #340

Open
QuickMythril wants to merge 2 commits into
mainfrom
feat/arrr-send-contract-v1
Open

QuickMythril wants to merge 2 commits into
mainfrom
feat/arrr-send-contract-v1

Conversation

@QuickMythril

@QuickMythril QuickMythril commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Summary

PR1 of the ARRR send plan (~/AGENTS/projects/qortium-arrr-send/PLAN.md, derived from reports/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.

  • Error codes FOREIGN_WALLET_NOT_READY 1205/503, FOREIGN_SEND_NOT_FOUND 1206/404, FOREIGN_SEND_CONFLICT 1207/409, FOREIGN_SEND_STORAGE_ISSUE 1208/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).
  • Strict request reader PirateChainSendRequestReader (@Provider, package-scanned like ApiExceptionMapper) replaces the generic MOXy binding for PirateChainSendRequest: every field must be a scalar; arrrAmount/feePerByte accept a JSON string or a JSON number whose original lexical text is kept (so 1e0 reaches — and fails — the amount parser); arrays, objects, booleans, duplicate/unknown fields, trailing content and bodies over 16 KiB are 400/115 MALFORMED_BODY: … (never echoing the body); an empty body reads as null → MISSING_BODY.
  • DTOs all-String PirateChainSendRequest (idempotencyKey required canonical lowercase UUID — validated, not deduplicated in v1, docs say retrying an unknown outcome can pay twice; feePerByte deprecated, any non-null → 125). New PirateChainSendResult {txid, feeAtomic:"10000", feePolicy:"FIXED", sendProtocolVersion:1} and GET /crosschain/arrr/sendcontract (apiKey, no entropy).
  • Amount 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 supply 20_000_000_000_000_000 atomic = 200,000,000 ARRR ("200000000" accepted, "200000000.00000001" rejected, asserted as literals). Shared AmountTypeAdapter untouched.
  • Address PirateChain.isCanonicalSaplingAddress (lowercase, 78 chars, zs hrp, Bech32 not Bech32m, 43-byte payload) for the pre-admission check; isValidAddress delegates — this tightens the trade paths (CrossChainTradeBotResource, PirateChainACCTv3TradeBot), noted in the changelog. Then, in-lane, the native wallet validates the recipient semantically via invokeJson {"method":"validate_address","address":…} before any spend; is_valid:false or a non-Sapling address_type → InvalidRecipientException → 102; no usable native answer → 1205 ARRR_RECIPIENT_VALIDATION_UNAVAILABLE. Native send errors naming an invalid recipient also map to 102, sanitized.
  • Memo validateMemo: ≤512 UTF-8 bytes, no lone surrogates, no controls except tab/CR/LF (DEL and C1 included), never trimmed or truncated.
  • Send PirateChain.sendCoins(entropy58, address, amountAtomic, memo): legacy backend → 125 (D5); disabled wallet → 1205 ARRR_WALLET_DISABLED (was an NPE); in-lane after the synchronized gate: native recipient validation → wallet.getWalletBalances(nativeAdapter) (a Unified get_balance with missing/null spendable is now a typed BalanceUnavailableException from parseTypedBalance, mapped to 1205 ARRR_VERIFIED_BALANCE_UNKNOWN here and to the existing 1204 on reads) → Math.addExact(amount, MAINNET_FEE) <= verified else 1202 → unlock → export → exactly one native send.
  • Outcome honesty SendOutcomeUnknownException(ARRR_SEND_OUTCOME_UNKNOWN) for: the native send call throwing, a lane timeout/interrupt/degrade after the send started (sendStarted/sendAnswered flags around the native call — the coordinator's started-timeout path lands in the generic ForeignBlockchainException branch of withWallet, which is exactly what these flags intercept), or a reply with neither txid nor error. The resource keeps 1201/500 but the message is ARRR_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 native error reply is a definitive ARRR_NATIVE_SEND_FAILED.
  • Resource /send JSON in/out with OpenAPI schemas and @ApiErrors; static validateSendRequest order: body 115 → entropy 128 → feePerByte 125 → idempotencyKey 125 → amount 125 → address 102 (RECIPIENT_UNSUPPORTED for non-zs1 prefixes) → 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. checkApiCallAllowed kept; no requireLoopbackRequest (D8).

D2 finding (input address / Ironwood)

Checked PirateWallet.java:1285-1311 and the bundled v1.2.4 qortal-handoff.md. In Unified mode Core's send input comes from export[0].address, and the handoff states export "returns Sapling key material before Ironwood and the matching Ironwood address and keys afterward" and send "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 the pirate1… 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 (Rust qortal.rs/tx_flow.rs not 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 for send in Stashi v1.2.4.

Native validate_address (observed, review finding 3)

Probed the bundled librust-linux-x86_64.so (v1.2.4) through LiteWalletJniAdapter.invokeJson with 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 and t1… → {"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 field address"}. Core does not hand-roll curve math; it calls this in-lane before every send.

Deviations / notes against PLAN.md

  • PLAN's "Keep green" MiscTests matches three classes (assets/group/naming); all run.
  • Unified parseTypedBalance contract refined: a reply with total but no spendable is now the typed BalanceUnavailableException (message contains BALANCE_UNAVAILABLE) instead of a generic "Unable to determine balance" — /walletbalance therefore returns 1204 for that case (was 1201). Malformed values still fail closed generically.
  • isValidAddress tightening is a small behavior change for trade-bot address checks (uppercase/Bech32m zs strings no longer accepted).
  • Owner confirmations still owed at review: D3 fee FIXED 10000 (/feekb, /feerequired remain trade-only); D4 blocking scope of an UNRESOLVED operation (PR3).

Tests (surefire, per class, on the rebased head)

Class run fail
PirateChainAmountAdapterTests (new) 5 0
PirateChainSendValidationTests (new) 10 0
PirateChainSendApiSerializationTests (new; strict reader + reviewer vectors) 5 0
PirateChainSendRequestReaderJerseyTests (new; reader wins over MOXy/Jackson in a real Jersey stack) 2 0
PirateChainSendNativeFlowTests (new; scripted native adapter behind the real coordinator/controller/lane) 9 0
CrossChainPirateChainResourceTests 27 0
PirateChainApiContractTests 5 0
PirateRecoveryApiSerializationTests 4 0
PirateChainVerifiedRecoveryContractTests 11 0
ApiRequestBodyBindingTests 9 0
TranslationsTests 4 0
PirateWalletSessionTests 3 0
ArrrWalletOwnershipTests 1 0
ZcashFamilyWalletControllerLifecycleTests 27 0
ZcashFamilyNativeCoordinatorTests 7 0
PirateChainHistoryTests 10 0
PirateUnifiedWalletStorageTests 31 0
PirateUnifiedWalletBundleTests 11 0
PirateUnifiedWalletSettingsTests 7 0
MiscTests (assets/group/naming) 18/17/21 0
SecurityTests 6 0
PirateChainACCTv3Tests 13 0

PirateChainSendNativeFlowTests proves: activation → /send → synchronized gate (height/info/syncStatus) → validate_address → get_active_wallet/get_balance → encryptionstatus (unlock) → export → exactly one send with input = export address, fee 10000, amount 150000000 for "1.5", memo (héllo "quoted" back\slash\nsecond line\t😀) round-tripped verbatim, PirateChainSendResult with the native txid; plus invalid-point recipient → 102 with zero sends, missing spendable → 1205, insufficient → 1202, native error → 1201 sanitized, txid-less reply → outcome unknown, native throw → SendOutcomeUnknownException, validation outage → 1205, other account → 409.

mvn -DskipTests package builds target/qortium-1.8.0.jar.

🤖 Generated with Claude Code

QuickMythril and others added 2 commits September 28, 2026 09:47
…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
QuickMythril force-pushed the feat/arrr-send-contract-v1 branch from 9b8cd48 to dc12886 Compare September 28, 2026 13:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant