diff --git a/README.md b/README.md index 25ea265..6db510c 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ is baked into the jar. The pinned version lives in `gradle.properties`: ```properties -yanoVersion = 0.1.0-pre13 +yanoVersion = 0.1.0-pre16 ``` Resolution order (first hit wins), which mirrors what `NodeLocator` does at diff --git a/adr/047-bounded-cip30-signer-search.md b/adr/047-bounded-cip30-signer-search.md new file mode 100644 index 0000000..2dc1232 --- /dev/null +++ b/adr/047-bounded-cip30-signer-search.md @@ -0,0 +1,113 @@ +# ADR-047: Bounded multi-address signer search + +- Status: Implemented in development; real extension/ledger qualification pending +- Date: 2026-09-08 +- Related: ADR-035 (CIP-30), ADR-037 (HD accounts), ADR-042 (transaction review) +- Scope: Software-wallet CIP-30 `signTx` and `signData`; not hardware signing + +## Problem + +The desktop can derive many receive addresses but the dApp transaction signer previously +used only receive index 0. An account enrolling payment keys from indexes 0, 1 and 2 could +collect only the first witness. Address-use discovery is insufficient: an unused address +can still be an explicit transaction authority. + +## Final search rule + +1. Restrict search to the currently unlocked HD account. Never try other HD accounts or + seed groups implicitly. Only payment chains receive (role 0) and change (role 1) are searched. +2. First examine public paths known in this unlock session: addresses displayed/exported + through the address view and funded paths discovered by balance scanning. Duplicates + are removed. These exact known indexes may lie beyond the fallback window; do not derive + the intervening range just because a high index is known. Cap known paths at 10,000. +3. Then examine receive indexes 0–29 and change indexes 0–29, irrespective of usage or gaps. + Stop once every outstanding target has matched. This means 30 per chain, not 30 combined. +4. If hashes remain unmatched, ask explicitly whether to extend this request through index + 49 on both chains. Declining expansion retains the initial result; it does not authorize + signing. Never extend automatically or beyond 49 except for exact known paths from step 2. +5. Targets come from transaction-body `required_signers` and payment credentials of resolved + spending/collateral inputs. Do not sign merely because an address appears in outputs or + reference inputs. Resolve inputs against the selected backend and check their identities; + unresolved inputs fail closed. Script-payment inputs do not imply payment-key witnesses. +6. Retain the existing single-account stake-key relevance check for required signers, + withdrawals and certificates; do not derive stake keys for every payment index. This + increment does not add discovery of arbitrary native-script-only, governance or other + certificate authorities that are not named in required signers or input credentials. +7. Pre-existing vkey witnesses must verify against the original body before they count as + satisfied. Do not return them again or add duplicate/unrelated primary-payment witnesses. +8. Show the selected HD account, matched derivation paths, search window and unmatched hashes + before approving. One approval signs all listed keys over the same original body bytes. + Expansion and final approval are separate decisions. An unmatched hash may belong to + another wallet, another account or an index outside the search; do not label it invalid. +9. For `partialSign=true`, return the reviewed relevant witnesses and leave unmatched targets + to other participants. No matches is an explicit failure. For `partialSign=false`, reject + if any of the collected target hashes remain unmatched; this is not a ledger-validation + promise or a general native-script satisfiability solver. +10. Bind approval to the exact request bytes, partial-sign flag, unlocked session and backend + connection. Consume the approval once. Account/network changes reject and require review + again. Sign the original CBOR, never a reconstructed body. Search makes no signatures. + +Input resolution plus derivation has a 10-second deadline per search attempt. At most two +search workers and two queued attempts are allowed; cancellation or failure signs nothing. +Review accepts at most 64 KiB transaction bytes and 256 unique spending/collateral inputs. +These are defensive client limits, not claims about ledger transaction limits. + +## Discovery and compatibility + +Known-path metadata is currently session-local. A fresh unlock/seed restoration searches the +30/50 windows until high paths are displayed or rediscovered. Persistent public path metadata +is a separate enhancement; never imply a gap scan recovers every unused enrolled key. + +`getUsedAddresses` remains an address-use API, not a complete key inventory. Do not put unused +addresses into it to work around a dApp. Kavach's transaction precheck was adjusted to call +`signTx` without treating those lists as proof of the wallet's entire signing capability; +its backend still validates witness membership and signatures on the unchanged transaction. + +This implementation leaves `signData` at its existing index-0 behavior. COSE multi-address +lookup needs its own address binding and consent work. Hardware paths are not expanded. + +## Validation + +`DappSignerSearchTest` covers unused receive 0/1/2, change keys, 29/30/49/50 boundaries, +known higher indexes, selected-account isolation, input/collateral versus output/reference +ownership, missing/mismatched input resolution, existing witness verification and duplicate +suppression, relevant stake keys, malformed/bounded requests, and multi-key signatures over +an indefinite-CBOR regression body. + +`Cip30SignerSearchApprovalTest` exercises the actual gate-to-signer wiring: one approval for +multiple keys, explicit extension, declined extension with partial signing, full-sign rejection, +no-match rejection, user rejection, session/network/body changes, one-use tickets and generated +high-index metadata. Existing signer, connector, wallet and application tests also run. + +These are local cryptographic and integration-of-components tests. They do not claim an actual +extension session, hardware approval or full node validation of the new multi-address workflow. +Keep Kavach's pending setup/backend alive when restarting only Yano to qualify it interactively. + +## COSE data-signing extension (2026-09-08) + +The same software-account search now applies to CIP-30 `signData`: known payment +paths, then receive/change 0–29, then explicit extension to 0–49. Targets come only +from the requested address's payment credential; key reward addresses retain the +single selected-account stake key. The current bounded address profile accepts key +enterprise and key-payment base addresses, plus key reward addresses; other address +forms fail closed. Network mismatches and unmatched keys reject without falling back +to receive index 0. There are no node/history/gap queries during data-key discovery. + +The approval displays the matched path, full requested address and payload hex, and +states that a data signature can authorize later actions. A one-use ticket binds +address and payload to the reviewed session and backend connection; changing any of +these, rejecting consent or calling the signer without a ticket fails closed. +The bounded search executor/timeouts are shared with transaction signing. + +Kavach exposes an explicit pending wallet-credential selector. It may construct a +testnet enterprise address for that exact payment hash when the wallet does not list +it; Yano decides ownership and obtains consent. Kavach still verifies the resulting +COSE key/signature against the expected credential and proof digest. It never tries +another authority automatically after a signing failure. The iPhone approval path +and fee-wallet transaction-signature path remain distinct. + +Regression tests cover unused receive/change keys, verifiable COSE evidence and exact +returned public keys, 30/50 boundaries, known high indexes, other-account/network and +malformed-address rejection, explicit expansion, denied consent, request mutation, +account/network switches, and one-use approvals. Full core/connector/app tests and +Kavach frontend build/tests pass. Live user-wallet confirmation is recorded separately. diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index ec98428..c44991a 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -181,10 +181,23 @@ Verification-only flags (used by the screenshot/e2e harness): all go through it, so behavior can't drift. - **Managed node = child process** (not in-process): crash isolation and the node stays a native-imageable binary. Overrides via `-D` sysprops that MUST - precede `-jar`: `quarkus.profile=,wallet`, `quarkus.http.port`, - `yano.server.port`, `yano.storage.path`. The `,wallet` profile is required so - real networks enable the wallet APIs (address/tx/reward history) — only - `%devnet` turns them on by itself. + precede `-jar`: `-Xmx`, then `quarkus.profile=,,wallet`, + `quarkus.http.port`, `yano.server.port`, `yano.storage.path`. The `,wallet` + profile is required so real networks enable the wallet APIs (scan index, + address-first-seen, UTxO state) — only `%devnet` turns them on by itself — and + it stays last because a later profile wins. The sizing profile (`medium` by + default) sets RocksDB caches and the decoded-block queue for a 4 GiB+ desktop. + It carries no heap of its own: `yano.sh` pairs each profile with an `-Xmx` and + we do not go through `yano.sh`, so the launcher passes `-Xmx` itself. The JVM + reads no `JAVA_OPTS`, and a `-Xmx` after `-jar` is a program argument the JVM + ignores — hence the ordering, pinned by `NodeLaunchCommandTest`. +- **Managed node sizing is user-editable**: `~/.yano-wallet//node/node.properties` + (beside `node.log`), written as a commented template on first managed start. + `sizingProfile=xsmall|small|medium|large` picks the profile and its heap + (384m/384m/1536m/2g, mirroring `yano.sh:355-373`); `maxHeap=` overrides just + the heap. Bad values are logged and ignored rather than failing the start, and + edits apply on the next node start. `-Dyano.wallet.node.sizing-profile` and + `-Dyano.wallet.node.max-heap` on the *wallet* do the same for a dev run. - **Node not native for the UI.** Per ADR-033, the JavaFX UI ships via jlink/jpackage (Gluon's GraalVM is frozen below JDK 25); the node stays the GraalVM-native binary. Keep `wallet-app`/`wallet-ui` framework-light so a diff --git a/docs/mempool-coin-selection.md b/docs/mempool-coin-selection.md new file mode 100644 index 0000000..f791984 --- /dev/null +++ b/docs/mempool-coin-selection.md @@ -0,0 +1,93 @@ +# Yano mempool-aware coin selection + +Yano transaction builders request `include_mempool=true` on address/payment- +credential listings and individual output resolution. The node excludes pending- +spent inputs and includes unspent pending change, with filtering and ordering +before pagination. The wallet also applies its local input reservations. + +The node must support the published listing contract. This was verified against +port 7070 using the supplied test address: the overlay excluded a confirmed +outpoint and returned a different pending output with `block: null`. A 200 alone +would not have established support. There is no custom capability-header check, +no new supplier class, and no confirmed-only fallback on lookup failures. Nodes +that silently ignore the parameter are outside this preview version's contract. + +## Selection and submission + +`YanoNodeBackend.selectionUtxoSupplier()` uses the existing `YanoUtxoSupplier` +with the node overlay and a local reservation tracker. Software ADA/native-asset sends, composed +transactions and hardware builders use this view. Address and payment-credential +lookups filter the same outpoints. Full listings continue past fully excluded +pages; explicit pages are formed after filtering, preserving server order. +Lookup failures abort selection rather than returning partial funds. + +The Yano transaction processor parses regular inputs from the submitted CBOR and +reserves them before sending the HTTP request. This covers software, hardware +and dApp submissions through that processor. A stale draft — a different +transaction using a reserved input — is rejected locally before another request +is made. Reference inputs are not +reserved. Pending outputs returned by the node are eligible, including change for subsequent +sends. The wallet does not construct or resubmit parent transactions itself. + +The wallet connection persists reservations in the network's `pending-inputs.json` +file, independently of the history display records. This survives wallet restarts +and reconnects, and covers hardware submissions whose history records omit CBOR. +Storage errors block submission/selection. This is local tracking for one wallet +application; it is not a reservation service shared by other wallet apps or +concurrently running processes. Diagnostic backends without a configured storage +path track only for their process lifetime. Existing submissions made before this +tracking was installed cannot be reconstructed from this file. + +## Refresh and failures + +Before selection/submission the tracker refreshes against the node: + +- A definite HTTP 4xx rejection releases that submission's reservations immediately. +- Confirmation removes its reservations once the node UTxO index is caught up. +- Passing the transaction's upper validity slot also releases reservations once + the index is caught up. There is no arbitrary wall-clock unlock. +- Transport errors and HTTP 5xx responses have uncertain outcomes, so reservations + remain until confirmation or expiry. Lookup failures never unlock inputs. A wallet + send keeps its signed draft for a retry in both cases; only a 4xx marks it failed. +- Resending the identical signed transaction is not a conflict — only a different + transaction over the same inputs is. That resend is how an uncertain outcome is + settled, and it cannot pay twice. Yano answers a transaction already in its + mempool with 200. One already in a block is refused for spending its own inputs, + so before any 4xx releases inputs the wallet asks `GET /txs/{hash}`; if the + transaction is there, the send is reported as submitted. If that lookup fails, + the outcome stays unknown: the reservation is kept and the send can be retried. + +The history UI's existing five-minute “failed” timeout is only an advisory guess, +not a definite node rejection, and does not release input reservations. The current +transaction lookup cannot distinguish a dropped mempool transaction from one +still pending. A transaction without an upper validity bound cannot be safely +expired by this mechanism; it needs confirmation or a definitive rejection. + +A submission conflict still discards the stale draft. The Send screen offers +**Rebuild & review**, which refreshes selection and requests a new approval; it +never automatically resubmits the signed transaction. Spending from another +wallet can still cause a node conflict because its submissions are unknown here. +When Yano names the claiming transaction and input in its conflict response, the +wallet persists that exact input under its actual owner. Rebuild excludes it, +while releasing the rejected draft's other inputs. The existing confirmation +refresh clears it once the owner is indexed. An unknown owner's TTL cannot be +inferred from the rejected draft: if the owner is dropped rather than confirmed, +the current API cannot prove that it is safe to unlock this learned reservation. + +## Unchanged views + +`utxoSupplier()` remains the confirmed view for balances and existing CIP-30 +queries. Confirmed history is unchanged. Yaci DevKit uses its existing supplier +and processor, with no local exclusion or Yano-specific query parameters. + +## Tests + +`PendingInputsTest` and `YanoMempoolViewTest` cover actual ADA and token draft +construction using pending change, exclusion +across server pages, explicit pagination, payment credentials and ordering, +restart persistence, rejection and expiry cleanup, uncertain outcomes, lagging +indexes, lookup/storage failures, stale-draft rejection and non-Yano isolation. +Tests use a stub node and public fixture keys; they do not submit user funds. + +Listing and individual-output errors remain explicit. `block: null` is accepted +for pending outputs; it does not alter the confirmed balance/history view. diff --git a/docs/wallet-address-discovery.md b/docs/wallet-address-discovery.md new file mode 100644 index 0000000..09a8dbc --- /dev/null +++ b/docs/wallet-address-discovery.md @@ -0,0 +1,59 @@ +# Shelley wallet address discovery + +Balance discovery scans receive (`1852'/1815'/account'/0/index`) and change +(`1852'/1815'/account'/1/index`) addresses independently. Each chain stops after +20 consecutive addresses with no transaction history by default. Spent addresses +reset the gap, just like funded addresses. Watch-only accounts derive both chains +from the stored account public key. Account discovery probes both chains too. + +The previous scan stopped at receive index 99 and never queried change addresses. +It could therefore omit funds in restored wallets, including older Yoroi wallets. + +The scan now continues to the unused gap, with a safety ceiling of 10,000 addresses +per chain. Reaching that ceiling without a gap is an **incomplete scan**, not a +successful balance. For unusually large accounts, increase the JVM property +`yano.wallet.scan.max-addresses-per-chain` (for example through +`JAVA_TOOL_OPTIONS=-Dyano.wallet.scan.max-addresses-per-chain=20000`) and retry. +This restarts discovery with the larger bound; it does not persist a scan cursor. + +## Nodes without address history + +The pinned Yano `0.1.0-pre13` distribution does not serve address transaction +history. Its `404` is different from a definite empty history result. A missing +Yano route, or a `503` explicitly identifying a disabled address-history index, +uses bounded UTxO-only recovery instead of failing the balance screen. + +Recovery scans indices **0–199 on both chains** by default, without stopping at +empty-address gaps. The dashboard calls this **Known balance — scan incomplete** +and displays the exact scanned ranges. More funds may exist outside those ranges; +without history, no gap-based algorithm can establish completeness. A range can +extend further if history becomes unavailable after discovery has already advanced. +Each refresh retries history, so restoring the endpoint restores normal gap scans. + +For a deeper recovery scan, launch with: + +```bash +JAVA_TOOL_OPTIONS=-Dyano.wallet.scan.historyless-addresses-per-chain=1000 ./gradlew :wallet-app:run +``` + +The general 10,000-address safety ceiling still applies. Wider recovery windows +make more node requests and take longer; dashboard refreshes do not overlap. +Transport errors, generic server errors, malformed responses, and failed UTxO pages +still fail the refresh. Blockfrost-style stores' address-not-found `404` is treated +as unused, per their endpoint convention. Account-profile discovery still requires +history; UTxO-only recovery applies to addresses within an already opened account. + +## Payments and scope + +Software ADA/native-asset payments use the same discovery result to enumerate +funded addresses. Signing preserves the full derivation path, including the change +role, and returns change to the existing primary receive address. Transaction +previews include the discovered payment credentials in wallet ownership. An +incomplete recovery scan makes the preview unchecked, since a partial ownership +set must not be used to assert a complete value difference. + +This change covers Shelley **base** addresses. Byron recovery is intentionally +out of scope. Enterprise-address discovery, hardware payments, the existing primary-address-only +staking/governance/minting paths, and CIP-30 address selection/signing are not +expanded by this change. The standard unused-address gap still applies; a wallet that used +addresses beyond a gap needs a larger gap setting through the scan API. diff --git a/docs/wallet-index-live-validation.md b/docs/wallet-index-live-validation.md new file mode 100644 index 0000000..3730f70 --- /dev/null +++ b/docs/wallet-index-live-validation.md @@ -0,0 +1,33 @@ +# Wallet index live validation + +`WalletIndexLiveTest` is opt-in and launches an isolated local devnet from a node +artifact and a devnet configuration directory. It never attaches to an existing +node. Public test mnemonics fund a fresh copied genesis. Logs and wallet state +remain in the printed temporary evidence directory; owned node processes are +stopped even when an assertion fails. + +From the wallet checkout: + +```sh +./gradlew :wallet-node-client:test --tests '*WalletIndexLiveTest' \ + -Dyano.wallet.live.artifact=/absolute/path/to/yano.jar \ + -Dyano.wallet.live.config=/absolute/path/to/yano/app/config/network/devnet +``` + +Use the native `yano` executable as the artifact to run the same test natively. +The JVM artifact must be the self-contained distribution jar. Both artifact +variants passed on 2026-09-06 against Yano PR #120. Sender and receiver have +distinct stake credentials, so outgoing-only detection cannot accidentally match +the recipient's credential. + +Verified through actual wallet client/port classes: first-seen discovery including +genesis, persisted native assets/outpoints, outgoing-only history, fully spent +address use, graceful node restart, forced termination and restart, snapshot +rollback removing orphaned history/outpoints, and replacement transactions. +Wallet client objects are recreated between reads to exercise durable state; +this is not a JavaFX UI test. General tests skip the live test unless both paths +are supplied. Opt-in live runs disable Gradle cache/up-to-date reuse, because the +external node artifact can change at the same path. The owner accepted devnet validation for handoff on 2026-09-06; preprod sync +and further historical/performance validation are deferred to manual follow-up. +The final asset assertion expects canonical hex asset names; the earlier JVM/native +recovery runs predate that assertion update. diff --git a/gradle.properties b/gradle.properties index 611a4e1..f05e2c1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,7 +3,7 @@ walletVersion = 0.1.0-pre5 # The Yano node release the wallet launches and bundles. Single source of truth — # bumping this is the whole upgrade. Override per-invocation with -PyanoVersion=…, # or point at a local build with -PyanoDist=/path (or YANO_DIST=…). -yanoVersion = 0.1.0-pre13 +yanoVersion = 0.1.0-pre16 org.gradle.jvmargs = -Xmx2g org.gradle.parallel = true diff --git a/gradle/yano-node.gradle b/gradle/yano-node.gradle index 3235ef7..dd3d24d 100644 --- a/gradle/yano-node.gradle +++ b/gradle/yano-node.gradle @@ -22,7 +22,7 @@ // So local dev and offline builds never hit the network, and CI downloads once // per version bump (cache the directory keyed on yanoVersion). -def yanoVersion = project.findProperty('yanoVersion') ?: '0.1.0-pre13' +def yanoVersion = project.findProperty('yanoVersion') ?: '0.1.0-pre16' def cacheRoot = new File(gradle.gradleUserHomeDir, "yano-dist") // -PyanoNative selects the platform-specific native distribution instead of the diff --git a/gradle/yano-node.lock b/gradle/yano-node.lock index 01b0897..070b069 100644 --- a/gradle/yano-node.lock +++ b/gradle/yano-node.lock @@ -1,5 +1,8 @@ # sha256 per release ARTIFACT (not per version): the native builds are one zip # per platform, so a single per-version entry cannot cover them. # Print it with: ./gradlew fetchYanoNode yanoNodeChecksum [-PyanoNative] +yano-0.1.0-pre16=408a93edce6316996dd73d3a4caddc53608630b1b2ec8e75759c2b25f8dca538 +yano-native-0.1.0-pre16-macos-arm64=f0866808a37b222f3b6d1acd679917f52188878131fc63f5d79af91b8f920abb +# Previous pin, kept so an explicit -PyanoVersion=0.1.0-pre13 still verifies. yano-0.1.0-pre13=1d280bbed66f1824e779a12f26ef1f565bebbd31dac994136936e2810b506746 yano-native-0.1.0-pre13-macos-arm64=8d683e2b3b11a2fb56cd1762675b841a3c0739e3d9e78d0b3c2a1b784741dc62 diff --git a/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/Cip30ApprovalGate.java b/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/Cip30ApprovalGate.java index 220f980..a1f31da 100644 --- a/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/Cip30ApprovalGate.java +++ b/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/Cip30ApprovalGate.java @@ -1,6 +1,8 @@ package com.bloxbean.cardano.yano.wallet.app; import com.bloxbean.cardano.yano.wallet.connector.Cip30Approvals; +import com.bloxbean.cardano.yano.wallet.connector.Cip30Exception; +import com.bloxbean.cardano.yano.wallet.core.tx.DappSignerSearch; import com.bloxbean.cardano.yano.wallet.ui.contract.Cip30Prompt; import com.bloxbean.cardano.yano.wallet.ui.contract.TxEffectView; @@ -18,11 +20,14 @@ final class Cip30ApprovalGate implements Cip30Approvals { private final Cip30AllowlistStore allowlist; private final Cip30Prompt prompt; private final TxEffectSummariser summariser; + private final WalletCip30Wallet wallet; - Cip30ApprovalGate(Cip30AllowlistStore allowlist, Cip30Prompt prompt, TxEffectSummariser summariser) { + Cip30ApprovalGate(Cip30AllowlistStore allowlist, Cip30Prompt prompt, TxEffectSummariser summariser, + WalletCip30Wallet wallet) { this.allowlist = allowlist; this.prompt = prompt; this.summariser = summariser; + this.wallet = wallet; } @Override @@ -44,14 +49,47 @@ public boolean confirmConnect(String origin) { @Override public boolean confirmSign(String origin, String txHex, boolean partialSign) { - // Simulation is best-effort and bounded; it never decides for the user and - // never blocks the prompt from appearing. + // Key discovery fails closed if input ownership cannot be resolved. + // The separate effect simulation is best-effort and visibly reports its limits. + var review = wallet.reviewSigning(txHex, partialSign, DappSignerSearch.DEFAULT_LIMIT); + if (review.plan() != null && !review.plan().unmatched().isEmpty() + && prompt.confirmExtendedSignerSearch(origin, review.plan().description())) { + var extended = wallet.reviewSigning(txHex, partialSign, DappSignerSearch.EXTENDED_LIMIT); + if (extended.session() != review.session() || extended.connection() != review.connection()) + throw Cip30Exception.refused("Account or network changed during signer search. Retry the request."); + review = extended; + } + if (review.plan() != null) { + if (!review.plan().hasSigners()) + throw Cip30Exception.refused("No matching signing keys found. " + review.plan().description()); + if (!partialSign && !review.plan().unmatched().isEmpty()) + throw Cip30Exception.refused("Cannot fully sign within this search. " + review.plan().description()); + } TxEffectView effect = summariser.summarise(txHex); - return prompt.confirmSign(origin, effect); + String paths = review.plan() == null ? "Hardware wallet: verify signing on the device." + : review.plan().description(); + if (!prompt.confirmSign(origin, effect, paths)) return false; + wallet.approveSigning(review); + return true; } @Override public boolean confirmSignData(String origin, String address) { - return prompt.confirmSignData(origin, address); + return false; // A payload-bound review is required by this implementation. + } + + @Override + public boolean confirmSignData(String origin, String address, String payload) { + var review = wallet.reviewData(address, payload, DappSignerSearch.DEFAULT_LIMIT); + if (!review.plan().hasSigner() && prompt.confirmExtendedSignerSearch(origin, review.plan().description())) { + var extended = wallet.reviewData(address, payload, DappSignerSearch.EXTENDED_LIMIT); + if (extended.session() != review.session() || extended.connection() != review.connection()) + throw Cip30Exception.refused("Account or network changed during signer search. Retry the request."); + review = extended; + } + if (!review.plan().hasSigner()) throw Cip30Exception.refused("No matching data signing key. " + review.plan().description()); + if (!prompt.confirmSignData(origin, address, review.plan().description(), review.plan().payload())) return false; + wallet.approveData(review); + return true; } } diff --git a/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/DefaultWalletUiController.java b/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/DefaultWalletUiController.java index dd4cdec..9459288 100644 --- a/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/DefaultWalletUiController.java +++ b/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/DefaultWalletUiController.java @@ -13,6 +13,7 @@ import com.bloxbean.cardano.yano.wallet.core.service.HistoryPort; import com.bloxbean.cardano.yano.wallet.core.service.NodeStatusPort; import com.bloxbean.cardano.yano.wallet.core.service.WalletService; +import com.bloxbean.cardano.yano.wallet.core.service.MempoolConflictException; import com.bloxbean.cardano.yano.wallet.core.tx.PendingTransaction; import com.bloxbean.cardano.yano.wallet.core.tx.QuickAdaTxDraft; import com.bloxbean.cardano.yano.wallet.core.wallet.StoredWallet; @@ -46,7 +47,7 @@ */ public class DefaultWalletUiController implements WalletUiController { private static final DateTimeFormatter TIME_FORMAT = - DateTimeFormatter.ofPattern("dd MMM HH:mm").withZone(ZoneId.systemDefault()); + DateTimeFormatter.ofPattern("dd MMM yyyy HH:mm").withZone(ZoneId.systemDefault()); /** Account-discovery bounds (ADR-037): each probe is a node call, so stay modest. */ private static final int MAX_DISCOVERED_ACCOUNTS = 20; private static final int DISCOVERY_GAP_LIMIT = 20; @@ -754,7 +755,7 @@ public synchronized void startDappConnector( boolean webSocketChosen = connectorSettings.isWebSocket(); wsConnectorEnabled = wsConnectorEnabled || webSocketChosen; var wallet = new WalletCip30Wallet(backendManager, () -> session); - var approvals = new Cip30ApprovalGate(cip30Allowlist, prompt, summariser()); + var approvals = new Cip30ApprovalGate(cip30Allowlist, prompt, summariser(), wallet); // Default transport (ADR-035 M5): the browser-launched proxy relays // to this socket — no localhost port, and Chrome vouches for the // extension id. Best-effort; a bind failure never breaks the wallet. @@ -939,18 +940,28 @@ public CompletableFuture forgetDapp(String origin) { @Override public CompletableFuture balance() { return async(() -> { - var balance = requireSession().balance(); - return new BalanceView( - ada(balance.lovelace()), - balance.lovelace().toString(), - balance.utxoCount(), - balance.addressCount(), - balance.assets().stream() - .map(asset -> new AssetItem(asset.unit(), asset.quantity().toString())) - .toList()); + var active = requireSession(); + var node = ports(); + return balanceView(active.balance(), () -> node.accountInfo(active.profile().stakeAddress())); }); } + static BalanceView balanceView(com.bloxbean.cardano.yano.wallet.core.wallet.WalletBalance balance, + Supplier accountLookup) { + String rewardsLovelace = null; + try { + BigInteger rewards = accountLookup.get().withdrawable(); + if (rewards != null && rewards.signum() >= 0) rewardsLovelace = rewards.toString(); + } catch (RuntimeException e) { + // Preserve the known UTxO balance and explicitly mark rewards unavailable. + } + return new BalanceView( + ada(balance.lovelace()), balance.lovelace().toString(), balance.utxoCount(), balance.addressCount(), + balance.assets().stream() + .map(asset -> new AssetItem(asset.unit(), asset.quantity().toString())).toList(), + balance.scanWarning(), rewardsLovelace); + } + @Override public CompletableFuture> addresses(int count) { return async(() -> requireSession().addresses(count).receiveAddresses().stream() @@ -959,6 +970,33 @@ public CompletableFuture> addresses(int count) { .toList()); } + @Override + public CompletableFuture addressDetails(int index) { + return async(() -> { + var details = requireSession().addressDetails(index); + return new AddressDetails(details.address(), details.paymentPath(), details.stakePath(), + details.paymentPublicKey(), details.paymentKeyHash(), + details.stakePublicKey(), details.stakeKeyHash()); + }); + } + + @Override + public CompletableFuture historyScanStatus() { + // Common pool, never the backend executor: the scan this reports on holds + // that single thread for its whole run, so a poll queued behind it could + // only ever arrive after the thing it describes had finished. + return io(() -> { + try { + return ports().scanProgress() + .map(scan -> new HistoryScanView(scan.currentBlock(), scan.tipBlock(), + (int) Math.round(scan.fraction() * 100))) + .orElse(HistoryScanView.idle()); + } catch (RuntimeException notConnected) { + return HistoryScanView.idle(); + } + }); + } + @Override public CompletableFuture history(int page, int count) { return async(() -> { @@ -976,7 +1014,7 @@ public CompletableFuture history(int page, int count) { profile.stakeAddress(), profile.baseAddress(), page, count, true)) { nodeHashes.add(tx.txHash()); dated.add(new Dated(tx.blockTime(), new TxItem(tx.txHash(), tx.blockHeight(), - TIME_FORMAT.format(Instant.ofEpochSecond(tx.blockTime())), + formatTransactionTime(tx.blockTime()), "confirmed", null, null, explorerUrl(network, tx.txHash())))); } } catch (HistoryPort.HistoryNotSupportedException e) { @@ -1034,6 +1072,10 @@ public CompletableFuture history(int page, int count) { }); } + static String formatTransactionTime(long epochSeconds) { + return TIME_FORMAT.format(Instant.ofEpochSecond(epochSeconds)); + } + /** * History assembled from the wallet's own record of what it submitted, for * backends that serve no transaction index (ADR-043). @@ -1565,50 +1607,61 @@ private static String bodyHex(com.bloxbean.cardano.client.transaction.spec.Trans @Override public CompletableFuture confirmDraft(String draftId) { return async(() -> { - HardwareSendService.Draft hardwareDraft = hardwareDrafts.get(draftId); - if (hardwareDraft != null) { - var connection = backendManager.active(); - String hwTxHash = hardwareSend.signAndSubmit( - connection.backend(), connection.network(), hardwareDraft); + try { + return submitExistingDraft(draftId); + } catch (MempoolConflictException e) { + drafts.remove(draftId); hardwareDrafts.remove(draftId); - service().recordSubmittedPayment(hardwareDraft.profile(), hwTxHash, - hardwareDraft.amount(), hardwareDraft.fee(), hardwareDraft.toAddress(), - hardwareDraft.ttl()); - service().trackConfirmation(hwTxHash, 120); - return new SubmitView(hwTxHash, false); - } - HardwareStakeService.Draft stakeDraft = hardwareStakeDrafts.get(draftId); - if (stakeDraft != null) { - var connection = backendManager.active(); - String hwTxHash = hardwareStake.signAndSubmit( - connection.backend(), connection.network(), stakeDraft); hardwareStakeDrafts.remove(draftId); - service().recordSubmittedPayment(stakeDraft.profile(), hwTxHash, - BigInteger.ZERO, stakeDraft.fee(), stakeDraft.summary(), stakeDraft.ttl()); - service().trackConfirmation(hwTxHash, 120); - return new SubmitView(hwTxHash, false); - } - QuickAdaTxDraft draft = drafts.get(draftId); - if (draft == null) { - throw new IllegalStateException("Draft expired — please review again"); + throw new DraftNeedsRebuildException(e.getMessage()); } - String txHash; - try { - txHash = requireSession().submit(draft); - } catch (WalletService.RetryableSubmitException e) { - throw e; // Transport failure: keep the signed draft so the user can retry. - } catch (RuntimeException e) { - drafts.remove(draftId); // Terminal rejection: the tx is invalid, don't retry it. - throw e; - } - drafts.remove(draftId); - // Confirmation polling on a dedicated thread (never the shared - // backend executor) and holding no reference to the session/keys. - service().trackConfirmation(txHash, 120); - return new SubmitView(txHash, false); }); } + private SubmitView submitExistingDraft(String draftId) { + HardwareSendService.Draft hardwareDraft = hardwareDrafts.get(draftId); + if (hardwareDraft != null) { + var connection = backendManager.active(); + String hwTxHash = hardwareSend.signAndSubmit( + connection.backend(), connection.network(), hardwareDraft); + hardwareDrafts.remove(draftId); + service().recordSubmittedPayment(hardwareDraft.profile(), hwTxHash, + hardwareDraft.amount(), hardwareDraft.fee(), hardwareDraft.toAddress(), + hardwareDraft.ttl()); + service().trackConfirmation(hwTxHash, 120); + return new SubmitView(hwTxHash, false); + } + HardwareStakeService.Draft stakeDraft = hardwareStakeDrafts.get(draftId); + if (stakeDraft != null) { + var connection = backendManager.active(); + String hwTxHash = hardwareStake.signAndSubmit( + connection.backend(), connection.network(), stakeDraft); + hardwareStakeDrafts.remove(draftId); + service().recordSubmittedPayment(stakeDraft.profile(), hwTxHash, + BigInteger.ZERO, stakeDraft.fee(), stakeDraft.summary(), stakeDraft.ttl()); + service().trackConfirmation(hwTxHash, 120); + return new SubmitView(hwTxHash, false); + } + QuickAdaTxDraft draft = drafts.get(draftId); + if (draft == null) { + throw new IllegalStateException("Draft expired — please review again"); + } + String txHash; + try { + txHash = requireSession().submit(draft); + } catch (WalletService.RetryableSubmitException e) { + throw e; // Transport failure: keep the signed draft so the user can retry. + } catch (RuntimeException e) { + drafts.remove(draftId); // Terminal rejection: the tx is invalid, don't retry it. + throw e; + } + drafts.remove(draftId); + // Confirmation polling on a dedicated thread (never the shared + // backend executor) and holding no reference to the session/keys. + service().trackConfirmation(txHash, 120); + return new SubmitView(txHash, false); + } + private DraftView cacheDraft(QuickAdaTxDraft draft, String kind, String toSummary, String amountAda, String feeAda, String totalAda) { drafts.put(draft.txHash(), draft); diff --git a/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/HardwareSendService.java b/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/HardwareSendService.java index ca979d1..7b849ae 100644 --- a/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/HardwareSendService.java +++ b/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/HardwareSendService.java @@ -59,7 +59,7 @@ record Draft(String txHash, String toAddress, BigInteger amount, BigInteger fee, Draft buildPayment(YanoNodeBackend backend, WalletNetwork network, StoredWallet profile, String toAddress, BigInteger lovelace, String memo) { - List available = backend.utxoSupplier().getAll(profile.baseAddress()).stream() + List available = backend.selectionUtxoSupplier().getAll(profile.baseAddress()).stream() .filter(u -> u.getAmount().size() == 1 && "lovelace".equals(u.getAmount().get(0).getUnit())) .sorted(Comparator.comparing((Utxo u) -> u.getAmount().get(0).getQuantity()).reversed()) .toList(); @@ -142,7 +142,7 @@ Draft buildTokenPayment(YanoNodeBackend backend, WalletNetwork network, StoredWa String cclName = "0x" + nameHex; BigInteger recipientAda = BigInteger.valueOf(1_500_000); - Utxo utxo = backend.utxoSupplier().getAll(profile.baseAddress()).stream() + Utxo utxo = backend.selectionUtxoSupplier().getAll(profile.baseAddress()).stream() .filter(u -> u.getAmount().stream() .allMatch(a -> "lovelace".equals(a.getUnit()) || unit.equals(a.getUnit()))) .filter(u -> u.getAmount().stream() diff --git a/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/HardwareStakeService.java b/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/HardwareStakeService.java index 8b9e6a8..96657b2 100644 --- a/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/HardwareStakeService.java +++ b/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/HardwareStakeService.java @@ -370,7 +370,7 @@ String signAndSubmit(YanoNodeBackend backend, WalletNetwork network, Draft draft } private static Selection selectUtxos(YanoNodeBackend backend, String address, BigInteger required) { - List available = backend.utxoSupplier().getAll(address).stream() + List available = backend.selectionUtxoSupplier().getAll(address).stream() .filter(u -> u.getAmount().size() == 1 && "lovelace".equals(u.getAmount().get(0).getUnit())) .sorted(Comparator.comparing((Utxo u) -> u.getAmount().get(0).getQuantity()).reversed()) .toList(); diff --git a/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/TxEffectSummariser.java b/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/TxEffectSummariser.java index 8059f17..ef9345d 100644 --- a/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/TxEffectSummariser.java +++ b/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/TxEffectSummariser.java @@ -7,6 +7,7 @@ import com.bloxbean.cardano.yano.wallet.core.simulate.WalletContext; import com.bloxbean.cardano.yano.wallet.core.simulate.WalletOwnership; import com.bloxbean.cardano.yano.wallet.core.wallet.StoredWallet; +import com.bloxbean.cardano.yano.wallet.core.wallet.WalletBalance; import com.bloxbean.cardano.yano.wallet.ui.contract.TxEffectView; import java.math.BigInteger; @@ -73,11 +74,15 @@ TxEffectView summarise(String txHex) { if (connection == null) { return degraded(txHex, "The wallet is not connected to a node, so this transaction was not checked."); } - WalletOwnership ownership = ownership(); TxEffectEngine engine = new TxEffectEngine(connection.backend().ports(), resolvers); - // Gathered on the supervisor thread, inside the deadline: a slow node - // must cost a couple of risk signals, never the whole prompt. - analysis = supervisor.submit(() -> engine.analyse(txHex, ownership, context(connection))); + // Discovery is inside the deadline too. If ownership cannot be established, + // report an unchecked transaction rather than a misleading value diff. + analysis = supervisor.submit(() -> { + WalletService.Session current = session.get(); + WalletBalance balance = current == null ? null : current.balance(); + return engine.analyse(txHex, ownership(current == null ? null : current.profile(), balance), + context(connection, balance)); + }); } catch (RuntimeException e) { return degraded(txHex, "This transaction could not be checked: " + e.getMessage()); } @@ -97,52 +102,43 @@ TxEffectView summarise(String txHex) { } /** - * Balance and chain tip for the signals that need them (SIM-M3). Best-effort - * by design: {@link WalletContext#unknown()} suppresses those two signals - * rather than guessing, and a node hiccup here must not cost the user the - * value diff they actually came for. + * Reuse the discovery balance for risk signals. Chain-tip lookup is best effort; + * a tip lookup failure suppresses expiry signals without changing ownership. */ - private WalletContext context(WalletBackendManager.ActiveConnection connection) { - WalletService.Session current = session.get(); - BigInteger balance = null; + private WalletContext context(WalletBackendManager.ActiveConnection connection, WalletBalance balance) { long slot = 0L; - try { - if (current != null) { - balance = current.balance().lovelace(); - } - } catch (RuntimeException e) { - balance = null; - } try { slot = connection.service().nodeStatus().slot(); } catch (RuntimeException e) { slot = 0L; } - return new WalletContext(balance, slot); + return new WalletContext(balance == null ? null : balance.lovelace(), slot); } /** * The addresses whose funds count as ours. * - *

One base address plus one stake address, because that is exactly what - * this wallet's signer can authorise: {@code DappSigner} signs with the - * account's single payment key, and its stake key for certificates and - * withdrawals. {@link WalletOwnership} requires the set to cover everything - * the signer can sign — so whenever the signer gains reach (more derived - * receive addresses, multiple accounts per ADR-037), this method must grow - * with it, or inputs at the new credentials would be quietly treated as - * somebody else's and shrink the reported loss. + *

Include discovered receive and change credentials as well as the primary + * address. Software payments can spend any of these. Discovery runs inside + * the analysis deadline and must succeed; otherwise the whole summary is + * degraded rather than misclassifying wallet inputs as somebody else's. */ - private WalletOwnership ownership() { - WalletService.Session current = session.get(); - if (current == null) { + static WalletOwnership ownership(StoredWallet profile, WalletBalance balance) { + if (balance != null && !balance.complete()) { + throw new IllegalStateException("Wallet address discovery is incomplete"); + } + if (profile == null) { return WalletOwnership.ofAddresses(List.of()); } - StoredWallet profile = current.profile(); List addresses = new ArrayList<>(); if (profile.baseAddress() != null) { addresses.add(profile.baseAddress()); } + if (balance != null) { + balance.utxos().stream() + .map(com.bloxbean.cardano.yano.wallet.core.wallet.WalletUtxoView::address) + .forEach(addresses::add); + } List rewardAddresses = new ArrayList<>(); if (profile.stakeAddress() != null) { rewardAddresses.add(profile.stakeAddress()); diff --git a/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/WalletBackendManager.java b/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/WalletBackendManager.java index 6ed139d..785014a 100644 --- a/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/WalletBackendManager.java +++ b/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/WalletBackendManager.java @@ -158,10 +158,17 @@ public synchronized ActiveConnection connect(WalletConnectionConfig config) { YanoNodeBackend backend = YanoNodeBackend.connectVerified(network, baseUrl); Path networkDir = dataDirRoot.resolve(network.id()); + backend.persistPendingInputs(networkDir.resolve("pending-inputs.json")); + // /scan is Yano-specific. Yaci DevKit already supplies address transaction + // history and must never be probed for Yano wallet-index endpoints. + if (!network.blockfrostFlavor()) { + backend.ports().enableScanHistory(networkDir.resolve("scan-history")); + } FileStoredWalletRepository repository = new FileStoredWalletRepository(networkDir, network); WalletService service = new WalletService( repository, backend.utxoSupplier(), + backend.selectionUtxoSupplier(), backend.protocolParamsSupplier(), backend.transactionProcessor(), new FilePendingTransactionStore(networkDir.resolve("pending-transactions.json")), diff --git a/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/WalletCip30Wallet.java b/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/WalletCip30Wallet.java index 243a3fa..3f89cfb 100644 --- a/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/WalletCip30Wallet.java +++ b/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/WalletCip30Wallet.java @@ -10,6 +10,7 @@ import com.bloxbean.cardano.yano.wallet.connector.Cip30Exception; import com.bloxbean.cardano.yano.wallet.connector.Cip30Wallet; import com.bloxbean.cardano.yano.wallet.core.service.WalletService; +import com.bloxbean.cardano.yano.wallet.core.tx.DappSignerSearch; import com.bloxbean.cardano.yano.wallet.core.wallet.StoredWallet; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -22,6 +23,13 @@ import java.util.Locale; import java.util.Map; import java.util.function.Supplier; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.Future; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; /** * The {@link Cip30Wallet} SPI backed by the unlocked session + node backend @@ -59,6 +67,49 @@ final class WalletCip30Wallet implements Cip30Wallet { private final WalletBackendManager backendManager; private final Supplier session; private final HardwareDappSigner hardwareDappSigner = new HardwareDappSigner(); + // Dispatcher review and signing run on the same worker. A ticket is consumed once; + // account/network changes while the user approves must never select different keys. + private final ThreadLocal approved = new ThreadLocal<>(); + private static final ThreadPoolExecutor SIGNER_SEARCH = new ThreadPoolExecutor( + 2, 2, 0, TimeUnit.SECONDS, new ArrayBlockingQueue<>(2), runnable -> { + Thread thread = new Thread(runnable, "yano-signer-search"); + thread.setDaemon(true); + return thread; + }); + + record SigningReview(WalletService.Session session, WalletBackendManager.ActiveConnection connection, + String txHex, boolean partial, DappSignerSearch.Plan plan) {} + + SigningReview reviewSigning(String txHex, boolean partial, int limit) { + approved.remove(); + var current = requireSession(); + var conn = connection(); + if (current.profile().isHardware()) return new SigningReview(current, conn, txHex, partial, null); + Future task; + try { task = SIGNER_SEARCH.submit(() -> current.reviewDappTx(txHex, limit)); } + catch (RejectedExecutionException e) { + throw Cip30Exception.refused("Signer search is busy. Retry after the pending requests finish."); + } + try { + return new SigningReview(current, conn, txHex, partial, task.get(10, TimeUnit.SECONDS)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw Cip30Exception.refused("Signer search interrupted; no transaction was signed."); + } catch (TimeoutException e) { + throw Cip30Exception.refused("Signer search timed out; check the node and retry. No transaction was signed."); + } catch (ExecutionException e) { + throw Cip30Exception.refused("Signer search failed: " + e.getCause().getMessage()); + } finally { + task.cancel(true); + } + } + + void approveSigning(SigningReview review) { + if (session.get() != review.session() || connection() != review.connection()) + throw Cip30Exception.refused("Account or network changed during review. Review the request again."); + approved.set(review); + } + /** * Transaction hashes this wallet submitted, newest last — the evidence that a * missing input is one we are about to create rather than one that never @@ -115,7 +166,12 @@ public List utxos() { @Override public String signTx(String txHex, boolean partialSign) { try { - StoredWallet profile = profile(); + SigningReview review = approved.get(); + approved.remove(); + if (review == null || !txHex.equals(review.txHex()) || partialSign != review.partial() + || session.get() != review.session() || connection() != review.connection()) + throw Cip30Exception.refused("No matching approval for this account, network and transaction."); + StoredWallet profile = review.session().profile(); log.info("CIP-30 signTx: {} bytes, partialSign={}, tx {}", txHex.length() / 2, partialSign, txHashOrUnknown(txHex)); if (profile.isHardware()) { @@ -125,7 +181,7 @@ public String signTx(String txHex, boolean partialSign) { return hardwareDappSigner.signTx(conn.backend(), conn.network(), profile, txHex, partialSign); } - String witnessSet = requireSession().signDappTx(txHex, partialSign); + String witnessSet = review.session().signDappTx(txHex, partialSign, review.plan()); log.info("CIP-30 signTx: returning {} bytes of witnesses for tx {}", witnessSet.length() / 2, txHashOrUnknown(txHex)); return witnessSet; @@ -148,17 +204,42 @@ private static String txHashOrUnknown(String txHex) { } } + private final ThreadLocal approvedData = new ThreadLocal<>(); + record DataReview(WalletService.Session session, WalletBackendManager.ActiveConnection connection, + DappSignerSearch.DataPlan plan) {} + + DataReview reviewData(String address, String payload, int limit) { + approvedData.remove(); + if (address == null || address.length() > 114 || payload == null || payload.length() > 131072) + throw Cip30Exception.refused("Data signing request exceeds its size limit."); + var current = requireSession(); var conn = connection(); + Future task; + try { task = SIGNER_SEARCH.submit(() -> current.reviewDappData(HexUtil.decodeHexString(address), HexUtil.decodeHexString(payload), limit)); } + catch (RejectedExecutionException e) { throw Cip30Exception.refused("Signer search is busy. Retry later."); } + try { return new DataReview(current, conn, task.get(10, TimeUnit.SECONDS)); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); throw Cip30Exception.refused("Signer search interrupted."); } + catch (TimeoutException e) { throw Cip30Exception.refused("Signer search timed out. No data was signed."); } + catch (ExecutionException e) { throw Cip30Exception.refused("Signer search failed: " + e.getCause().getMessage()); } + finally { task.cancel(true); } + } + + void approveData(DataReview review) { + if (session.get() != review.session() || connection() != review.connection()) + throw Cip30Exception.refused("Account or network changed during review. Retry the request."); + approvedData.set(review); + } + @Override public DataSignature signData(String signerAddress, String payloadHex) { try { - var sig = requireSession().signDappData( - HexUtil.decodeHexString(signerAddress), HexUtil.decodeHexString(payloadHex)); + var review = approvedData.get(); approvedData.remove(); + if (review == null || session.get() != review.session() || connection() != review.connection() + || !review.plan().address().equalsIgnoreCase(signerAddress) || !review.plan().payload().equalsIgnoreCase(payloadHex)) + throw Cip30Exception.refused("No matching approval for this account, network, address and payload."); + var sig = review.session().signDappData(HexUtil.decodeHexString(signerAddress), HexUtil.decodeHexString(payloadHex), review.plan()); return new DataSignature(sig.signature(), sig.key()); - } catch (Cip30Exception e) { - throw e; - } catch (RuntimeException e) { - throw Cip30Exception.internal(e.getMessage()); - } + } catch (Cip30Exception e) { throw e; } + catch (RuntimeException e) { throw Cip30Exception.internal(e.getMessage()); } } private WalletService.Session requireSession() { diff --git a/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/YanoWalletApp.java b/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/YanoWalletApp.java index e15334d..5dc1f66 100644 --- a/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/YanoWalletApp.java +++ b/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/YanoWalletApp.java @@ -21,7 +21,7 @@ * *

  * Options:
- *   --network=devnet|preview|preprod|mainnet   (default preprod)
+ *   --network=devnet|yaci-devkit|preview|preprod|mainnet   (default preprod)
  *   --base-url=http://localhost:7070/api/v1/
  *   --data-dir=~/.yano-wallet
  *   --auto-connect                             reconnect to the saved node on launch
@@ -110,8 +110,11 @@ public static void main(String[] args) {
             WalletNetwork network = WalletNetwork.fromId(opts.get("network"));
             WalletConnectionConfig config;
             if ("external".equalsIgnoreCase(nodeMode)) {
+                String defaultUrl = network.defaultBaseUrl() == null
+                        ? "http://localhost:7070/api/v1/"
+                        : network.defaultBaseUrl();
                 config = WalletConnectionConfig.external(network,
-                        opts.getOrDefault("base-url", "http://localhost:7070/api/v1/"));
+                        opts.getOrDefault("base-url", defaultUrl));
             } else if (opts.containsKey("managed-port")) {
                 // Pin the managed node's REST port (e.g. to hit the devnet faucet).
                 config = WalletConnectionConfig.managed(network, Integer.parseInt(opts.get("managed-port")));
diff --git a/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/probe/LedgerSignSubmitProbe.java b/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/probe/LedgerSignSubmitProbe.java
index f938fc7..1c52493 100644
--- a/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/probe/LedgerSignSubmitProbe.java
+++ b/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/probe/LedgerSignSubmitProbe.java
@@ -59,7 +59,7 @@ public static void main(String[] args) throws Exception {
         System.out.println("Account address: " + address);
 
         // Pick an ADA-only UTXO at the account's receive-0 address.
-        Utxo utxo = backend.utxoSupplier().getAll(address).stream()
+        Utxo utxo = backend.selectionUtxoSupplier().getAll(address).stream()
                 .filter(u -> u.getAmount().size() == 1 && "lovelace".equals(u.getAmount().get(0).getUnit()))
                 .findFirst()
                 .orElse(null);
diff --git a/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/probe/WalletProbe.java b/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/probe/WalletProbe.java
index 773c86d..fff1f05 100644
--- a/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/probe/WalletProbe.java
+++ b/wallet-app/src/main/java/com/bloxbean/cardano/yano/wallet/app/probe/WalletProbe.java
@@ -130,9 +130,11 @@ private static void list(FileStoredWalletRepository repository) {
     private static WalletService walletService(FileStoredWalletRepository repository, WalletNetwork network,
                                                String baseUrl, Map opts) {
         YanoNodeBackend backend = YanoNodeBackend.connectVerified(network, baseUrl);
+        backend.persistPendingInputs(dataDirOf(opts, network).resolve("pending-inputs.json"));
         return new WalletService(
                 repository,
                 backend.utxoSupplier(),
+                backend.selectionUtxoSupplier(),
                 backend.protocolParamsSupplier(),
                 backend.transactionProcessor(),
                 new FilePendingTransactionStore(dataDirOf(opts, network).resolve("pending-transactions.json")),
@@ -149,6 +151,8 @@ private static void balance(FileStoredWalletRepository repository, WalletNetwork
                 "ada", new BigDecimal(balance.lovelace()).movePointLeft(6).toPlainString(),
                 "utxoCount", balance.utxoCount(),
                 "addressesScanned", balance.addressCount(),
+                "scanComplete", balance.complete(),
+                "scanWarning", balance.scanWarning(),
                 "assets", balance.assets().size());
     }
 
diff --git a/wallet-app/src/test/java/com/bloxbean/cardano/yano/wallet/app/Cip30SignerSearchApprovalTest.java b/wallet-app/src/test/java/com/bloxbean/cardano/yano/wallet/app/Cip30SignerSearchApprovalTest.java
new file mode 100644
index 0000000..d5fa2e1
--- /dev/null
+++ b/wallet-app/src/test/java/com/bloxbean/cardano/yano/wallet/app/Cip30SignerSearchApprovalTest.java
@@ -0,0 +1,205 @@
+package com.bloxbean.cardano.yano.wallet.app;
+
+import com.bloxbean.cardano.client.common.model.Networks;
+import com.bloxbean.cardano.client.common.cbor.CborSerializationUtil;
+import com.bloxbean.cardano.client.transaction.spec.*;
+import com.bloxbean.cardano.client.util.HexUtil;
+import com.bloxbean.cardano.hdwallet.Wallet;
+import com.bloxbean.cardano.yano.wallet.core.config.WalletNetwork;
+import com.bloxbean.cardano.yano.wallet.core.service.WalletService;
+import com.bloxbean.cardano.yano.wallet.core.tx.FilePendingTransactionStore;
+import com.bloxbean.cardano.yano.wallet.core.wallet.FileStoredWalletRepository;
+import com.bloxbean.cardano.yano.wallet.core.wallet.WalletAddresses;
+import com.bloxbean.cardano.yano.wallet.ui.contract.Cip30Prompt;
+import com.bloxbean.cardano.yano.wallet.ui.contract.TxEffectView;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.math.BigInteger;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.*;
+
+/** Tests actual consent-to-sign wiring with disposable keys and no node/wallet UI. */
+class Cip30SignerSearchApprovalTest {
+    private static final String MNEMONIC = "test walk nut penalty hip pave soap entry language right filter choice";
+    @TempDir Path dir;
+    private final Wallet hd = Wallet.createFromMnemonic(Networks.testnet(), MNEMONIC);
+    private final AtomicReference current = new AtomicReference<>();
+    private WalletService service;
+    private Backend backend;
+    private WalletCip30Wallet wallet;
+    private Cip30ApprovalGate gate;
+    private final Prompt prompt = new Prompt();
+
+    static class Backend extends WalletBackendManager {
+        ActiveConnection connection;
+        Backend(Path path) { super(path); }
+        @Override public ActiveConnection active() { return connection; }
+    }
+    static class Prompt implements Cip30Prompt {
+        boolean extend;
+        boolean approve = true;
+        int extensions;
+        int approvals;
+        String description;
+        Runnable beforeApprove = () -> {};
+        public boolean confirmConnect(String origin) { return true; }
+        public boolean confirmSignData(String origin, String address) { return false; }
+        public boolean confirmSignData(String origin, String address, String description, String payload) {
+            approvals++; this.description = description; beforeApprove.run(); return approve;
+        }
+        public boolean confirmSign(String origin, TxEffectView effect) { throw new AssertionError("Missing signer review"); }
+        public boolean confirmExtendedSignerSearch(String origin, String description) { extensions++; return extend; }
+        public boolean confirmSign(String origin, TxEffectView effect, String description) {
+            approvals++;
+            this.description = description;
+            beforeApprove.run();
+            return approve;
+        }
+    }
+
+    @BeforeEach void setup() {
+        var repository = new FileStoredWalletRepository(dir, WalletNetwork.PREPROD);
+        var stored = repository.importMnemonic("test", MNEMONIC, "disposable".toCharArray());
+        service = WalletService.localOnly(repository, new FilePendingTransactionStore(dir.resolve("pending.json")), "test node unavailable");
+        current.set(service.unlock(stored.id(), "disposable".toCharArray()));
+        backend = new Backend(dir);
+        backend.connection = new WalletBackendManager.ActiveConnection(null, WalletNetwork.PREPROD, "", null, service);
+        wallet = new WalletCip30Wallet(backend, current::get);
+        gate = new Cip30ApprovalGate(new Cip30AllowlistStore(dir), prompt, new TxEffectSummariser(backend, current::get), wallet);
+    }
+
+    private byte[] hash(int role, int index) {
+        return WalletAddresses.account(hd, role, index).hdKeyPair().getPublicKey().getKeyHash();
+    }
+    private String tx(byte[]... hashes) throws Exception {
+        return Transaction.builder().body(TransactionBody.builder().inputs(List.of())
+                .outputs(List.of(new TransactionOutput(hd.getBaseAddressString(0), Value.builder().coin(BigInteger.ONE).build())))
+                .fee(BigInteger.valueOf(200_000)).requiredSigners(List.of(hashes)).build())
+                .witnessSet(TransactionWitnessSet.builder().build()).build().serializeToHex();
+    }
+    private int count(String witnesses) throws Exception {
+        return TransactionWitnessSet.deserialize((co.nstant.in.cbor.model.Map)
+                CborSerializationUtil.deserialize(HexUtil.decodeHexString(witnesses))).getVkeyWitnesses().size();
+    }
+
+    private String dataAddress(int role, int index) { return "60" + HexUtil.encodeHexString(hash(role, index)); }
+
+    @Test void coseSignsUnusedReceiveAndChangePathsWithOneUseApproval() {
+        for (int role : new int[]{0, 1}) for (int index : new int[]{1, 2, 29}) {
+            String address = dataAddress(role, index);
+            assertThat(gate.confirmSignData("https://test.invalid", address, "010203")).isTrue();
+            assertThat(prompt.description).contains("/" + role + "/" + index);
+            var result = wallet.signData(address, "010203");
+            var signature = new com.bloxbean.cardano.client.cip.cip30.DataSignature(result.signature(), result.key());
+            assertThat(com.bloxbean.cardano.client.cip.cip30.CIP30DataSigner.INSTANCE.verify(signature)).isTrue();
+            assertThat(result.key()).contains(HexUtil.encodeHexString(WalletAddresses.account(hd, role, index).hdKeyPair().getPublicKey().getKeyData()));
+            assertThatThrownBy(() -> wallet.signData(address, "010203")).hasMessageContaining("No matching approval");
+        }
+        assertThat(prompt.extensions).isZero();
+    }
+
+    @Test void coseExpansionIsExplicitAndNeverSignsIndex50() {
+        String address = dataAddress(1, 49);
+        assertThatThrownBy(() -> gate.confirmSignData("https://test.invalid", address, "01")).hasMessageContaining("No matching data signing key");
+        prompt.extend = true;
+        assertThat(gate.confirmSignData("https://test.invalid", address, "01")).isTrue();
+        assertThat(prompt.description).contains("/1/49");
+        assertThat(wallet.signData(address, "01").signature()).isNotBlank();
+        assertThatThrownBy(() -> gate.confirmSignData("https://test.invalid", dataAddress(0, 50), "01")).hasMessageContaining("No matching data signing key");
+    }
+
+    @Test void coseRejectsChangedPayloadAddressSessionNetworkAndDeclinedConsent() {
+        String address = dataAddress(0, 1);
+        assertThat(gate.confirmSignData("https://test.invalid", address, "01")).isTrue();
+        assertThatThrownBy(() -> wallet.signData(address, "02")).hasMessageContaining("No matching approval");
+        assertThat(gate.confirmSignData("https://test.invalid", address, "01")).isTrue();
+        assertThatThrownBy(() -> wallet.signData(dataAddress(0, 2), "01")).hasMessageContaining("No matching approval");
+        prompt.approve = false;
+        assertThat(gate.confirmSignData("https://test.invalid", address, "01")).isFalse();
+        assertThatThrownBy(() -> wallet.signData(address, "01")).hasMessageContaining("No matching approval");
+        prompt.approve = true;
+        assertThat(gate.confirmSignData("https://test.invalid", address, "01")).isTrue();
+        backend.connection = new WalletBackendManager.ActiveConnection(null, WalletNetwork.PREVIEW, "", null, service);
+        assertThatThrownBy(() -> wallet.signData(address, "01")).hasMessageContaining("No matching approval");
+        prompt.beforeApprove = () -> current.set(null);
+        assertThatThrownBy(() -> gate.confirmSignData("https://test.invalid", address, "01")).hasMessageContaining("Account or network changed");
+    }
+
+    @Test void oneApprovalSignsThreeAddressesWithoutExtension() throws Exception {
+        String tx = tx(hash(0, 0), hash(0, 1), hash(1, 2));
+        assertThat(gate.confirmSign("https://test.invalid", tx, true)).isTrue();
+        assertThat(prompt.extensions).isZero();
+        assertThat(prompt.approvals).isEqualTo(1);
+        assertThat(prompt.description).contains("/0/0", "/0/1", "/1/2");
+        assertThat(count(wallet.signTx(tx, true))).isEqualTo(3);
+        assertThatThrownBy(() -> wallet.signTx(tx, true)).hasMessageContaining("No matching approval");
+    }
+
+    @Test void extensionNeedsExplicitConsentAndCoversBothChains() throws Exception {
+        String tx = tx(hash(0, 35), hash(1, 49));
+        prompt.extend = true;
+        assertThat(gate.confirmSign("https://test.invalid", tx, true)).isTrue();
+        assertThat(prompt.extensions).isEqualTo(1);
+        assertThat(prompt.description).contains("0–49", "/0/35", "/1/49");
+        assertThat(count(wallet.signTx(tx, true))).isEqualTo(2);
+    }
+
+    @Test void decliningExtensionAllowsOnlyReviewedPartialSignatures() throws Exception {
+        String tx = tx(hash(0, 1), hash(1, 35));
+        assertThat(gate.confirmSign("https://test.invalid", tx, true)).isTrue();
+        assertThat(prompt.extensions).isEqualTo(1);
+        assertThat(prompt.description).contains("Unmatched signer hashes: 1").doesNotContain("Payment: m/1852'/1815'/0'/1/35");
+        assertThat(count(wallet.signTx(tx, true))).isEqualTo(1);
+    }
+
+    @Test void fullSigningRejectsUnmatchedKeysEvenAfterExtension() throws Exception {
+        String tx = tx(hash(0, 0), hash(1, 50));
+        prompt.extend = true;
+        assertThatThrownBy(() -> gate.confirmSign("https://test.invalid", tx, false)).hasMessageContaining("Cannot fully sign");
+        assertThat(prompt.approvals).isZero();
+        assertThatThrownBy(() -> wallet.signTx(tx, false)).hasMessageContaining("No matching approval");
+    }
+
+    @Test void rejectedApprovalCannotSign() throws Exception {
+        String tx = tx(hash(0, 0));
+        prompt.approve = false;
+        assertThat(gate.confirmSign("https://test.invalid", tx, true)).isFalse();
+        assertThatThrownBy(() -> wallet.signTx(tx, true)).hasMessageContaining("No matching approval");
+    }
+
+    @Test void noMatchDoesNotProduceAnUnrelatedPrimaryKeySignature() throws Exception {
+        String tx = tx(hash(1, 50));
+        prompt.extend = true;
+        assertThatThrownBy(() -> gate.confirmSign("https://test.invalid", tx, true)).hasMessageContaining("No matching signing keys");
+        assertThat(prompt.approvals).isZero();
+    }
+
+    @Test void activeSessionChangeDuringPromptRejects() throws Exception {
+        String tx = tx(hash(0, 0));
+        prompt.beforeApprove = () -> current.set(null);
+        assertThatThrownBy(() -> gate.confirmSign("https://test.invalid", tx, true)).hasMessageContaining("Account or network changed");
+        assertThatThrownBy(() -> wallet.signTx(tx, true)).hasMessageContaining("No matching approval");
+    }
+
+    @Test void networkChangeAfterApprovalAndBodyMutationReject() throws Exception {
+        String original = tx(hash(0, 0));
+        assertThat(gate.confirmSign("https://test.invalid", original, true)).isTrue();
+        assertThatThrownBy(() -> wallet.signTx(tx(hash(0, 1)), true)).hasMessageContaining("No matching approval");
+        assertThat(gate.confirmSign("https://test.invalid", original, true)).isTrue();
+        backend.connection = new WalletBackendManager.ActiveConnection(null, WalletNetwork.PREVIEW, "", null, service);
+        assertThatThrownBy(() -> wallet.signTx(original, true)).hasMessageContaining("No matching approval");
+    }
+
+    @Test void generatedPathBeyond50IsKnownWithinSession() throws Exception {
+        current.get().addressDetails(75);
+        String tx = tx(hash(0, 75));
+        assertThat(gate.confirmSign("https://test.invalid", tx, false)).isTrue();
+        assertThat(prompt.extensions).isZero();
+        assertThat(count(wallet.signTx(tx, false))).isEqualTo(1);
+    }
+}
diff --git a/wallet-app/src/test/java/com/bloxbean/cardano/yano/wallet/app/TxEffectViewMappingTest.java b/wallet-app/src/test/java/com/bloxbean/cardano/yano/wallet/app/TxEffectViewMappingTest.java
index 1022a7a..23b2cf7 100644
--- a/wallet-app/src/test/java/com/bloxbean/cardano/yano/wallet/app/TxEffectViewMappingTest.java
+++ b/wallet-app/src/test/java/com/bloxbean/cardano/yano/wallet/app/TxEffectViewMappingTest.java
@@ -12,6 +12,7 @@
 import java.util.List;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /**
  * ADR-042: the core→UI mapping. This is the last place a hostile string can be
@@ -33,6 +34,30 @@ private static TxEffect effectWithAsset(String assetNameHex, BigInteger quantity
                 TxEffect.ScriptOutcome.NO_SCRIPTS, null, List.of(), TxFacts.empty(), List.of());
     }
 
+    @Test
+    void ownershipIncludesRecoveredChangeInputs() {
+        var wallet = com.bloxbean.cardano.hdwallet.Wallet.create(
+                com.bloxbean.cardano.client.common.model.Networks.mainnet());
+        String change = wallet.getAccountAtIndex(0).changeAddress();
+        var profile = new com.bloxbean.cardano.yano.wallet.core.wallet.StoredWallet(
+                "test", "test", "Test", "mainnet", 0, wallet.getBaseAddressString(0),
+                wallet.getStakeAddress(), null, "test.vault", null, null, null, null);
+        var balance = new com.bloxbean.cardano.yano.wallet.core.wallet.WalletBalance(
+                BigInteger.TEN, 40, 1, List.of(
+                    new com.bloxbean.cardano.yano.wallet.core.wallet.WalletUtxoView(
+                        change, "a".repeat(64), 0, BigInteger.TEN, 0, false, false)));
+        var partial = new com.bloxbean.cardano.yano.wallet.core.wallet.WalletBalance(
+                balance.lovelace(), balance.addressCount(), balance.utxoCount(), balance.utxos(),
+                balance.assets(), "History unavailable; recovery range is incomplete");
+        assertThatThrownBy(() -> TxEffectSummariser.ownership(profile, partial))
+                .isInstanceOf(IllegalStateException.class).hasMessageContaining("incomplete");
+        var ownership = TxEffectSummariser.ownership(profile, balance);
+        assertThat(ownership.classify(change)).isEqualTo(
+                com.bloxbean.cardano.yano.wallet.core.simulate.WalletOwnership.Ownership.MINE);
+        assertThat(ownership.classify(wallet.getBaseAddressString(0))).isEqualTo(
+                com.bloxbean.cardano.yano.wallet.core.simulate.WalletOwnership.Ownership.MINE);
+    }
+
     @Test
     void carriesTheDiffAcrossTheBoundaryAsPlainTypes() {
         TxEffectView view = TxEffectSummariser.toView(
diff --git a/wallet-app/src/test/java/com/bloxbean/cardano/yano/wallet/app/WalletBalanceViewTest.java b/wallet-app/src/test/java/com/bloxbean/cardano/yano/wallet/app/WalletBalanceViewTest.java
new file mode 100644
index 0000000..b34b1c5
--- /dev/null
+++ b/wallet-app/src/test/java/com/bloxbean/cardano/yano/wallet/app/WalletBalanceViewTest.java
@@ -0,0 +1,51 @@
+package com.bloxbean.cardano.yano.wallet.app;
+
+import com.bloxbean.cardano.yano.wallet.core.service.NodeStatusPort;
+import com.bloxbean.cardano.yano.wallet.core.wallet.WalletBalance;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigInteger;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class WalletBalanceViewTest {
+    private final WalletBalance utxos = new WalletBalance(
+            BigInteger.valueOf(10_123_456), 40, 2, List.of(), List.of());
+
+    @Test
+    void totalIncludesWithdrawableRewardsWhileSpendableBalanceRemainsUtxoOnly() {
+        var view = DefaultWalletUiController.balanceView(utxos,
+                () -> new NodeStatusPort.AccountView(true, BigInteger.valueOf(2_876_545), null, null, null));
+        assertThat(view.totalAda()).isEqualTo("13.000001");
+        assertThat(view.rewardsAda()).isEqualTo("2.876545");
+        assertThat(view.ada()).isEqualTo("10.123456");
+        assertThat(view.lovelace()).isEqualTo("10123456");
+        assertThat(view.utxoCount()).isEqualTo(2);
+    }
+
+    @Test
+    void unregisteredAccountAddsZeroRewards() {
+        var view = DefaultWalletUiController.balanceView(utxos,
+                () -> new NodeStatusPort.AccountView(false, BigInteger.ZERO, null, null, null));
+        assertThat(view.totalAda()).isEqualTo(view.ada());
+        assertThat(view.rewardsAda()).isEqualTo("0");
+    }
+
+    @Test
+    void unavailableRewardsPreserveKnownUtxosAndScanWarningWithoutClaimingZeroRewards() {
+        var partial = new WalletBalance(utxos.lovelace(), 400, 2, List.of(), List.of(), "Scan incomplete");
+        var view = DefaultWalletUiController.balanceView(partial, () -> {
+            throw new IllegalStateException("Node unavailable");
+        });
+        assertThat(view.totalAda()).isEqualTo("10.123456");
+        assertThat(view.rewardsAda()).isNull();
+        assertThat(view.scanWarning()).isEqualTo("Scan incomplete");
+    }
+
+    @Test
+    void transactionTimeIncludesTheYear() {
+        assertThat(DefaultWalletUiController.formatTransactionTime(1688169600L))
+                .contains("2023");
+    }
+}
diff --git a/wallet-connector-host/src/main/java/com/bloxbean/cardano/yano/wallet/connector/Cip30Approvals.java b/wallet-connector-host/src/main/java/com/bloxbean/cardano/yano/wallet/connector/Cip30Approvals.java
index d4b8fce..474eb04 100644
--- a/wallet-connector-host/src/main/java/com/bloxbean/cardano/yano/wallet/connector/Cip30Approvals.java
+++ b/wallet-connector-host/src/main/java/com/bloxbean/cardano/yano/wallet/connector/Cip30Approvals.java
@@ -30,8 +30,12 @@ public interface Cip30Approvals {
 
     /**
      * Prompts the user to approve a CIP-8 data signature by the key for
-     * {@code address}. Separate from {@link #confirmSign} because signing data
-     * moves no value: there is nothing to simulate and nothing to diff.
+     * {@code address}. Separate from {@link #confirmSign} because data signatures may authorize later actions but do not supply a transaction to simulate.
      */
     boolean confirmSignData(String origin, String address);
+
+    /** Pass exact payload to implementations that bind consent to data-signing discovery. */
+    default boolean confirmSignData(String origin, String address, String payload) {
+        return confirmSignData(origin, address);
+    }
 }
diff --git a/wallet-connector-host/src/main/java/com/bloxbean/cardano/yano/wallet/connector/Cip30Dispatcher.java b/wallet-connector-host/src/main/java/com/bloxbean/cardano/yano/wallet/connector/Cip30Dispatcher.java
index 59757d9..4958885 100644
--- a/wallet-connector-host/src/main/java/com/bloxbean/cardano/yano/wallet/connector/Cip30Dispatcher.java
+++ b/wallet-connector-host/src/main/java/com/bloxbean/cardano/yano/wallet/connector/Cip30Dispatcher.java
@@ -152,7 +152,7 @@ private String signTx(JsonNode params, String origin) {
     private Object signData(JsonNode params, String origin) {
         String addr = textParam(params, "addr");
         String payload = textParam(params, "payload");
-        if (!approvals.confirmSignData(origin, addr)) {
+        if (!approvals.confirmSignData(origin, addr, payload)) {
             throw Cip30Exception.refused("The user declined to sign.");
         }
         Cip30Wallet.DataSignature sig = wallet.signData(addr, payload);
diff --git a/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/service/HistoryPort.java b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/service/HistoryPort.java
index 57885d3..82cad76 100644
--- a/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/service/HistoryPort.java
+++ b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/service/HistoryPort.java
@@ -2,6 +2,7 @@
 
 import java.math.BigInteger;
 import java.util.List;
+import java.util.Optional;
 
 /**
  * Transaction/reward history served by the node's ADR-033 M2 endpoints.
@@ -22,9 +23,38 @@ List walletTransactions(String stakeAddress, String paymentAddress,
 
     List rewards(String stakeAddress, int page, int count);
 
+    /**
+     * How far a scan running right now has walked the chain, or empty when none
+     * is. A first scan from origin walks the whole chain before it can answer,
+     * which is long enough that a screen showing nothing reads as a failure —
+     * this is what lets the UI say what is happening instead.
+     *
+     * 

Read from whatever thread asks, including while the scan itself holds + * the backend executor. + */ + default Optional scanProgress() { + return Optional.empty(); + } + record TxRef(String txHash, long blockHeight, long blockTime, long slot) { } + /** + * Absolute chain heights: {@code fromBlock} is the cursor the scan resumed + * from (-1 for origin) and {@code tipBlock} the point the node reported it + * had indexed through when the scan started. + */ + record ScanProgress(long fromBlock, long currentBlock, long tipBlock) { + /** Share of the interval walked, 0..1. A scan with nothing to walk is done. */ + public double fraction() { + long span = tipBlock - fromBlock; + if (span <= 0) { + return 1.0; + } + return Math.clamp((double) (currentBlock - fromBlock) / span, 0.0, 1.0); + } + } + record RewardView(int epoch, BigInteger amount, String poolId, String type) { } diff --git a/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/service/MempoolConflictException.java b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/service/MempoolConflictException.java new file mode 100644 index 0000000..ef3af25 --- /dev/null +++ b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/service/MempoolConflictException.java @@ -0,0 +1,9 @@ +package com.bloxbean.cardano.yano.wallet.core.service; + +/** A definite rejection requiring fresh input selection and renewed approval. */ +public final class MempoolConflictException extends RuntimeException { + public MempoolConflictException(String nodeReason) { + super("Another pending transaction already uses an input from this draft. " + + "Rebuild and review the payment with the latest available funds. Node response: " + nodeReason); + } +} diff --git a/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/service/WalletService.java b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/service/WalletService.java index fc5a55c..e159cbf 100644 --- a/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/service/WalletService.java +++ b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/service/WalletService.java @@ -7,6 +7,12 @@ import com.bloxbean.cardano.client.api.model.Result; import com.bloxbean.cardano.client.util.HexUtil; import com.bloxbean.cardano.yano.wallet.core.tx.PendingTransaction; +import com.bloxbean.cardano.yano.wallet.core.tx.DappSigner; +import com.bloxbean.cardano.yano.wallet.core.tx.DappSignerSearch; +import com.bloxbean.cardano.yano.wallet.core.wallet.WalletAddresses; +import com.bloxbean.cardano.yano.wallet.core.wallet.WalletAddressScanner; +import com.bloxbean.cardano.client.crypto.bip32.HdKeyPair; +import com.bloxbean.cardano.client.transaction.util.TransactionUtil; import com.bloxbean.cardano.yano.wallet.core.tx.PendingTransactionStatus; import com.bloxbean.cardano.yano.wallet.core.tx.PendingTransactionStore; import com.bloxbean.cardano.yano.wallet.core.tx.QuickAdaTxDraft; @@ -24,6 +30,8 @@ import java.math.BigInteger; import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Set; import java.util.List; import java.util.Map; import java.util.Objects; @@ -37,6 +45,7 @@ public class WalletService { private final StoredWalletRepository repository; private final UtxoSupplier utxoSupplier; + private final UtxoSupplier selectionUtxoSupplier; private final ProtocolParamsSupplier protocolParamsSupplier; private final TransactionProcessor transactionProcessor; private final PendingTransactionStore pendingStore; @@ -51,8 +60,20 @@ public WalletService(StoredWalletRepository repository, TransactionProcessor transactionProcessor, PendingTransactionStore pendingStore, NodeStatusPort nodeStatusPort) { + this(repository, utxoSupplier, utxoSupplier, protocolParamsSupplier, + transactionProcessor, pendingStore, nodeStatusPort); + } + + public WalletService(StoredWalletRepository repository, + UtxoSupplier utxoSupplier, + UtxoSupplier selectionUtxoSupplier, + ProtocolParamsSupplier protocolParamsSupplier, + TransactionProcessor transactionProcessor, + PendingTransactionStore pendingStore, + NodeStatusPort nodeStatusPort) { this.repository = Objects.requireNonNull(repository, "repository is required"); this.utxoSupplier = Objects.requireNonNull(utxoSupplier, "utxoSupplier is required"); + this.selectionUtxoSupplier = Objects.requireNonNull(selectionUtxoSupplier, "selectionUtxoSupplier is required"); this.protocolParamsSupplier = Objects.requireNonNull(protocolParamsSupplier, "protocolParamsSupplier is required"); this.transactionProcessor = Objects.requireNonNull(transactionProcessor, "transactionProcessor is required"); @@ -394,6 +415,8 @@ public void trackConfirmation(String txHash, long timeoutSeconds) { /** An unlocked wallet bound to the service's node backend. */ public final class Session { private final UnlockedWallet unlocked; + // Public paths observed in this unlock session. Never persisted with private keys. + private final Set knownSignerPaths = new LinkedHashSet<>(); private Session(UnlockedWallet unlocked) { this.unlocked = unlocked; @@ -404,11 +427,23 @@ public StoredWallet profile() { } public WalletBalance balance() { - return balanceService.scan(unlocked.wallet(), utxoSupplier); + var scan = new WalletAddressScanner().scan(unlocked.wallet(), utxoSupplier); + for (var funded : scan.fundedAddresses()) + funded.address().getDerivationPath().ifPresent(path -> + rememberSigningPath(path.getRole().getValue(), path.getIndex().getValue())); + return balanceService.balance(scan); } public WalletAccountView addresses(int receiveAddressCount) { - return addressService.accountView(unlocked.profile(), unlocked.wallet(), receiveAddressCount); + var view = addressService.accountView(unlocked.profile(), unlocked.wallet(), receiveAddressCount); + for (int index = 0; index < receiveAddressCount; index++) rememberSigningPath(0, index); + return view; + } + + public WalletAddressService.AddressDetails addressDetails(int index) { + var details = addressService.addressDetails(unlocked.wallet(), index); + rememberSigningPath(0, index); + return details; } /** Builds and signs a payment without submitting — for review/approval UIs. */ @@ -416,7 +451,7 @@ public QuickAdaTxDraft draftPayment(String receiverAddress, BigInteger lovelace, List nativeAssets, String message) { return txService.buildSignedDraft( unlocked.wallet(), - utxoSupplier, + selectionUtxoSupplier, protocolParamsSupplier, transactionProcessor, receiverAddress, @@ -428,7 +463,7 @@ public QuickAdaTxDraft draftPayment(String receiverAddress, BigInteger lovelace, public QuickAdaTxDraft draftPayments(List payments, String message) { return txService.buildSignedDraft( unlocked.wallet(), - utxoSupplier, + selectionUtxoSupplier, protocolParamsSupplier, transactionProcessor, payments, @@ -532,21 +567,64 @@ public QuickAdaTxDraft draftVote(String govActionTxHash, int govActionIndex, * wallet's witness-set hex. Software wallets only (hardware = M4). */ public String signDappTx(String txHex, boolean partialSign) { - if (unlocked.profile().isHardware()) { - throw new WalletServiceException("dApp signing with a hardware wallet isn't supported yet."); - } - return com.bloxbean.cardano.yano.wallet.core.tx.DappSigner.witnessSetHex( - unlocked.wallet().getAccountAtIndex(0), txHex, partialSign); + return signDappTx(txHex, partialSign, reviewDappTx(txHex, DappSignerSearch.DEFAULT_LIMIT)); + } + + /** Remember a public receive/change path generated by this session (not supplied by a dApp). */ + public synchronized void rememberSigningPath(int role, int index) { + var path = new DappSignerSearch.KeyPath(role, index); + if (!knownSignerPaths.contains(path) && knownSignerPaths.size() >= DappSignerSearch.MAX_KNOWN_PATHS) + throw new WalletServiceException("Too many known signing paths in this session"); + knownSignerPaths.add(path); + } + + /** Discover transaction keys without signing or consulting address-use history. */ + public DappSignerSearch.Plan reviewDappTx(String txHex, int limit) { + if (unlocked.profile().isHardware()) + throw new WalletServiceException("Bounded software signer search does not support hardware wallets."); + List known; + synchronized (this) { known = List.copyOf(knownSignerPaths); } + return DappSignerSearch.transaction(unlocked.wallet(), txHex, selectionUtxoSupplier, known, limit); + } + + /** Sign only the reviewed paths and exact body, after the connector obtains user approval. */ + public String signDappTx(String txHex, boolean partialSign, DappSignerSearch.Plan plan) { + if (unlocked.profile().isHardware() || plan.account() != unlocked.profile().accountIndex() + || !plan.txHash().equals(TransactionUtil.getTxHash(HexUtil.decodeHexString(txHex)))) + throw new WalletServiceException("Signing review no longer matches this account or transaction."); + if (!partialSign && !plan.unmatched().isEmpty()) + throw new WalletServiceException("Cannot fully sign: unmatched signer hashes remain within the bounded search."); + if (!plan.hasSigners()) throw new WalletServiceException("No matching signing keys found in this account."); + List keys = new ArrayList<>(); + for (var path : plan.paymentPaths()) + keys.add(WalletAddresses.account(unlocked.wallet(), path.role(), path.index()).hdKeyPair()); + if (plan.stake()) keys.add(unlocked.wallet().getAccountAtIndex(0).stakeHdKeyPair()); + return DappSigner.witnessSetHex(keys, txHex); + } + + /** Discover the requested data key within this selected HD account. */ + public DappSignerSearch.DataPlan reviewDappData(byte[] address, byte[] payload, int limit) { + if (unlocked.profile().isHardware()) throw new WalletServiceException("dApp data signing with a hardware wallet isn't supported yet."); + List known; + synchronized (this) { known = List.copyOf(knownSignerPaths); } + return DappSignerSearch.data(unlocked.wallet(), address, payload, known, limit); } - /** Signs data for a dApp (CIP-30 signData / CIP-8). Software wallets only. */ + /** Signs with only the reviewed key; callers must obtain consent for this plan. */ public com.bloxbean.cardano.client.cip.cip30.DataSignature signDappData(byte[] addressBytes, - byte[] payloadBytes) { - if (unlocked.profile().isHardware()) { - throw new WalletServiceException("dApp signing with a hardware wallet isn't supported yet."); - } - return com.bloxbean.cardano.yano.wallet.core.tx.DappSigner.signData( - unlocked.wallet().getAccountAtIndex(0), addressBytes, payloadBytes); + byte[] payloadBytes, DappSignerSearch.DataPlan plan) { + if (unlocked.profile().isHardware() || plan.account() != unlocked.profile().accountIndex() + || !plan.hasSigner() || !plan.address().equals(HexUtil.encodeHexString(addressBytes)) + || !plan.payload().equals(HexUtil.encodeHexString(payloadBytes))) + throw new WalletServiceException("Data signing review no longer matches this account or request."); + var account = plan.stake() ? unlocked.wallet().getAccountAtIndex(0) + : WalletAddresses.account(unlocked.wallet(), plan.paymentPath().role(), plan.paymentPath().index()); + return com.bloxbean.cardano.yano.wallet.core.tx.DappSigner.signData(account, addressBytes, payloadBytes); + } + + /** Default bounded discovery for non-connector callers that already obtained consent. */ + public com.bloxbean.cardano.client.cip.cip30.DataSignature signDappData(byte[] address, byte[] payload) { + return signDappData(address, payload, reviewDappData(address, payload, DappSignerSearch.DEFAULT_LIMIT)); } /** Builds and signs a withdrawal of all available rewards. */ @@ -612,7 +690,7 @@ private QuickAdaTxDraft signComposedTx(com.bloxbean.cardano.client.quicktx.Tx tx String summary, com.bloxbean.cardano.client.function.TxSigner extraSigner) { var builder = new com.bloxbean.cardano.client.quicktx.QuickTxBuilder( - utxoSupplier, protocolParamsSupplier, transactionProcessor); + selectionUtxoSupplier, protocolParamsSupplier, transactionProcessor); var composed = builder.compose(tx) .feePayer(signerAccount.baseAddress()) .withSigner(com.bloxbean.cardano.client.function.helper.SignerProviders @@ -652,6 +730,12 @@ private QuickAdaTxDraft signComposedTx(com.bloxbean.cardano.client.quicktx.Tx tx * and is terminal; a transport failure (node briefly unreachable) * throws {@link RetryableSubmitException} WITHOUT a pending record so * the caller can retry the already-signed draft. + * + *

Only a 4xx is a rejection. Any other unsuccessful answer — a 5xx, + * or none at all — leaves the outcome as unknown as a dropped + * connection: the node may have taken the transaction. Marking that + * FAILED discards the draft while its payment may still land, and a + * rebuild then pays again from other funds. */ public String submit(QuickAdaTxDraft draft) { PendingTransaction pending = PendingTransaction.fromDraft( @@ -659,10 +743,17 @@ public String submit(QuickAdaTxDraft draft) { Result result; try { result = transactionProcessor.submitTransaction(HexUtil.decodeHexString(draft.cborHex())); + } catch (MempoolConflictException e) { + pendingStore.save(pending.markFailed(e.getMessage())); + throw e; } catch (Exception e) { throw new RetryableSubmitException("Transaction submit failed: " + e.getMessage(), e); } if (!result.isSuccessful()) { + if (result.code() < 400 || result.code() >= 500) { + throw new RetryableSubmitException("Transaction submit failed: the node did not confirm " + + "receiving it (HTTP " + result.code() + ": " + result.getResponse() + ")", null); + } pendingStore.save(pending.markFailed(String.valueOf(result.getResponse()))); throw new WalletServiceException("Transaction rejected: " + result.getResponse()); } diff --git a/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/tx/DappSigner.java b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/tx/DappSigner.java index 6c54e54..26a65a0 100644 --- a/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/tx/DappSigner.java +++ b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/tx/DappSigner.java @@ -1,12 +1,15 @@ package com.bloxbean.cardano.yano.wallet.core.tx; import com.bloxbean.cardano.client.account.Account; +import com.bloxbean.cardano.client.crypto.Blake2bUtil; +import com.bloxbean.cardano.client.crypto.config.CryptoConfiguration; import com.bloxbean.cardano.client.address.Address; import com.bloxbean.cardano.client.cip.cip30.CIP30DataSigner; import com.bloxbean.cardano.client.cip.cip30.DataSignError; import com.bloxbean.cardano.client.cip.cip30.DataSignature; import com.bloxbean.cardano.client.common.cbor.CborSerializationUtil; import com.bloxbean.cardano.client.transaction.TransactionSigner; +import com.bloxbean.cardano.client.crypto.bip32.HdKeyPair; import com.bloxbean.cardano.client.transaction.spec.Transaction; import com.bloxbean.cardano.client.transaction.spec.TransactionBody; import com.bloxbean.cardano.client.transaction.spec.TransactionWitnessSet; @@ -40,6 +43,17 @@ private DappSigner() { } public static String witnessSetHex(Account account, String txHex, boolean partialSign) { + Transaction transaction; + try { transaction = Transaction.deserialize(HexUtil.decodeHexString(txHex)); } + catch (Exception e) { throw new IllegalArgumentException("Invalid transaction CBOR", e); } + List keys = new ArrayList<>(); + keys.add(account.hdKeyPair()); + if (needsStakeKey(account, transaction.getBody())) keys.add(account.stakeHdKeyPair()); + return witnessSetHex(keys, txHex); + } + + /** Sign with only the approved keys, preserving the exact transaction body bytes. */ + public static String witnessSetHex(List keys, String txHex) { byte[] txBytes = HexUtil.decodeHexString(txHex); Transaction tx; try { @@ -50,6 +64,7 @@ public static String witnessSetHex(Account account, String txHex, boolean partia // Snapshot any witnesses already on the tx (script/other signers) so we // return only the ones WE add. + verifiedWitnessHashes(tx, txBytes); Set preexisting = vkeyHexes(tx); // Sign the ORIGINAL bytes, never a re-encoding of them. @@ -68,9 +83,12 @@ public static String witnessSetHex(Account account, String txHex, boolean partia // TransactionSigner.sign(byte[], …) takes the body slice out of the bytes it // was handed, hashes that, and splices the witness in without touching the // body — so whatever the dApp encoded is what gets signed. - byte[] signedBytes = TransactionSigner.INSTANCE.sign(txBytes, account.hdKeyPair()); - if (needsStakeKey(account, tx.getBody())) { - signedBytes = TransactionSigner.INSTANCE.sign(signedBytes, account.stakeHdKeyPair()); + byte[] signedBytes = txBytes; + Set addedKeys = new HashSet<>(); + for (HdKeyPair key : keys) { + String publicKey = HexUtil.encodeHexString(key.getPublicKey().getKeyData()); + if (!preexisting.contains(publicKey) && addedKeys.add(publicKey)) + signedBytes = TransactionSigner.INSTANCE.sign(signedBytes, key); } // The body must be identical to what we were given. This cannot fail with @@ -143,7 +161,7 @@ public static DataSignature signData(Account account, byte[] addressBytes, byte[ * while an extra witness breaks every ordinary transaction whose fee was * computed exactly. */ - private static boolean needsStakeKey(Account account, TransactionBody body) { + static boolean needsStakeKey(Account account, TransactionBody body) { String stakeKeyHash; try { stakeKeyHash = HexUtil.encodeHexString( @@ -191,6 +209,20 @@ private static List orEmpty(List list) { return list == null ? List.of() : list; } + /** Existing witnesses count only if they verify against the original body. */ + static Set verifiedWitnessHashes(Transaction tx, byte[] bytes) { + Set hashes = new HashSet<>(); + byte[] bodyHash = HexUtil.decodeHexString(TransactionUtil.getTxHash(bytes)); + if (tx.getWitnessSet() != null && tx.getWitnessSet().getVkeyWitnesses() != null) + for (VkeyWitness witness : tx.getWitnessSet().getVkeyWitnesses()) { + if (!CryptoConfiguration.INSTANCE.getSigningProvider() + .verify(witness.getSignature(), bodyHash, witness.getVkey())) + throw new IllegalArgumentException("Invalid pre-existing transaction signature"); + hashes.add(HexUtil.encodeHexString(Blake2bUtil.blake2bHash224(witness.getVkey()))); + } + return hashes; + } + private static Set vkeyHexes(Transaction tx) { Set keys = new HashSet<>(); if (tx.getWitnessSet() != null && tx.getWitnessSet().getVkeyWitnesses() != null) { diff --git a/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/tx/DappSignerSearch.java b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/tx/DappSignerSearch.java new file mode 100644 index 0000000..3f3a7fe --- /dev/null +++ b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/tx/DappSignerSearch.java @@ -0,0 +1,160 @@ +package com.bloxbean.cardano.yano.wallet.core.tx; + +import com.bloxbean.cardano.client.account.Account; +import com.bloxbean.cardano.client.address.Address; +import com.bloxbean.cardano.client.api.UtxoSupplier; +import com.bloxbean.cardano.client.transaction.spec.Transaction; +import com.bloxbean.cardano.client.transaction.spec.TransactionInput; +import com.bloxbean.cardano.client.transaction.util.TransactionUtil; +import com.bloxbean.cardano.client.util.HexUtil; +import com.bloxbean.cardano.hdwallet.Wallet; +import com.bloxbean.cardano.yano.wallet.core.wallet.WalletAddresses; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Bounded, history-independent software signing discovery in one selected HD account. + * No signatures are made here. Receive and change chains each get their own 30/50 window; + * explicitly known paths are searched first. Never interpret an unmatched hash as absent + * from the seed: it may belong to another party, account, or an index outside the window. + */ +public final class DappSignerSearch { + public static final int DEFAULT_LIMIT = 30; + public static final int EXTENDED_LIMIT = 50; + public static final int MAX_KNOWN_PATHS = 10_000; + + private DappSignerSearch() {} + + /** Public payment path within the selected account; never contains key material. */ + public record KeyPath(int role, int index) { + public KeyPath { + if ((role != 0 && role != 1) || index < 0) + throw new IllegalArgumentException("Invalid payment path"); + } + public String describe(int account) { + return "m/1852'/1815'/" + account + "'/" + role + "/" + index; + } + } + + /** Immutable review data bound to the exact original transaction body. */ + public record Plan(String txHash, int account, List paymentPaths, + boolean stake, List unmatched, int limit) { + public Plan { + paymentPaths = List.copyOf(paymentPaths); + unmatched = List.copyOf(unmatched); + } + public boolean hasSigners() { return stake || !paymentPaths.isEmpty(); } + public String description() { + StringBuilder text = new StringBuilder("Selected HD account: " + account + + "\nSearched known paths and receive/change indexes 0–" + (limit - 1) + ".\n"); + for (KeyPath path : paymentPaths) text.append("Payment: ").append(path.describe(account)).append('\n'); + if (stake) text.append("Stake: m/1852'/1815'/").append(account).append("'/2/0\n"); + text.append("Unmatched signer hashes: ").append(unmatched.size()); + for (String hash : unmatched) text.append('\n').append(hash); + if (!unmatched.isEmpty()) text.append("\nThese may belong to other wallets or lie outside this search."); + return text.toString(); + } + } + + /** Resolve actual spent/collateral outputs, never reference inputs, to establish input ownership. */ + public static Plan transaction(Wallet wallet, String txHex, UtxoSupplier supplier, + Collection known, int limit) { + if (txHex == null || txHex.length() > 131072) + throw new IllegalArgumentException("Transaction exceeds signing review size limit"); + Transaction tx; + try { tx = Transaction.deserialize(HexUtil.decodeHexString(txHex)); } + catch (Exception e) { throw new IllegalArgumentException("Invalid transaction CBOR", e); } + Set wanted = new LinkedHashSet<>(); + if (tx.getBody().getRequiredSigners() != null) + for (byte[] hash : tx.getBody().getRequiredSigners()) { + if (hash.length != 28) throw new IllegalArgumentException("Invalid required signer hash"); + wanted.add(HexUtil.encodeHexString(hash)); + } + Set inputs = new LinkedHashSet<>(); + if (tx.getBody().getInputs() != null) inputs.addAll(tx.getBody().getInputs()); + if (tx.getBody().getCollateral() != null) inputs.addAll(tx.getBody().getCollateral()); + if (inputs.size() > 256) throw new IllegalArgumentException("Too many inputs for signing review"); + for (TransactionInput input : inputs) { + var output = supplier.getTxOutput(input.getTransactionId(), input.getIndex()) + .orElseThrow(() -> new IllegalArgumentException("Cannot resolve signing input " + + input.getTransactionId() + "#" + input.getIndex())); + if (!input.getTransactionId().equals(output.getTxHash()) || input.getIndex() != output.getOutputIndex()) + throw new IllegalArgumentException("Resolved input identity mismatch"); + String hash = paymentHash(new Address(output.getAddress()).getBytes()); + if (hash != null) wanted.add(hash); + } + Set witnessed = DappSigner.verifiedWitnessHashes(tx, HexUtil.decodeHexString(txHex)); + wanted.removeAll(witnessed); + Account primary = wallet.getAccountAtIndex(0); + boolean stake = DappSigner.needsStakeKey(primary, tx.getBody()) + && !witnessed.contains(HexUtil.encodeHexString(primary.stakeHdKeyPair().getPublicKey().getKeyHash())); + if (stake) wanted.remove(HexUtil.encodeHexString(primary.stakeHdKeyPair().getPublicKey().getKeyHash())); + List paths = match(wallet, wanted, known, limit); + return new Plan(TransactionUtil.getTxHash(HexUtil.decodeHexString(txHex)), wallet.getAccountNo(), + paths, stake, List.copyOf(wanted), limit); + } + + /** One data-signing credential, bound to exact address and payload bytes. */ + public record DataPlan(int account, String address, String payload, KeyPath paymentPath, + boolean stake, int limit) { + public boolean hasSigner() { return stake || paymentPath != null; } + public String description() { + return "Selected HD account: " + account + "\nSearched known paths and receive/change indexes 0–" + (limit - 1) + + ".\n" + (paymentPath != null ? "Payment: " + paymentPath.describe(account) + : stake ? "Stake: m/1852'/1815'/" + account + "'/2/0" : "Requested key not found in this account within the search window."); + } + } + + /** Search a CIP-8 key without consulting address history or signing unrelated keys. */ + public static DataPlan data(Wallet wallet, byte[] address, byte[] payload, Collection known, int limit) { + if (payload.length > 65536) throw new IllegalArgumentException("Data payload exceeds 64 KB"); + if (limit != DEFAULT_LIMIT && limit != EXTENDED_LIMIT) + throw new IllegalArgumentException("Signer search must use 30 or 50 addresses per chain"); + if (address.length != 29 && address.length != 57) throw new IllegalArgumentException("Unsupported signing address length"); + int type = (address[0] & 255) >>> 4; + if (!((type == 0 || type == 2) && address.length == 57 || (type == 6 || type == 14) && address.length == 29)) + throw new IllegalArgumentException("Data signing requires a key payment or key reward address"); + Account primary = wallet.getAccountAtIndex(0); + if ((address[0] & 15) != (new Address(primary.baseAddress()).getBytes()[0] & 15)) + throw new IllegalArgumentException("Signing address is on another network"); + String hash = HexUtil.encodeHexString(Arrays.copyOfRange(address, 1, 29)); + boolean stake = type == 14 && hash.equals(HexUtil.encodeHexString(primary.stakeHdKeyPair().getPublicKey().getKeyHash())); + List paths = type == 14 ? List.of() : match(wallet, new LinkedHashSet<>(List.of(hash)), known, limit); + return new DataPlan(wallet.getAccountNo(), HexUtil.encodeHexString(address), HexUtil.encodeHexString(payload), + paths.isEmpty() ? null : paths.getFirst(), stake, limit); + } + + private static List match(Wallet wallet, Set wanted, Collection known, int limit) { + if (limit != DEFAULT_LIMIT && limit != EXTENDED_LIMIT) + throw new IllegalArgumentException("Signer search must use 30 or 50 addresses per chain"); + if (known.size() > MAX_KNOWN_PATHS) throw new IllegalArgumentException("Too many known signing paths"); + Set candidates = new LinkedHashSet<>(known); + for (int index = 0; index < limit; index++) { + candidates.add(new KeyPath(0, index)); + candidates.add(new KeyPath(1, index)); + } + List matches = new ArrayList<>(); + for (KeyPath path : candidates) { + if (wanted.isEmpty()) break; + if (Thread.currentThread().isInterrupted()) throw new IllegalStateException("Signer search interrupted"); + Account key = WalletAddresses.account(wallet, path.role(), path.index()); + if (wanted.remove(HexUtil.encodeHexString(key.hdKeyPair().getPublicKey().getKeyHash()))) + matches.add(path); + } + return matches; + } + + private static String paymentHash(byte[] address) { + if (address.length < 29) throw new IllegalArgumentException("Invalid Shelley address"); + int type = (address[0] & 0xff) >>> 4; + if (type == 0 || type == 2 || type == 4 || type == 6) + return HexUtil.encodeHexString(Arrays.copyOfRange(address, 1, 29)); + if (type == 1 || type == 3 || type == 5 || type == 7) return null; + throw new IllegalArgumentException("Unsupported signing input address type"); + } +} diff --git a/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/tx/QuickAdaTxService.java b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/tx/QuickAdaTxService.java index 4cb1019..67e9e8d 100644 --- a/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/tx/QuickAdaTxService.java +++ b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/tx/QuickAdaTxService.java @@ -13,6 +13,8 @@ import com.bloxbean.cardano.client.transaction.util.TransactionUtil; import com.bloxbean.cardano.client.util.HexUtil; import com.bloxbean.cardano.hdwallet.Wallet; +import com.bloxbean.cardano.yano.wallet.core.wallet.DiscoveredSpendingWallet; +import com.bloxbean.cardano.yano.wallet.core.wallet.WalletAddressScanner; import java.math.BigInteger; import java.util.LinkedHashMap; @@ -79,11 +81,13 @@ public QuickAdaTxDraft buildSignedDraft( throw new IllegalArgumentException("at least one payment is required"); } + Wallet spendingWallet = new DiscoveredSpendingWallet(wallet, + new WalletAddressScanner().scan(wallet, utxoSupplier)); QuickTxBuilder builder = new QuickTxBuilder(utxoSupplier, protocolParamsSupplier, transactionProcessor); String senderAddress = wallet.getBaseAddressString(0); Tx tx = new Tx(); normalizedPayments.forEach(payment -> tx.payToAddress(payment.receiverAddress(), payment.amounts())); - tx.from(wallet); + tx.from(spendingWallet); tx.withChangeAddress(senderAddress); String normalizedMetadata = metadataMessage == null ? null : metadataMessage.trim(); if (normalizedMetadata != null && !normalizedMetadata.isBlank()) { @@ -91,8 +95,8 @@ public QuickAdaTxDraft buildSignedDraft( } Transaction signed = builder.compose(tx) - .feePayer(wallet) - .withSigner(SignerProviders.signerFrom(wallet)) + .feePayer(spendingWallet) + .withSigner(SignerProviders.signerFrom(spendingWallet)) .buildAndSign(); byte[] cbor; diff --git a/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/DiscoveredSpendingWallet.java b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/DiscoveredSpendingWallet.java new file mode 100644 index 0000000..d08f4be --- /dev/null +++ b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/DiscoveredSpendingWallet.java @@ -0,0 +1,62 @@ +package com.bloxbean.cardano.yano.wallet.core.wallet; + +import com.bloxbean.cardano.client.account.Account; +import com.bloxbean.cardano.client.address.Address; +import com.bloxbean.cardano.client.api.model.WalletUtxo; +import com.bloxbean.cardano.client.transaction.spec.Transaction; +import com.bloxbean.cardano.hdwallet.DefaultWallet; +import com.bloxbean.cardano.hdwallet.Wallet; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.IntStream; + +/** + * A finite address iterator for QuickTx, using the balance scanner's funded addresses. + * CCL's DefaultWallet signer drops the derivation role, so signing must preserve it here. + * Only used for a single software payment draft; never persisted as a wallet profile. + */ +public final class DiscoveredSpendingWallet extends DefaultWallet { + private final List

addresses; + + public DiscoveredSpendingWallet(Wallet source, WalletAddressScanner.Scan scan) { + super(source.getNetwork(), null, source.getRootPvtKey() + .orElseThrow(() -> new IllegalStateException("Software payment requires a signing key")), + null, source.getAccountNo()); + // QuickTx treats position zero as the default change address. Keep it stable, + // even when all funds are elsewhere. A nonempty explicit index list also + // prevents CCL from falling back to its own receive-only gap scan. + Map unique = new LinkedHashMap<>(); + Address primary = WalletAddresses.baseAddress(source, 0, 0); + unique.put(primary.toBech32(), primary); + for (var funded : scan.fundedAddresses()) { + unique.put(funded.address().toBech32(), funded.address()); + } + addresses = List.copyOf(unique.values()); + setIndexesToScan(IntStream.range(0, addresses.size()).toArray()); + } + + @Override + public Address getBaseAddress(int position) { + return addresses.get(position); + } + + @Override + public Transaction sign(Transaction transaction, Set utxos) { + Map signers = new LinkedHashMap<>(); + for (WalletUtxo utxo : utxos) { + var path = utxo.getDerivationPath(); + if (path == null || addresses.stream().noneMatch(address -> + address.getDerivationPath().filter(path::equals).isPresent())) { + throw new IllegalArgumentException("Input has no discovered wallet derivation path"); + } + Account account = Account.createFromRootKey(getNetwork(), getRootPvtKey().orElseThrow(), path); + signers.putIfAbsent(account.baseAddress(), account); + } + if (signers.isEmpty()) throw new IllegalStateException("No wallet payment signers found"); + for (Account account : signers.values()) transaction = account.sign(transaction); + return transaction; + } +} diff --git a/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/FileStoredWalletRepository.java b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/FileStoredWalletRepository.java index de4a18c..0053f6e 100644 --- a/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/FileStoredWalletRepository.java +++ b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/FileStoredWalletRepository.java @@ -263,7 +263,8 @@ public synchronized List discoverAccounts(String seedId, char /** An account is in use if any address within its gap window has history. */ private static boolean hasHistory(Wallet wallet, Predicate addressUsed, int gapLimit) { for (int addressIndex = 0; addressIndex < gapLimit; addressIndex++) { - if (addressUsed.test(wallet.getBaseAddressString(addressIndex))) { + if (addressUsed.test(WalletAddresses.baseAddress(wallet, 0, addressIndex).toBech32()) + || addressUsed.test(WalletAddresses.baseAddress(wallet, 1, addressIndex).toBech32())) { return true; } } diff --git a/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/StoredWalletRepository.java b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/StoredWalletRepository.java index 2462232..5d0082b 100644 --- a/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/StoredWalletRepository.java +++ b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/StoredWalletRepository.java @@ -107,7 +107,7 @@ void addFactor(String walletId, char[] passphrase, VaultSecondFactor unlockFacto * @param addressUsed probes the chain for an address's history — supplied by * the caller so this layer stays node-agnostic * @param maxAccounts hard stop on how many indexes to probe - * @param gapLimit addresses probed per account before calling it unused + * @param gapLimit addresses probed on each payment chain before calling an account unused * @throws com.bloxbean.cardano.yano.wallet.core.vault.WalletVaultException * if the seed is unknown, is a hardware wallet (its accounts live on * the device), or the passphrase is wrong diff --git a/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletAddressScanner.java b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletAddressScanner.java new file mode 100644 index 0000000..570b1f2 --- /dev/null +++ b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletAddressScanner.java @@ -0,0 +1,95 @@ +package com.bloxbean.cardano.yano.wallet.core.wallet; + +import com.bloxbean.cardano.client.address.Address; +import com.bloxbean.cardano.client.api.UtxoSupplier; +import com.bloxbean.cardano.client.api.model.Utxo; +import com.bloxbean.cardano.hdwallet.Wallet; +import com.bloxbean.cardano.yano.wallet.core.service.HistoryPort.HistoryNotSupportedException; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** Shared discovery for balances and software payments; each chain has its own history gap. */ +public final class WalletAddressScanner { + // A safety bound, not a successful stopping condition. Never label a truncated scan complete. + public static final int DEFAULT_MAX_ADDRESSES_PER_CHAIN = 10_000; + + public record FundedAddress(Address address, List utxos) { + public FundedAddress { utxos = List.copyOf(utxos); } + } + + public record Scan(int addressCount, List fundedAddresses, String warning) { + public Scan { fundedAddresses = List.copyOf(fundedAddresses); } + public boolean complete() { return warning == null; } + } + + public Scan scan(Wallet wallet, UtxoSupplier supplier) { + Objects.requireNonNull(wallet, "wallet is required"); + int limit = Integer.getInteger("yano.wallet.scan.max-addresses-per-chain", DEFAULT_MAX_ADDRESSES_PER_CHAIN); + return scan(wallet, supplier, wallet.getGapLimit(), limit); + } + + public Scan scan(Wallet wallet, UtxoSupplier supplier, int gapLimit, int maxAddressesPerChain) { + Objects.requireNonNull(wallet, "wallet is required"); + Objects.requireNonNull(supplier, "utxoSupplier is required"); + if (gapLimit <= 0 || maxAddressesPerChain <= 0) { + throw new IllegalArgumentException("gapLimit and maxAddressesPerChain must be positive"); + } + List funded = new ArrayList<>(); + int scanned = 0; + boolean historyAvailable = true; + int recoveryWindow = Integer.getInteger("yano.wallet.scan.historyless-addresses-per-chain", 200); + if (recoveryWindow <= 0) throw new IllegalArgumentException("Historyless scan window must be positive"); + int[] scannedPerChain = new int[2]; + for (int role = 0; role <= 1; role++) { + int unused = 0; + int chainLimit = historyAvailable ? maxAddressesPerChain : Math.min(recoveryWindow, maxAddressesPerChain); + for (int index = 0; index < chainLimit && (!historyAvailable || unused < gapLimit); index++) { + if (Thread.currentThread().isInterrupted()) { + throw new IncompleteScanException("Address scan interrupted; balance is unavailable"); + } + Address address = WalletAddresses.baseAddress(wallet, role, index); + List utxos = supplier.getAll(address.toBech32()); + if (utxos == null) { + throw new IncompleteScanException("Node did not return UTxOs; balance is unavailable"); + } + scanned++; + scannedPerChain[role]++; + if (!utxos.isEmpty()) { + unused = 0; + funded.add(new FundedAddress(address, utxos)); + } else if (historyAvailable) { + try { + unused = supplier.isUsedAddress(address) ? 0 : unused + 1; + } catch (HistoryNotSupportedException e) { + // No historical evidence means no valid gap decision. Scan a fixed + // range instead, and carry the uncertainty all the way to the UI. + historyAvailable = false; + chainLimit = (int) Math.min((long) maxAddressesPerChain, + Math.max((long) recoveryWindow, (long) index + gapLimit + 1)); + } catch (RuntimeException e) { + throw new IncompleteScanException( + "Address scan incomplete: transaction history is unavailable. " + + "Use a node with address history enabled and retry.", e); + } + } + } + if (historyAvailable && unused < gapLimit) { + throw new IncompleteScanException("Address scan incomplete: reached " + maxAddressesPerChain + + " addresses on the " + (role == 0 ? "receive" : "change") + + " chain before finding an unused gap. Increase yano.wallet.scan.max-addresses-per-chain and retry."); + } + } + String warning = historyAvailable ? null + : "Address history is unavailable. Scanned receive indices 0–" + (scannedPerChain[0] - 1) + + " and change indices 0–" + (scannedPerChain[1] - 1) + + "; more funds may exist beyond the scanned range."; + return new Scan(scanned, funded, warning); + } + + public static final class IncompleteScanException extends IllegalStateException { + public IncompleteScanException(String message) { super(message); } + public IncompleteScanException(String message, Throwable cause) { super(message, cause); } + } +} diff --git a/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletAddressService.java b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletAddressService.java index 786bee3..8a52bbf 100644 --- a/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletAddressService.java +++ b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletAddressService.java @@ -1,12 +1,38 @@ package com.bloxbean.cardano.yano.wallet.core.wallet; import com.bloxbean.cardano.hdwallet.Wallet; +import com.bloxbean.cardano.client.crypto.bip32.key.HdPublicKey; +import com.bloxbean.cardano.client.util.HexUtil; import java.util.List; import java.util.Objects; import java.util.stream.IntStream; public class WalletAddressService { + /** Raw verification keys only: no private material or extended-key chain codes. */ + public record AddressDetails(String address, String paymentPath, String stakePath, + String paymentPublicKey, String paymentKeyHash, + String stakePublicKey, String stakeKeyHash) {} + + public AddressDetails addressDetails(Wallet wallet, int index) { + if (index < 0) throw new IllegalArgumentException("Address index must be non-negative"); + HdPublicKey payment; + HdPublicKey stake; + if (wallet instanceof WatchOnlyWallet watchOnly) { + payment = watchOnly.childKey(0, index); + stake = watchOnly.childKey(2, 0); + } else { + var account = wallet.getAccountAtIndex(index); + payment = account.hdKeyPair().getPublicKey(); + stake = account.stakeHdKeyPair().getPublicKey(); + } + return new AddressDetails(wallet.getBaseAddressString(index), + receiveDerivationPath(wallet.getAccountNo(), index), + "m/1852'/1815'/" + wallet.getAccountNo() + "'/2/0", + HexUtil.encodeHexString(payment.getKeyData()), HexUtil.encodeHexString(payment.getKeyHash()), + HexUtil.encodeHexString(stake.getKeyData()), HexUtil.encodeHexString(stake.getKeyHash())); + } + public WalletAccountView accountView(StoredWallet profile, Wallet wallet, int receiveAddressCount) { Objects.requireNonNull(profile, "profile is required"); Objects.requireNonNull(wallet, "wallet is required"); diff --git a/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletAddresses.java b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletAddresses.java new file mode 100644 index 0000000..608f8e7 --- /dev/null +++ b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletAddresses.java @@ -0,0 +1,40 @@ +package com.bloxbean.cardano.yano.wallet.core.wallet; + +import com.bloxbean.cardano.client.account.Account; +import com.bloxbean.cardano.client.address.Address; +import com.bloxbean.cardano.client.crypto.cip1852.DerivationPath; +import com.bloxbean.cardano.hdwallet.Wallet; + +/** Shelley base addresses on the receive (0) and change (1) chains. */ +public final class WalletAddresses { + private WalletAddresses() {} + + public static DerivationPath path(int account, int role, int index) { + if ((role != 0 && role != 1) || account < 0 || index < 0) { + throw new IllegalArgumentException("Invalid payment address path"); + } + var path = role == 0 + ? DerivationPath.createExternalAddressDerivationPathForAccount(account) + : DerivationPath.createInternalAddressDerivationPathForAccount(account); + path.getIndex().setValue(index); + return path; + } + + public static Address baseAddress(Wallet wallet, int role, int index) { + var path = path(wallet.getAccountNo(), role, index); + if (role == 0) { + return new Address(wallet.getBaseAddressString(index), path); + } + if (wallet instanceof WatchOnlyWallet watchOnly) { + return new Address(watchOnly.getChangeAddress(index).toBech32(), path); + } + return new Address(account(wallet, role, index).baseAddress(), path); + } + + public static Account account(Wallet wallet, int role, int index) { + if (role == 0) return wallet.getAccountAtIndex(index); + return Account.createFromRootKey(wallet.getNetwork(), wallet.getRootPvtKey() + .orElseThrow(() -> new IllegalStateException("Wallet has no software signing key")), + path(wallet.getAccountNo(), role, index)); + } +} diff --git a/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletBalance.java b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletBalance.java index 3c576ea..64d5834 100644 --- a/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletBalance.java +++ b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletBalance.java @@ -8,7 +8,15 @@ public record WalletBalance( int addressCount, int utxoCount, List utxos, - List assets) { + List assets, + String scanWarning) { + + public boolean complete() { return scanWarning == null; } + + public WalletBalance(BigInteger lovelace, int addressCount, int utxoCount, + List utxos, List assets) { + this(lovelace, addressCount, utxoCount, utxos, assets, null); + } public WalletBalance { lovelace = lovelace == null ? BigInteger.ZERO : lovelace; diff --git a/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletBalanceService.java b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletBalanceService.java index 12153fd..9852319 100644 --- a/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletBalanceService.java +++ b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletBalanceService.java @@ -11,89 +11,41 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Objects; -/** - * Scans a wallet's receive addresses through a {@link UtxoSupplier} and - * aggregates the balance. - * - *

The gap decision asks {@link UtxoSupplier#isUsedAddress} — against a Yano - * node with the ADR-033 M2 address-tx index, that resolves to - * {@code GET /addresses/{address}/transactions}, so fully-spent addresses - * still count as used and restored wallets scan correctly. When the supplier - * cannot answer (older node, endpoint disabled), the scan falls back to the - * "has unspent UTXO" proxy, which can under-scan wallets whose early - * addresses were emptied. - */ +/** Aggregates all UTxOs discovered on both Shelley payment chains. */ public class WalletBalanceService { - private static final int DEFAULT_GAP_LIMIT = 20; - private static final int DEFAULT_MAX_ADDRESSES = 100; - public WalletBalance scan(Wallet wallet, UtxoSupplier utxoSupplier) { - return scan(wallet, utxoSupplier, DEFAULT_GAP_LIMIT, DEFAULT_MAX_ADDRESSES); + return balance(new WalletAddressScanner().scan(wallet, utxoSupplier)); } public WalletBalance scan(Wallet wallet, UtxoSupplier utxoSupplier, int gapLimit, int maxAddresses) { - Objects.requireNonNull(wallet, "wallet is required"); - Objects.requireNonNull(utxoSupplier, "utxoSupplier is required"); - if (gapLimit <= 0) { - throw new IllegalArgumentException("gapLimit must be positive"); - } - if (maxAddresses <= 0) { - throw new IllegalArgumentException("maxAddresses must be positive"); - } + return balance(new WalletAddressScanner().scan(wallet, utxoSupplier, gapLimit, maxAddresses)); + } + /** Aggregate a completed scan; callers may retain its public derivation paths. */ + public WalletBalance balance(WalletAddressScanner.Scan scan) { BigInteger total = BigInteger.ZERO; - int unusedStreak = 0; - int scannedAddresses = 0; List walletUtxos = new ArrayList<>(); Map assets = new LinkedHashMap<>(); - - for (int index = 0; index < maxAddresses && unusedStreak < gapLimit; index++) { - String address = wallet.getBaseAddressString(index); - List utxos = utxoSupplier.getAll(address); - scannedAddresses++; - - if (utxos == null || utxos.isEmpty()) { - if (isUsedAddress(utxoSupplier, address)) { - // Used but fully spent: not a gap — keep scanning past it. - unusedStreak = 0; - } else { - unusedStreak++; - } - continue; - } - - unusedStreak = 0; - for (Utxo utxo : utxos) { + java.util.Set seen = new java.util.HashSet<>(); + for (var funded : scan.fundedAddresses()) { + String address = funded.address().toBech32(); + for (Utxo utxo : funded.utxos()) { + if (!seen.add(utxo.getTxHash() + "#" + utxo.getOutputIndex())) continue; BigInteger lovelace = lovelace(utxo); total = total.add(lovelace); aggregateAssets(utxo, assets); walletUtxos.add(new WalletUtxoView( - address, - utxo.getTxHash(), - utxo.getOutputIndex(), - lovelace, - assetCount(utxo), + address, utxo.getTxHash(), utxo.getOutputIndex(), lovelace, assetCount(utxo), utxo.getDataHash() != null || utxo.getInlineDatum() != null, utxo.getReferenceScriptHash() != null)); } } - List assetBalances = assets.entrySet().stream() .sorted(Comparator.comparing(Map.Entry::getKey)) .map(entry -> new WalletAssetBalance(entry.getKey(), entry.getValue())) .toList(); - return new WalletBalance(total, scannedAddresses, walletUtxos.size(), walletUtxos, assetBalances); - } - - private boolean isUsedAddress(UtxoSupplier utxoSupplier, String address) { - try { - return utxoSupplier.isUsedAddress(new com.bloxbean.cardano.client.address.Address(address)); - } catch (Exception e) { - // Older node / endpoint disabled: fall back to the unspent-only proxy. - return false; - } + return new WalletBalance(total, scan.addressCount(), walletUtxos.size(), walletUtxos, assetBalances, scan.warning()); } private BigInteger lovelace(Utxo utxo) { diff --git a/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/WatchOnlyWallet.java b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/WatchOnlyWallet.java index a50e752..58170ba 100644 --- a/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/WatchOnlyWallet.java +++ b/wallet-core/src/main/java/com/bloxbean/cardano/yano/wallet/core/wallet/WatchOnlyWallet.java @@ -51,7 +51,7 @@ public static WatchOnlyWallet fromHex(Network network, String accountXpubHex, in return new WatchOnlyWallet(network, HexUtil.decodeHexString(accountXpubHex), accountNo); } - private HdPublicKey childKey(int role, int index) { + HdPublicKey childKey(int role, int index) { return cip1852.getPublicKeyFromAccountPubKey(accountXpub, role, index); } @@ -62,6 +62,10 @@ public Address getBaseAddress(int index) { return AddressProvider.getBaseAddress(childKey(ROLE_EXTERNAL, index), childKey(ROLE_STAKE, STAKE_INDEX), network); } + public Address getChangeAddress(int index) { + return AddressProvider.getBaseAddress(childKey(1, index), childKey(ROLE_STAKE, STAKE_INDEX), network); + } + @Override public Address getBaseAddress(int account, int index) { return getBaseAddress(index); diff --git a/wallet-core/src/test/java/com/bloxbean/cardano/yano/wallet/core/service/SubmitOutcomeTest.java b/wallet-core/src/test/java/com/bloxbean/cardano/yano/wallet/core/service/SubmitOutcomeTest.java new file mode 100644 index 0000000..f62d7db --- /dev/null +++ b/wallet-core/src/test/java/com/bloxbean/cardano/yano/wallet/core/service/SubmitOutcomeTest.java @@ -0,0 +1,89 @@ +package com.bloxbean.cardano.yano.wallet.core.service; + +import com.bloxbean.cardano.client.api.TransactionProcessor; +import com.bloxbean.cardano.client.api.model.EvaluationResult; +import com.bloxbean.cardano.client.api.model.Result; +import com.bloxbean.cardano.client.api.model.Utxo; +import com.bloxbean.cardano.yano.wallet.core.config.WalletNetwork; +import com.bloxbean.cardano.yano.wallet.core.tx.FilePendingTransactionStore; +import com.bloxbean.cardano.yano.wallet.core.tx.PendingTransactionStatus; +import com.bloxbean.cardano.yano.wallet.core.tx.QuickAdaTxDraft; +import com.bloxbean.cardano.yano.wallet.core.wallet.FileStoredWalletRepository; +import com.bloxbean.cardano.yano.wallet.core.wallet.StoredWalletCreation; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.math.BigInteger; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Which submit answers are a failed payment. Getting this wrong in the + * "failed" direction is the expensive one: the draft is discarded, and a + * rebuild selects other funds while the first payment may still land. + */ +class SubmitOutcomeTest { + private static final String HASH = "ab".repeat(32); + + @TempDir + Path tempDir; + + private FilePendingTransactionStore store; + + private WalletService.Session sessionAnswering(Result answer) { + PendingNodeAccess noNode = new PendingNodeAccess("no node in this test"); + store = new FilePendingTransactionStore(tempDir.resolve("pending-transactions.json")); + TransactionProcessor processor = new TransactionProcessor() { + @Override + public Result submitTransaction(byte[] cborData) { + return answer; + } + + @Override + public Result> evaluateTx(byte[] cbor, Set inputUtxos) { + throw new UnsupportedOperationException(); + } + }; + WalletService service = new WalletService(new FileStoredWalletRepository(tempDir, WalletNetwork.PREPROD), + noNode, noNode, processor, store, noNode); + StoredWalletCreation created = service.createWallet("submit", "passphrase".toCharArray()); + return service.unlock(created.wallet().id(), "passphrase".toCharArray()); + } + + private static QuickAdaTxDraft draft() { + return new QuickAdaTxDraft(HASH, "84a0a0f5f6", "addr_from", "addr_to", + BigInteger.valueOf(2_000_000), BigInteger.valueOf(170_000), 1000L, null, null, 1, 1); + } + + @SuppressWarnings("unchecked") + private static Result answer(int code, String body) { + return Result.error(body).code(code); + } + + @Test + void aServerErrorIsAnUnknownOutcomeNotAFailedPayment() { + // A 5xx says nothing about whether the node took the transaction. + WalletService.Session session = sessionAnswering(answer(503, "unavailable")); + + assertThatThrownBy(() -> session.submit(draft())) + .isInstanceOf(WalletService.RetryableSubmitException.class) + .hasMessageContaining("503"); + assertThat(store.find(HASH)).isEmpty(); + } + + @Test + void aRefusalIsAFailedPayment() { + WalletService.Session session = sessionAnswering(answer(400, "ValueNotConserved")); + + assertThatThrownBy(() -> session.submit(draft())) + .isInstanceOf(WalletService.WalletServiceException.class) + .isNotInstanceOf(WalletService.RetryableSubmitException.class) + .hasMessageContaining("rejected"); + assertThat(store.find(HASH)).get() + .extracting(tx -> tx.status()).isEqualTo(PendingTransactionStatus.FAILED); + } +} diff --git a/wallet-core/src/test/java/com/bloxbean/cardano/yano/wallet/core/tx/DappSignerSearchTest.java b/wallet-core/src/test/java/com/bloxbean/cardano/yano/wallet/core/tx/DappSignerSearchTest.java new file mode 100644 index 0000000..585bd56 --- /dev/null +++ b/wallet-core/src/test/java/com/bloxbean/cardano/yano/wallet/core/tx/DappSignerSearchTest.java @@ -0,0 +1,177 @@ +package com.bloxbean.cardano.yano.wallet.core.tx; + +import com.bloxbean.cardano.client.account.Account; +import com.bloxbean.cardano.client.address.Address; +import com.bloxbean.cardano.client.api.UtxoSupplier; +import com.bloxbean.cardano.client.api.common.OrderEnum; +import com.bloxbean.cardano.client.api.model.Utxo; +import com.bloxbean.cardano.client.common.cbor.CborSerializationUtil; +import com.bloxbean.cardano.client.common.model.Networks; +import com.bloxbean.cardano.client.crypto.config.CryptoConfiguration; +import com.bloxbean.cardano.client.transaction.TransactionSigner; +import com.bloxbean.cardano.client.transaction.spec.*; +import com.bloxbean.cardano.client.transaction.util.TransactionUtil; +import com.bloxbean.cardano.client.util.HexUtil; +import com.bloxbean.cardano.hdwallet.Wallet; +import com.bloxbean.cardano.yano.wallet.core.wallet.WalletAddresses; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.util.*; + +import static org.assertj.core.api.Assertions.*; + +/** Cryptographic/discovery tests, not claims of ledger acceptance for fabricated transactions. */ +class DappSignerSearchTest { + private static final String MNEMONIC = "test walk nut penalty hip pave soap entry language right filter choice"; + private final Wallet wallet = Wallet.createFromMnemonic(Networks.testnet(), MNEMONIC, 1); + private final Inputs inputs = new Inputs(); + + static class Inputs implements UtxoSupplier { + final Map outputs = new HashMap<>(); + final Set resolved = new HashSet<>(); + public List getPage(String address, Integer count, Integer page, OrderEnum order) { + throw new AssertionError("Signing must not scan address history or balances"); + } + public boolean isUsedAddress(Address address) { throw new AssertionError("No gap scan for signing"); } + public Optional getTxOutput(String hash, int index) { + resolved.add(hash + "#" + index); + return Optional.ofNullable(outputs.get(hash + "#" + index)); + } + void add(TransactionInput input, String address) { + outputs.put(input.getTransactionId() + "#" + input.getIndex(), Utxo.builder() + .txHash(input.getTransactionId()).outputIndex(input.getIndex()).address(address).build()); + } + } + + private Account key(int role, int index) { return WalletAddresses.account(wallet, role, index); } + private byte[] hash(int role, int index) { return key(role, index).hdKeyPair().getPublicKey().getKeyHash(); } + private Transaction tx(byte[]... signers) { + return Transaction.builder().body(TransactionBody.builder().inputs(List.of()) + .outputs(List.of(new TransactionOutput(wallet.getBaseAddressString(0), + Value.builder().coin(BigInteger.valueOf(2_000_000)).build()))) + .fee(BigInteger.valueOf(200_000)).requiredSigners(Arrays.asList(signers)).build()) + .witnessSet(TransactionWitnessSet.builder().build()).build(); + } + private DappSignerSearch.Plan find(Transaction tx, int limit) throws Exception { + return DappSignerSearch.transaction(wallet, tx.serializeToHex(), inputs, List.of(), limit); + } + private List verify(String original, String witnesses) throws Exception { + var result = TransactionWitnessSet.deserialize((co.nstant.in.cbor.model.Map) + CborSerializationUtil.deserialize(HexUtil.decodeHexString(witnesses))).getVkeyWitnesses(); + byte[] digest = HexUtil.decodeHexString(TransactionUtil.getTxHash(HexUtil.decodeHexString(original))); + for (var w : result) assertThat(CryptoConfiguration.INSTANCE.getSigningProvider() + .verify(w.getSignature(), digest, w.getVkey())).isTrue(); + return result; + } + + @Test void dataSearchChecksKnownPathsAndRejectsWrongAccountNetworkAndMalformedAddress() { + byte[] addr = new Address(key(1, 123).enterpriseAddress()).getBytes(); + assertThat(DappSignerSearch.data(wallet, addr, new byte[32], List.of(new DappSignerSearch.KeyPath(1, 123)), 30).paymentPath()) + .isEqualTo(new DappSignerSearch.KeyPath(1, 123)); + assertThat(DappSignerSearch.data(wallet, addr, new byte[32], List.of(), 50).hasSigner()).isFalse(); + var another = Wallet.createFromMnemonic(Networks.testnet(), MNEMONIC, 2); + assertThat(DappSignerSearch.data(another, new Address(key(0, 1).enterpriseAddress()).getBytes(), new byte[32], List.of(), 50).hasSigner()).isFalse(); + addr[0] = 0x61; + assertThatThrownBy(() -> DappSignerSearch.data(wallet, addr, new byte[32], List.of(), 30)).hasMessageContaining("another network"); + addr[0] = 0x70; + assertThatThrownBy(() -> DappSignerSearch.data(wallet, addr, new byte[32], List.of(), 30)).hasMessageContaining("key payment"); + assertThatThrownBy(() -> DappSignerSearch.data(wallet, new byte[1], new byte[32], List.of(), 30)).isInstanceOf(IllegalArgumentException.class); + } + + @Test void findsUnusedReceive012AndReturnsAllThreeVerifiableSignatures() throws Exception { + var tx = tx(hash(0, 0), hash(0, 1), hash(0, 2)); + var plan = find(tx, 30); + assertThat(plan.paymentPaths()).containsExactly(new DappSignerSearch.KeyPath(0, 0), + new DappSignerSearch.KeyPath(0, 1), new DappSignerSearch.KeyPath(0, 2)); + assertThat(plan.unmatched()).isEmpty(); + assertThat(plan.stake()).isFalse(); + var keys = plan.paymentPaths().stream().map(p -> key(p.role(), p.index()).hdKeyPair()).toList(); + assertThat(verify(tx.serializeToHex(), DappSigner.witnessSetHex(keys, tx.serializeToHex()))).hasSize(3); + } + + @Test void bothChainsRespect30And50ExclusiveBounds() throws Exception { + var tx = tx(hash(0, 29), hash(1, 29), hash(0, 30), hash(1, 30), hash(0, 49), hash(1, 49), hash(0, 50), hash(1, 50)); + var initial = find(tx, 30); + assertThat(initial.paymentPaths()).hasSize(2); + assertThat(initial.unmatched()).hasSize(6); + var extended = find(tx, 50); + assertThat(extended.paymentPaths()).hasSize(6); + assertThat(extended.unmatched()).containsExactly(HexUtil.encodeHexString(hash(0, 50)), HexUtil.encodeHexString(hash(1, 50))); + } + + @Test void knownPathsBeyondWindowAreCheckedFirstWithoutScanningInterveningIndexes() throws Exception { + var known = new DappSignerSearch.KeyPath(1, 123); + var plan = DappSignerSearch.transaction(wallet, tx(hash(1, 123), hash(0, 2)).serializeToHex(), + inputs, List.of(known, known), 30); + assertThat(plan.paymentPaths()).containsExactly(known, new DappSignerSearch.KeyPath(0, 2)); + } + + @Test void neverSearchesAnotherHdAccountAndNeverAddsUnrelatedIndexZero() throws Exception { + var other = Wallet.createFromMnemonic(Networks.testnet(), MNEMONIC, 2); + byte[] foreign = other.getAccountAtIndex(0).hdKeyPair().getPublicKey().getKeyHash(); + var plan = find(tx(hash(1, 2), foreign), 50); + assertThat(plan.paymentPaths()).containsExactly(new DappSignerSearch.KeyPath(1, 2)); + assertThat(plan.unmatched()).containsExactly(HexUtil.encodeHexString(foreign)); + } + + @Test void matchesSpendingAndCollateralButNotReferenceInputsOrOutputs() throws Exception { + var spend = new TransactionInput("aa".repeat(32), 0); + var collateral = new TransactionInput("bb".repeat(32), 1); + var reference = new TransactionInput("cc".repeat(32), 2); + inputs.add(spend, key(0, 2).baseAddress()); + inputs.add(collateral, key(1, 3).baseAddress()); + var tx = tx(); + tx.getBody().setInputs(List.of(spend)); + tx.getBody().setCollateral(List.of(collateral)); + tx.getBody().setReferenceInputs(List.of(reference)); + var plan = find(tx, 30); + assertThat(plan.paymentPaths()).containsExactly(new DappSignerSearch.KeyPath(0, 2), new DappSignerSearch.KeyPath(1, 3)); + assertThat(inputs.resolved).containsExactlyInAnyOrder("aa".repeat(32) + "#0", "bb".repeat(32) + "#1"); + } + + @Test void failsClosedOnMissingOrMismatchedInputResolution() throws Exception { + var input = new TransactionInput("aa".repeat(32), 0); + var tx = tx(hash(0, 0)); + tx.getBody().setInputs(List.of(input)); + assertThatThrownBy(() -> find(tx, 30)).hasMessageContaining("Cannot resolve"); + inputs.outputs.put("aa".repeat(32) + "#0", Utxo.builder().txHash("bb".repeat(32)) + .outputIndex(0).address(key(0, 0).baseAddress()).build()); + assertThatThrownBy(() -> find(tx, 30)).hasMessageContaining("identity mismatch"); + } + + @Test void preservesExistingValidWitnessesAndDoesNotReturnThemAgain() throws Exception { + var tx = tx(hash(0, 0), hash(0, 1)); + String signed = HexUtil.encodeHexString(TransactionSigner.INSTANCE.sign(tx.serialize(), key(0, 0).hdKeyPair())); + var plan = DappSignerSearch.transaction(wallet, signed, inputs, List.of(), 30); + assertThat(plan.paymentPaths()).containsExactly(new DappSignerSearch.KeyPath(0, 1)); + String witnesses = DappSigner.witnessSetHex(List.of(key(0, 0).hdKeyPair(), key(0, 1).hdKeyPair(), key(0, 1).hdKeyPair()), signed); + assertThat(verify(signed, witnesses)).hasSize(1); + } + + @Test void invalidExistingWitnessCannotSuppressTheRealSigner() throws Exception { + var tx = tx(hash(0, 0)); + tx.getWitnessSet().setVkeyWitnesses(List.of(new VkeyWitness(key(0, 0).publicKeyBytes(), new byte[64]))); + assertThatThrownBy(() -> find(tx, 30)).hasMessageContaining("Invalid pre-existing"); + } + + @Test void stakeOnlyRequestDoesNotAddPaymentWitnesses() throws Exception { + var stake = key(0, 0).stakeHdKeyPair(); + var plan = find(tx(stake.getPublicKey().getKeyHash()), 30); + assertThat(plan.stake()).isTrue(); + assertThat(plan.paymentPaths()).isEmpty(); + assertThat(plan.unmatched()).isEmpty(); + } + + @Test void multiKeySignaturesPreserveIndefiniteCborBody() throws Exception { + String original = Cip113Fixtures.REG_TX; + String witnesses = DappSigner.witnessSetHex(List.of(key(0, 0).hdKeyPair(), key(0, 1).hdKeyPair(), key(1, 2).hdKeyPair()), original); + assertThat(verify(original, witnesses)).hasSize(3); + } + + @Test void rejectsUnboundedSearchAndMalformedTransactions() throws Exception { + assertThatThrownBy(() -> find(tx(hash(0, 1)), 51)).hasMessageContaining("30 or 50"); + assertThatThrownBy(() -> DappSignerSearch.transaction(wallet, "00", inputs, List.of(), 30)).hasMessageContaining("Invalid transaction"); + assertThatThrownBy(() -> DappSignerSearch.transaction(wallet, "00".repeat(65537), inputs, List.of(), 30)).hasMessageContaining("size limit"); + } +} diff --git a/wallet-core/src/test/java/com/bloxbean/cardano/yano/wallet/core/tx/QuickAdaTxServiceTest.java b/wallet-core/src/test/java/com/bloxbean/cardano/yano/wallet/core/tx/QuickAdaTxServiceTest.java index 43bda83..30723a8 100644 --- a/wallet-core/src/test/java/com/bloxbean/cardano/yano/wallet/core/tx/QuickAdaTxServiceTest.java +++ b/wallet-core/src/test/java/com/bloxbean/cardano/yano/wallet/core/tx/QuickAdaTxServiceTest.java @@ -50,7 +50,86 @@ void sendsChangeBackToSenderBaseAddressByDefault() throws Exception { .anySatisfy(output -> assertThat(output.getAddress()).isEqualTo(senderAddress)); } + @Test + void spendsInternalUtxoOfNonzeroAccountWithTheCorrectWitness() throws Exception { + Wallet sender = Wallet.createFromMnemonic(Networks.mainnet(), MNEMONIC, 2); + var internal = com.bloxbean.cardano.yano.wallet.core.wallet.WalletAddresses.account(sender, 1, 7); + String receiver = Wallet.create(Networks.mainnet()).getBaseAddressString(0); + QuickAdaTxDraft draft = new QuickAdaTxService().buildSignedDraft(sender, + singleUtxoSupplier(internal.baseAddress()), this::protocolParams, + new NoopTransactionProcessor(), receiver, BigInteger.valueOf(1_000_000)); + Transaction tx = Transaction.deserialize(HexUtil.decodeHexString(draft.cborHex())); + assertThat(tx.getBody().getInputs()).hasSize(1); + assertThat(tx.getBody().getInputs().getFirst().getTransactionId()) + .isEqualTo("7e1eecf7439fb5119a6762985a61c9fb3ca8158d9fc38361f0c4746430d5e0c7"); + assertThat(tx.getWitnessSet().getVkeyWitnesses()).hasSize(1); + assertThat(tx.getWitnessSet().getVkeyWitnesses().getFirst().getVkey()) + .containsExactly(internal.publicKeyBytes()); + var witness = tx.getWitnessSet().getVkeyWitnesses().getFirst(); + assertThat(com.bloxbean.cardano.client.crypto.config.CryptoConfiguration.INSTANCE.getSigningProvider() + .verify(witness.getSignature(), HexUtil.decodeHexString(draft.txHash()), witness.getVkey())).isTrue(); + assertThat(tx.getBody().getOutputs()).anySatisfy(output -> + assertThat(output.getAddress()).isEqualTo(sender.getBaseAddressString(0))); + } + + @Test + void signsBothRolesWhenInputsShareTheSameDerivationIndex() throws Exception { + Wallet sender = Wallet.createFromMnemonic(Networks.mainnet(), MNEMONIC, 1); + var external = sender.getAccountAtIndex(7); + var internal = com.bloxbean.cardano.yano.wallet.core.wallet.WalletAddresses.account(sender, 1, 7); + Set funded = Set.of(external.baseAddress(), internal.baseAddress()); + UtxoSupplier supplier = new UtxoSupplier() { + @Override + public List getPage(String address, Integer count, Integer page, OrderEnum order) { + if (page != 0 || !funded.contains(address)) return List.of(); + return List.of(Utxo.builder().address(address) + .txHash((address.equals(external.baseAddress()) ? "1" : "2").repeat(64)) + .outputIndex(0).amount(List.of(Amount.lovelace(BigInteger.valueOf(5_000_000)))) + .build()); + } + + @Override + public Optional getTxOutput(String hash, int index) { return Optional.empty(); } + + @Override + public boolean isUsedAddress(Address address) { return funded.contains(address.toBech32()); } + }; + QuickAdaTxDraft draft = new QuickAdaTxService().buildSignedDraft(sender, supplier, + this::protocolParams, new NoopTransactionProcessor(), + Wallet.create(Networks.mainnet()).getBaseAddressString(0), BigInteger.valueOf(7_000_000)); + Transaction tx = Transaction.deserialize(HexUtil.decodeHexString(draft.cborHex())); + assertThat(tx.getBody().getInputs()).hasSize(2); + assertThat(tx.getBody().getFee()).isGreaterThanOrEqualTo( + BigInteger.valueOf(155381L + 44L * HexUtil.decodeHexString(draft.cborHex()).length)); + assertThat(tx.getWitnessSet().getVkeyWitnesses()) + .extracting(w -> HexUtil.encodeHexString(w.getVkey())) + .containsExactlyInAnyOrder(HexUtil.encodeHexString(external.publicKeyBytes()), + HexUtil.encodeHexString(internal.publicKeyBytes())); + for (var witness : tx.getWitnessSet().getVkeyWitnesses()) { + assertThat(com.bloxbean.cardano.client.crypto.config.CryptoConfiguration.INSTANCE.getSigningProvider() + .verify(witness.getSignature(), HexUtil.decodeHexString(draft.txHash()), witness.getVkey())).isTrue(); + } + } + + @Test + void canDraftPaymentFromKnownChangeFundsWithoutHistory() throws Exception { + Wallet sender = Wallet.createFromMnemonic(Networks.mainnet(), MNEMONIC); + var internal = com.bloxbean.cardano.yano.wallet.core.wallet.WalletAddresses.account(sender, 1, 24); + var draft = new QuickAdaTxService().buildSignedDraft(sender, + singleUtxoSupplier(internal.baseAddress(), true), this::protocolParams, + new NoopTransactionProcessor(), Wallet.create(Networks.mainnet()).getBaseAddressString(0), + BigInteger.valueOf(1_000_000)); + var tx = Transaction.deserialize(HexUtil.decodeHexString(draft.cborHex())); + assertThat(tx.getWitnessSet().getVkeyWitnesses()).hasSize(1); + assertThat(tx.getWitnessSet().getVkeyWitnesses().getFirst().getVkey()) + .containsExactly(internal.publicKeyBytes()); + } + private UtxoSupplier singleUtxoSupplier(String address) { + return singleUtxoSupplier(address, false); + } + + private UtxoSupplier singleUtxoSupplier(String address, boolean historyUnsupported) { return new UtxoSupplier() { @Override public List getPage(String queryAddress, Integer nrOfItems, Integer page, OrderEnum order) { @@ -72,6 +151,8 @@ public Optional getTxOutput(String txHash, int outputIndex) { @Override public boolean isUsedAddress(Address candidate) { + if (historyUnsupported) throw new com.bloxbean.cardano.yano.wallet.core.service.HistoryPort + .HistoryNotSupportedException("No history endpoint"); return address.equals(candidate.toBech32()); } }; diff --git a/wallet-core/src/test/java/com/bloxbean/cardano/yano/wallet/core/wallet/FileStoredWalletRepositoryTest.java b/wallet-core/src/test/java/com/bloxbean/cardano/yano/wallet/core/wallet/FileStoredWalletRepositoryTest.java index 2dd6c69..7712c45 100644 --- a/wallet-core/src/test/java/com/bloxbean/cardano/yano/wallet/core/wallet/FileStoredWalletRepositoryTest.java +++ b/wallet-core/src/test/java/com/bloxbean/cardano/yano/wallet/core/wallet/FileStoredWalletRepositoryTest.java @@ -166,6 +166,19 @@ void discoversAnAccountWhoseHistoryStartsBeyondItsFirstAddress() { .containsExactly(1); } + @Test + void discoversAccountWithOnlyInternalAddressHistory() { + FileStoredWalletRepository repository = repository(WalletNetwork.PREPROD); + StoredWallet account0 = repository.importMnemonic("Wallet", MNEMONIC, "passphrase".toCharArray()); + var wallet = com.bloxbean.cardano.hdwallet.Wallet.createFromMnemonic( + WalletNetwork.PREPROD.toCclNetwork(), MNEMONIC, 1); + String internal = WalletAddresses.baseAddress(wallet, 1, 3).toBech32(); + var found = repository.discoverAccounts(account0.seedId(), "passphrase".toCharArray(), + internal::equals, 4, 5); + assertThat(found).extracting(StoredWalletRepository.DiscoveredAccount::accountIndex) + .containsExactly(1); + } + @Test void discoveryFindsNothingForAnUnusedSeedAndRejectsWrongPassphrase() { FileStoredWalletRepository repository = repository(WalletNetwork.PREPROD); diff --git a/wallet-core/src/test/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletAddressDetailsTest.java b/wallet-core/src/test/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletAddressDetailsTest.java new file mode 100644 index 0000000..5ef9bb9 --- /dev/null +++ b/wallet-core/src/test/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletAddressDetailsTest.java @@ -0,0 +1,48 @@ +package com.bloxbean.cardano.yano.wallet.core.wallet; + +import com.bloxbean.cardano.client.address.Address; +import com.bloxbean.cardano.client.common.model.Networks; +import com.bloxbean.cardano.client.crypto.bip32.HdKeyGenerator; +import com.bloxbean.cardano.client.crypto.Blake2bUtil; +import com.bloxbean.cardano.client.util.HexUtil; +import com.bloxbean.cardano.hdwallet.Wallet; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; + +class WalletAddressDetailsTest { + private static final String MNEMONIC = "drive useless envelope shine range ability time copper alarm museum near flee wrist " + + "live type device meadow allow churn purity wisdom praise drop code"; + + @Test void softwareAndHardwareDetailsMatchTheirAddressWithoutExposingExtendedKeys() { + Wallet wallet = Wallet.createFromMnemonic(Networks.preprod(), MNEMONIC, 2); + var generator = new HdKeyGenerator(); + var purpose = generator.getChildKeyPair(wallet.getRootKeyPair().orElseThrow(), 1852, true); + var coin = generator.getChildKeyPair(purpose, 1815, true); + var account = generator.getChildKeyPair(coin, 2, true); + var watch = new WatchOnlyWallet(Networks.preprod(), account.getPublicKey().getBytes(), 2); + var service = new WalletAddressService(); + for (int index : new int[]{0, 4, 21}) { + var details = service.addressDetails(wallet, index); + assertThat(service.addressDetails(watch, index)).isEqualTo(details); + assertThat(details.paymentPublicKey()).hasSize(64); + assertThat(details.stakePublicKey()).hasSize(64); + assertThat(details.paymentPath()).isEqualTo("m/1852'/1815'/2'/0/" + index); + assertThat(details.stakePath()).isEqualTo("m/1852'/1815'/2'/2/0"); + var address = new Address(details.address()); + assertThat(HexUtil.encodeHexString(address.getPaymentCredentialHash().orElseThrow())) + .isEqualTo(details.paymentKeyHash()); + assertThat(HexUtil.encodeHexString(address.getDelegationCredentialHash().orElseThrow())) + .isEqualTo(details.stakeKeyHash()); + assertThat(HexUtil.encodeHexString(Blake2bUtil.blake2bHash224(HexUtil.decodeHexString(details.paymentPublicKey())))) + .isEqualTo(details.paymentKeyHash()); + assertThat(HexUtil.encodeHexString(Blake2bUtil.blake2bHash224(HexUtil.decodeHexString(details.stakePublicKey())))) + .isEqualTo(details.stakeKeyHash()); + } + assertThat(service.addressDetails(wallet, 0).paymentPublicKey()) + .isNotEqualTo(service.addressDetails(wallet, 4).paymentPublicKey()); + assertThat(service.addressDetails(wallet, 0).stakePublicKey()) + .isEqualTo(service.addressDetails(wallet, 4).stakePublicKey()); + assertThatThrownBy(() -> service.addressDetails(wallet, -1)).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/wallet-core/src/test/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletAddressScannerTest.java b/wallet-core/src/test/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletAddressScannerTest.java new file mode 100644 index 0000000..ab1bd13 --- /dev/null +++ b/wallet-core/src/test/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletAddressScannerTest.java @@ -0,0 +1,180 @@ +package com.bloxbean.cardano.yano.wallet.core.wallet; + +import com.bloxbean.cardano.client.address.Address; +import com.bloxbean.cardano.client.api.UtxoSupplier; +import com.bloxbean.cardano.client.api.common.OrderEnum; +import com.bloxbean.cardano.client.api.model.Amount; +import com.bloxbean.cardano.client.api.model.Utxo; +import com.bloxbean.cardano.client.common.model.Networks; +import com.bloxbean.cardano.hdwallet.Wallet; +import org.junit.jupiter.api.Test; +import java.math.BigInteger; +import java.util.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class WalletAddressScannerTest { + Wallet wallet = Wallet.createFromMnemonic(Networks.mainnet(), + "drive useless envelope shine range ability time copper alarm museum near flee wrist live type device meadow allow churn purity wisdom praise drop code"); + static final BigInteger ADA = BigInteger.valueOf(1_000_000); + + static class Supplier implements UtxoSupplier { + Map> funds = new HashMap<>(); + Set history = new HashSet<>(); + Set queried = new HashSet<>(); + boolean historyFails; + boolean historyUnsupported; + int historyCalls; + void fund(String address) { + funds.put(address, List.of(Utxo.builder().txHash(String.format("%064x", funds.size() + 1)).outputIndex(0) + .amount(List.of(Amount.lovelace(ADA))).build())); + history.add(address); + } + public List getPage(String address, Integer count, Integer page, OrderEnum order) { + queried.add(address); + return page == 0 ? funds.getOrDefault(address, List.of()) : List.of(); + } + public Optional getTxOutput(String hash, int index) { return Optional.empty(); } + public boolean isUsedAddress(Address address) { + historyCalls++; + if (historyUnsupported) throw new com.bloxbean.cardano.yano.wallet.core.service.HistoryPort + .HistoryNotSupportedException("No history endpoint"); + if (historyFails) throw new IllegalStateException("history unavailable"); + return history.contains(address.toBech32()); + } + } + + @Test + void findsInternalChangeUtxoEvenWhenReceiveChainIsEmpty() { + Supplier s = new Supplier(); + String change = wallet.getAccountAtIndex(0).changeAddress(); + assertThat(change).isNotEqualTo(wallet.getBaseAddressString(0)); + s.fund(change); + WalletBalance result = new WalletBalanceService().scan(wallet, s); + assertThat(result.lovelace()).isEqualTo(ADA); + assertThat(s.queried).contains(change); + } + + @Test + void continuesBeyondOneHundredAddressesWithHistory() { + Supplier s = new Supplier(); + for (int i = 0; i < 100; i++) s.history.add(wallet.getBaseAddressString(i)); + s.fund(wallet.getBaseAddressString(100)); + WalletBalance result = new WalletBalanceService().scan(wallet, s); + assertThat(result.addressCount()).isEqualTo(141); + assertThat(result.lovelace()).isEqualTo(ADA); + assertThat(new WalletBalanceService().scan(wallet, s, 20, 121).lovelace()).isEqualTo(ADA); + } + + @Test + void refusesPartialBalanceWhenHistoryFails() { + Supplier s = new Supplier(); + for (int i = 0; i < 20; i++) s.history.add(wallet.getBaseAddressString(i)); + s.fund(wallet.getBaseAddressString(20)); + s.historyFails = true; + assertThatThrownBy(() -> new WalletBalanceService().scan(wallet, s)) + .isInstanceOf(WalletAddressScanner.IncompleteScanException.class) + .hasMessageContaining("history is unavailable"); + s.historyFails = false; + assertThat(new WalletBalanceService().scan(wallet, s).lovelace()).isEqualTo(ADA); + } + @Test + void refusesBalanceWhenSafetyBoundIsReachedBeforeGap() { + Supplier s = new Supplier(); + s.fund(wallet.getBaseAddressString(0)); + assertThatThrownBy(() -> new WalletBalanceService().scan(wallet, s, 3, 3)) + .isInstanceOf(WalletAddressScanner.IncompleteScanException.class) + .hasMessageContaining("receive"); + } + + @Test + void scansChangeChainPastSpentAddressesWithItsOwnGap() { + Supplier s = new Supplier(); + for (int i = 0; i < 24; i++) s.history.add(WalletAddresses.baseAddress(wallet, 1, i).toBech32()); + String change = WalletAddresses.baseAddress(wallet, 1, 24).toBech32(); + s.fund(change); + WalletBalance result = new WalletBalanceService().scan(wallet, s); + assertThat(result.lovelace()).isEqualTo(ADA); + assertThat(result.addressCount()).isEqualTo(65); + assertThat(result.utxos()).extracting(WalletUtxoView::address).containsExactly(change); + } + + @Test + void scansHardwareChangeAddressesUsingOnlyAccountPublicKey() { + // Account xpub derived from the public test mnemonic, using the same export as hardware enrollment. + var root = wallet.getRootKeyPair().orElseThrow(); + var generator = new com.bloxbean.cardano.client.crypto.bip32.HdKeyGenerator(); + var purposeKey = generator.getChildKeyPair(root, 1852, true); + var coinKey = generator.getChildKeyPair(purposeKey, 1815, true); + var accountKey = generator.getChildKeyPair(coinKey, 0, true); + WatchOnlyWallet watch = new WatchOnlyWallet(Networks.mainnet(), + accountKey.getPublicKey().getBytes(), 0); + String change = WalletAddresses.baseAddress(wallet, 1, 7).toBech32(); + assertThat(watch.getChangeAddress(7).toBech32()).isEqualTo(change); + Supplier s = new Supplier(); + s.fund(change); + assertThat(new WalletBalanceService().scan(watch, s).lovelace()).isEqualTo(ADA); + } + + @Test + void aggregatesFundsAndAssetsAcrossBothChains() { + Supplier supplier = new Supplier(); + String receive = wallet.getBaseAddressString(0); + String change = WalletAddresses.baseAddress(wallet, 1, 0).toBech32(); + supplier.fund(receive); + supplier.fund(change); + String unit = "a".repeat(56) + "01"; + for (var list : supplier.funds.values()) { + list.getFirst().setAmount(List.of(Amount.lovelace(ADA), Amount.asset(unit, BigInteger.TEN))); + } + WalletBalance balance = new WalletBalanceService().scan(wallet, supplier); + assertThat(balance.lovelace()).isEqualTo(ADA.multiply(BigInteger.TWO)); + assertThat(balance.utxoCount()).isEqualTo(2); + assertThat(balance.assets()).containsExactly(new WalletAssetBalance(unit, BigInteger.valueOf(20))); + } + + @Test + void refusesPartialBalanceWhenChangeChainReachesItsBound() { + Supplier supplier = new Supplier(); + supplier.fund(wallet.getBaseAddressString(0)); + supplier.fund(WalletAddresses.baseAddress(wallet, 1, 1).toBech32()); + assertThatThrownBy(() -> new WalletBalanceService().scan(wallet, supplier, 2, 3)) + .isInstanceOf(WalletAddressScanner.IncompleteScanException.class) + .hasMessageContaining("change"); + } + + @Test + void acceptsGapEndingExactlyAtSafetyBound() { + assertThat(new WalletBalanceService().scan(wallet, new Supplier(), 3, 3).addressCount()) + .isEqualTo(6); + } + + @Test + void missingHistoryScansFullBoundOnBothChainsAndMarksBalanceIncomplete() { + Supplier supplier = new Supplier(); + supplier.historyUnsupported = true; + supplier.fund(wallet.getBaseAddressString(3)); + supplier.fund(WalletAddresses.baseAddress(wallet, 1, 3).toBech32()); + // A UTxO-based gap of two would stop before either funded address. + WalletBalance balance = new WalletBalanceService().scan(wallet, supplier, 2, 4); + assertThat(balance.lovelace()).isEqualTo(ADA.multiply(BigInteger.TWO)); + assertThat(balance.utxoCount()).isEqualTo(2); + assertThat(balance.addressCount()).isEqualTo(8); + assertThat(balance.complete()).isFalse(); + assertThat(balance.scanWarning()).contains("receive indices 0–3", "change indices 0–3", "more funds"); + assertThat(supplier.historyCalls).isEqualTo(1); + } + + @Test + void nextScanUsesHistoryAgainWhenEndpointBecomesAvailable() { + Supplier supplier = new Supplier(); + supplier.historyUnsupported = true; + assertThat(new WalletBalanceService().scan(wallet, supplier, 2, 4).complete()).isFalse(); + supplier.historyUnsupported = false; + var balance = new WalletBalanceService().scan(wallet, supplier, 2, 4); + assertThat(balance.complete()).isTrue(); + assertThat(balance.scanWarning()).isNull(); + assertThat(balance.addressCount()).isEqualTo(4); + } + +} diff --git a/wallet-core/src/test/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletBalanceServiceTest.java b/wallet-core/src/test/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletBalanceServiceTest.java index 010fefd..f2619f4 100644 --- a/wallet-core/src/test/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletBalanceServiceTest.java +++ b/wallet-core/src/test/java/com/bloxbean/cardano/yano/wallet/core/wallet/WalletBalanceServiceTest.java @@ -1,6 +1,7 @@ package com.bloxbean.cardano.yano.wallet.core.wallet; import com.bloxbean.cardano.client.api.UtxoSupplier; +import com.bloxbean.cardano.client.address.Address; import com.bloxbean.cardano.client.api.common.OrderEnum; import com.bloxbean.cardano.client.api.model.Amount; import com.bloxbean.cardano.client.api.model.Utxo; @@ -63,6 +64,9 @@ public List getPage(String address, Integer nrOfItems, Integer page, Order .build()); } + @Override + public boolean isUsedAddress(Address address) { return address0.equals(address.toBech32()); } + @Override public Optional getTxOutput(String txHash, int outputIndex) { return Optional.empty(); @@ -98,6 +102,11 @@ public List getPage(String address, Integer nrOfItems, Integer page, Order return List.of(); } + @Override + public boolean isUsedAddress(Address address) { + return address0.equals(address.toBech32()) || address2.equals(address.toBech32()); + } + @Override public Optional getTxOutput(String txHash, int outputIndex) { return Optional.empty(); diff --git a/wallet-node-client/build.gradle b/wallet-node-client/build.gradle index 63df6a4..7eea18d 100644 --- a/wallet-node-client/build.gradle +++ b/wallet-node-client/build.gradle @@ -15,6 +15,7 @@ dependencies { implementation libs.jackson.databind implementation libs.slf4j.api + testImplementation libs.cardano.client.quicktx testImplementation libs.junit.jupiter.api testRuntimeOnly libs.junit.jupiter.engine testRuntimeOnly libs.junit.platform.launcher @@ -23,9 +24,14 @@ dependencies { test { useJUnitPlatform() + // An external node artifact can change at the same path; live recovery must execute. + if (System.getProperty('yano.wallet.live.artifact')) { + outputs.upToDateWhen { false } + outputs.cacheIf { false } + } // Opt-in live-node tests (ADR-042 SIM-M0/SIM-M5) read this; Gradle does not // forward -D to the test JVM on its own. Absent → those tests stay skipped. - ['yano.live.node', 'yano.live.outref'].each { key -> + ['yano.live.node', 'yano.live.outref', 'yano.wallet.live.artifact', 'yano.wallet.live.config'].each { key -> if (System.getProperty(key)) { systemProperty key, System.getProperty(key) } diff --git a/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/PendingInputs.java b/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/PendingInputs.java new file mode 100644 index 0000000..de2819f --- /dev/null +++ b/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/PendingInputs.java @@ -0,0 +1,160 @@ +package com.bloxbean.cardano.yano.wallet.nodeclient; + +import com.bloxbean.cardano.client.transaction.spec.Transaction; +import com.bloxbean.cardano.client.transaction.util.TransactionUtil; +import com.bloxbean.cardano.yano.wallet.core.service.MempoolConflictException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; + +/** Local reservations, independent of history's display timeouts. No pending outputs are invented. */ +final class PendingInputs { + private static final Pattern CLAIMED_INPUT = Pattern.compile( + "regular input is already claimed by ([0-9a-f]{64}): ([0-9a-f]{64}#[0-9]+)", + Pattern.CASE_INSENSITIVE); + record Reservation(long ttl, Set inputs) {} + + private final ObjectMapper mapper = new ObjectMapper(); + private Map reservations = new HashMap<>(); + private Path file; + + synchronized void persistAt(Path path) { + if (file != null) throw new IllegalStateException("Pending input storage is already configured"); + if (!reservations.isEmpty()) throw new IllegalStateException("Configure storage before submitting transactions"); + file = path.toAbsolutePath(); + if (Files.exists(file)) { + try { + reservations = mapper.readValue(file.toFile(), new TypeReference>() {}); + if (reservations == null || reservations.values().stream() + .anyMatch(r -> r == null || r.inputs() == null || r.inputs().contains(null))) { + throw new IOException("Invalid pending input records"); + } + } catch (IOException e) { + throw new UncheckedIOException("Cannot read pending input reservations", e); + } + } + } + + synchronized Set snapshot() { + Set inputs = new HashSet<>(); + reservations.values().forEach(r -> inputs.addAll(r.inputs())); + return Set.copyOf(inputs); + } + + synchronized boolean isEmpty() { + return reservations.isEmpty(); + } + + synchronized void expire(NodeStatus status) { + // A lagging index may still advertise outputs already spent on chain. + if (!status.utxoIndexCaughtUp()) return; + Map next = new HashMap<>(reservations); + if (next.values().removeIf(r -> r.ttl() > 0 && status.slot() > r.ttl())) update(next); + } + + synchronized void refresh(YanoNodeClient client) { + if (reservations.isEmpty()) return; + NodeStatus status = client.getStatus(); + expire(status); + if (!status.utxoIndexCaughtUp()) return; + // Transactions without an upper validity bound cannot be expired by a wall-clock timeout. + // Once indexed in a block, their inputs are also absent from the confirmed UTxO view. + for (String hash : Set.copyOf(reservations.keySet())) { + if ("in_block".equals(client.getTxStatus(hash).status())) release(hash); + } + } + + synchronized String reserve(byte[] cbor) { + try { + Transaction tx = Transaction.deserialize(cbor); + String hash = TransactionUtil.getTxHash(tx); + Set inputs = new HashSet<>(); + tx.getBody().getInputs().forEach(i -> inputs.add(i.getTransactionId() + "#" + i.getIndex())); + // Only a DIFFERENT transaction over these inputs is a conflict. Resending the + // identical signed transaction is how a submit with an unknown outcome is + // settled, and it cannot pay twice: the ledger applies a transaction at most + // once. Refusing it here made that retry fail, and the failure discarded a + // draft whose payment may already have gone. + Set blocked = new HashSet<>(); + reservations.forEach((owner, reservation) -> { + if (!owner.equals(hash)) blocked.addAll(reservation.inputs()); + }); + if (inputs.stream().anyMatch(blocked::contains)) { + throw new MempoolConflictException("This wallet already submitted a transaction using these inputs. " + + "Wait for confirmation if no other confirmed funds are available."); + } + // A resend keeps its reservation as is. One learned from a node conflict + // (TTL 0, only the claimed input) is replaced by the transaction's own. + Reservation own = new Reservation(tx.getBody().getTtl(), Set.copyOf(inputs)); + if (!own.equals(reservations.get(hash))) { + Map next = new HashMap<>(reservations); + next.put(hash, own); + update(next); // Durable before the request can reach the node. + } + return hash; + } catch (MempoolConflictException | UncheckedIOException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException("Cannot track transaction inputs; transaction was not submitted", e); + } + } + + synchronized void release(String hash) { + Map next = new HashMap<>(reservations); + if (next.remove(hash) != null) update(next); + } + + /** The transaction a node conflict names as already holding an input, if it names one. */ + static Optional claimant(String reason) { + var match = CLAIMED_INPUT.matcher(reason); + return match.find() ? Optional.of(match.group(1).toLowerCase(java.util.Locale.ROOT)) : Optional.empty(); + } + + /** Replace the rejected draft's reservation with the actual owner's reported input. */ + synchronized boolean recordConflict(String rejectedHash, String reason) { + var match = CLAIMED_INPUT.matcher(reason); + if (!match.find()) return false; + String owner = match.group(1).toLowerCase(java.util.Locale.ROOT); + String input = match.group(2).toLowerCase(java.util.Locale.ROOT); + Reservation rejected = reservations.get(rejectedHash); + if (rejected == null || !rejected.inputs().contains(input)) return false; + Map next = new HashMap<>(reservations); + next.remove(rejectedHash); + Reservation existing = next.get(owner); + Set inputs = new HashSet<>(existing == null ? Set.of() : existing.inputs()); + inputs.add(input); + // The rejected draft's TTL tells us nothing about the claiming transaction's validity. + next.put(owner, new Reservation(existing == null ? 0 : existing.ttl(), Set.copyOf(inputs))); + update(next); + return true; + } + + private void update(Map next) { + if (file != null) { + try { + Files.createDirectories(file.getParent()); + Path tmp = Files.createTempFile(file.getParent(), "pending-inputs-", ".tmp"); + try { + mapper.writeValue(tmp.toFile(), next); + Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } finally { + Files.deleteIfExists(tmp); + } + } catch (IOException e) { + throw new UncheckedIOException("Cannot persist pending input reservations", e); + } + } + reservations = next; + } +} diff --git a/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/ScanProgressTracker.java b/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/ScanProgressTracker.java new file mode 100644 index 0000000..e827dff --- /dev/null +++ b/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/ScanProgressTracker.java @@ -0,0 +1,53 @@ +package com.bloxbean.cardano.yano.wallet.nodeclient; + +import com.bloxbean.cardano.yano.wallet.core.service.HistoryPort.ScanProgress; +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.Optional; + +/** + * Reads a scan's position out of the records it streams, so the UI can report a + * long first scan instead of showing an empty screen for half a minute. + * + *

Reporting only, and deliberately separate from the scan's own bookkeeping + * in {@link WalletScanHistory}: the validation there decides whether a scan is + * trustworthy, and a display value must never be able to influence it. A record + * this tracker cannot read is skipped rather than rejected, for the same reason. + */ +final class ScanProgressTracker { + private final long fromBlock; + + /** Null until the node reports how far it has indexed, and again once done. */ + private volatile ScanProgress snapshot; + + ScanProgressTracker(long fromBlock) { + this.fromBlock = fromBlock; + } + + /** + * The node states its tip in the {@code ready} record that opens every scan, + * so the interval is known before the first block arrives; each later record + * carries the point reached. {@code done} clears the snapshot — a finished + * scan is not progress, and the rows themselves are about to be shown. + */ + void observe(JsonNode record) { + JsonNode block = record.path("point").path("blockNumber"); + if (!block.isIntegralNumber()) { + return; + } + switch (record.path("type").asText()) { + case "ready" -> snapshot = new ScanProgress(fromBlock, fromBlock, block.longValue()); + case "done" -> snapshot = null; + default -> { + ScanProgress current = snapshot; + if (current != null) { + snapshot = new ScanProgress(fromBlock, block.longValue(), current.tipBlock()); + } + } + } + } + + Optional snapshot() { + return Optional.ofNullable(snapshot); + } +} diff --git a/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/WalletScanHistory.java b/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/WalletScanHistory.java new file mode 100644 index 0000000..328a514 --- /dev/null +++ b/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/WalletScanHistory.java @@ -0,0 +1,279 @@ +package com.bloxbean.cardano.yano.wallet.nodeclient; + +import com.bloxbean.cardano.client.address.Address; +import com.bloxbean.cardano.client.address.util.AddressUtil; +import com.bloxbean.cardano.yano.wallet.core.service.HistoryPort.ScanProgress; +import com.bloxbean.cardano.yano.wallet.core.service.HistoryPort.TxRef; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; + +/** Atomic wallet-local cursor, matching outputs and transaction history. */ +final class WalletScanHistory { + private static final int MAX_TRANSACTIONS = 100_000; + private final YanoNodeClient client; + private final Path directory; + private final ObjectMapper mapper = new ObjectMapper(); + + /** Set while a scan is streaming; read by the UI from another thread. */ + private volatile ScanProgressTracker tracker; + + WalletScanHistory(YanoNodeClient client, Path directory) { + this.client = client; + this.directory = directory; + } + + /** + * Deliberately not synchronized: {@link #transactions} holds this instance's + * lock for the whole scan, so a progress read that waited for it could only + * ever report a scan that had already finished. + */ + Optional progress() { + ScanProgressTracker current = tracker; + return current == null ? Optional.empty() : current.snapshot(); + } + + synchronized List transactions(String stakeAddress, int page, int count, boolean newestFirst) { + if (page < 1 || count < 1 || count > 100) throw new IllegalArgumentException("Invalid history page/count"); + try { + byte[] stake = new Address(stakeAddress).getBytes(); + if (stake.length != 29 || ((stake[0] & 255) >>> 4) < 14) throw new IllegalArgumentException("Stake address required for account scan"); + String hash = HexFormat.of().formatHex(stake, 1, 29); + Path file = directory.resolve(digest(client.baseUrl() + '|' + stakeAddress) + ".json"); + ObjectNode saved = read(file); + List candidates = new ArrayList<>(); + if (saved != null) { + candidates.add((ObjectNode) saved.path("current")); + if (saved.path("previous").isObject()) candidates.add((ObjectNode) saved.path("previous")); + } + candidates.add(empty()); + ObjectNode next = null; + ObjectNode previous = null; + for (ObjectNode candidate : candidates) { + try { + next = scan(candidate, hash, (stake[0] & 0x10) != 0); + previous = candidate; + break; + } catch (WalletScanTransport.Reorg reorg) { + // Retry a persisted earlier boundary with its matching outpoint state. + // If both retained boundaries were orphaned, origin is always the safe fallback. + } + } + if (next == null) throw new NodeClientException("Chain kept changing during wallet history recovery; retry"); + ObjectNode durable = mapper.createObjectNode().put("version", 1); + durable.set("current", next); + durable.set("previous", previous); + if (saved == null || !next.equals(saved.path("current"))) persist(file, durable); + List rows = new ArrayList<>(); + for (JsonNode row : next.path("transactions")) { + rows.add(new TxRef(row.path("txHash").asText(), row.path("blockNumber").longValue(), + row.path("blockTime").longValue(), row.path("slot").longValue())); + } + Comparator order = Comparator.comparingLong(TxRef::blockHeight).thenComparing(TxRef::txHash); + rows.sort(newestFirst ? order.reversed() : order); + return rows.stream().skip(Math.multiplyExact((long) page - 1, count)).limit(count).toList(); + } catch (IOException failure) { + throw new NodeClientException("Wallet scan history could not be read or saved", failure); + } + } + + private ObjectNode scan(ObjectNode previous, String stakeHash, boolean script) { + ObjectNode next = previous.deepCopy(); + ObjectNode outputs = (ObjectNode) next.path("outputs"); + ObjectNode transactions = (ObjectNode) next.path("transactions"); + ObjectNode request = mapper.createObjectNode().put("version", 1); + request.putArray("credentials").addObject().put("role", "stake").put("type", script ? "script" : "key").put("hash", stakeHash); + request.set("after", previous.path("cursor")); + ArrayNode known = request.putArray("knownOutputs"); + outputs.forEach(known::add); + boolean[] ready = {false}; + JsonNode[] end = {null}; + JsonNode[] last = {previous.path("cursor")}; + boolean[] genesis = {false}; + ScanProgressTracker progress = new ScanProgressTracker(previous.path("cursor").path("blockNumber").longValue()); + tracker = progress; + try { + scanRecords(request, progress, previous, next, outputs, transactions, stakeHash, script, + ready, end, last, genesis); + } finally { + // Cleared on every exit, so a failed or abandoned scan never leaves a + // stalled percentage on screen claiming work is still happening. + tracker = null; + } + return next; + } + + private void scanRecords(ObjectNode request, ScanProgressTracker progress, ObjectNode previous, + ObjectNode next, ObjectNode outputs, ObjectNode transactions, + String stakeHash, boolean script, boolean[] ready, JsonNode[] end, + JsonNode[] last, boolean[] genesis) { + client.scanWallet(request, record -> { + progress.observe(record); + String type = record.path("type").asText(); + switch (type) { + case "ready" -> { + if (ready[0]) throw new NodeClientException("Duplicate scan readiness record"); + JsonNode identity = record.path("coverage").path("identity"); + if (!identity.isTextual() || !validPoint(record.path("point"))) throw new NodeClientException("Missing scan identity or point"); + if (previous.hasNonNull("identity") && !previous.path("identity").equals(identity)) { + throw new WalletScanTransport.Reorg("Node chain identity changed"); + } + checkForward(previous.path("cursor"), record.path("point")); + next.set("identity", identity); + end[0] = record.path("point"); + ready[0] = true; + } + case "genesis", "transaction" -> { + if (!ready[0]) throw new NodeClientException("Scan data preceded coverage record"); + JsonNode point = record.path("point"); + if (type.equals("genesis")) { + if (genesis[0] || previous.path("cursor").path("blockNumber").longValue() != -1 + || !point.equals(previous.path("cursor")) || !last[0].equals(previous.path("cursor"))) { + throw new NodeClientException("Unexpected genesis record"); + } + genesis[0] = true; + } else { + if (!validPoint(point) || point.path("blockNumber").longValue() + <= previous.path("cursor").path("blockNumber").longValue()) { + throw new NodeClientException("Transaction outside scan interval"); + } + checkForward(last[0], point); + checkForward(point, end[0]); + last[0] = point; + } + for (JsonNode input : record.path("inputs")) outputs.remove(outpoint(input)); + for (JsonNode output : record.path("outputs")) { + if (matchesStake(output.path("address").asText(), stakeHash, script)) { + outputs.set(outpoint(output.path("outpoint")), output); + } + } + if (outputs.size() > 10_000) throw new NodeClientException("Wallet scan output limit exceeded"); + if (type.equals("transaction")) { + if (!validHash(record.path("txHash")) || !record.path("blockTime").isIntegralNumber()) { + throw new NodeClientException("Malformed scan transaction"); + } + ObjectNode row = mapper.createObjectNode().put("txHash", record.path("txHash").asText()) + .put("blockNumber", point.path("blockNumber").longValue()) + .put("slot", point.path("slot").longValue()).put("blockTime", record.path("blockTime").longValue()); + row.set("blockHash", point.path("blockHash")); + transactions.set(row.path("txHash").asText(), row); + if (transactions.size() > MAX_TRANSACTIONS) throw new NodeClientException("Wallet history limit exceeded"); + } + } + case "progress" -> { + if (!ready[0]) throw new NodeClientException("Scan progress preceded coverage record"); + checkForward(last[0], record.path("point")); + checkForward(record.path("point"), end[0]); + last[0] = record.path("point"); + } + case "done" -> { + if (!ready[0] || !record.path("point").equals(end[0])) throw new NodeClientException("Scan completion point mismatch"); + next.set("cursor", record.path("point")); + } + default -> throw new NodeClientException("Unknown scan record type: " + type); + } + }); + } + + private ObjectNode empty() { + ObjectNode state = mapper.createObjectNode(); + state.putObject("cursor").put("blockNumber", -1).put("slot", 0).put("blockHash", "00".repeat(32)); + state.putObject("outputs"); + state.putObject("transactions"); + return state; + } + + private ObjectNode read(Path file) throws IOException { + if (!Files.exists(file)) return null; + if (Files.size(file) > 64_000_000) throw new IOException("Wallet history file exceeds size limit"); + JsonNode root = mapper.readTree(Files.readAllBytes(file)); + if (!root.isObject() || root.path("version").asInt() != 1 || !validState(root.path("current")) + || root.has("previous") && !validState(root.path("previous"))) { + throw new IOException("Invalid wallet scan history state"); + } + return (ObjectNode) root; + } + + private static boolean validState(JsonNode state) { + return state.isObject() && validPoint(state.path("cursor")) + && state.path("outputs").isObject() && state.path("transactions").isObject(); + } + + private static boolean validHash(JsonNode hash) { + return hash.isTextual() && hash.textValue().matches("[0-9a-f]{64}"); + } + + private static boolean validPoint(JsonNode point) { + JsonNode block = point.path("blockNumber"); + JsonNode slot = point.path("slot"); + if (!block.isIntegralNumber() || !block.canConvertToLong() || block.longValue() < -1 + || !slot.isIntegralNumber() || !slot.canConvertToLong() || slot.longValue() < 0 + || !validHash(point.path("blockHash"))) return false; + return block.longValue() != -1 || slot.longValue() == 0 + && point.path("blockHash").textValue().equals("00".repeat(32)); + } + + private static void checkForward(JsonNode from, JsonNode to) { + if (!validPoint(from) || !validPoint(to) + || to.path("blockNumber").longValue() < from.path("blockNumber").longValue() + || to.path("slot").longValue() < from.path("slot").longValue() + || to.path("blockNumber").longValue() == from.path("blockNumber").longValue() && !to.equals(from)) { + throw new NodeClientException("Invalid or inconsistent scan point"); + } + } + + private void persist(Path file, ObjectNode state) throws IOException { + byte[] bytes = mapper.writeValueAsBytes(state); + if (bytes.length > 64_000_000) throw new IOException("Wallet history exceeds size limit"); + Files.createDirectories(directory); + Path temporary = Files.createTempFile(directory, ".scan-", ".tmp"); + try { + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.wrap(bytes); + while (buffer.hasRemaining()) channel.write(buffer); + channel.force(true); + } + Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } finally { + Files.deleteIfExists(temporary); + } + } + + private static boolean matchesStake(String address, String hash, boolean script) { + byte[] raw; + try { raw = AddressUtil.addressToBytes(address); } + catch (Exception failure) { throw new NodeClientException("Malformed scan output address", failure); } + if (raw == null || raw.length == 0) throw new NodeClientException("Missing scan output address"); + int type = (raw[0] & 255) >>> 4; + return raw.length == 57 && type <= 3 && (type == 2 || type == 3) == script + && HexFormat.of().formatHex(raw, 29, 57).equals(hash); + } + + private static String outpoint(JsonNode point) { + if (!point.path("txHash").isTextual() || !point.path("index").isIntegralNumber()) throw new NodeClientException("Malformed scan outpoint"); + return point.path("txHash").asText() + '#' + point.path("index").intValue(); + } + + private static String digest(String key) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(key.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); } + } +} diff --git a/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/WalletScanTransport.java b/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/WalletScanTransport.java new file mode 100644 index 0000000..fef176e --- /dev/null +++ b/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/WalletScanTransport.java @@ -0,0 +1,158 @@ +package com.bloxbean.cardano.yano.wallet.nodeclient; + +import com.bloxbean.cardano.yano.wallet.core.service.HistoryPort.HistoryNotSupportedException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.MissingNode; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Locale; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +/** Streams bounded NDJSON records; disconnect/EOF is never equivalent to done. */ +final class WalletScanTransport { + private static final ScheduledExecutorService DEADLINES = Executors.newSingleThreadScheduledExecutor( + Thread.ofPlatform().daemon().name("wallet-scan-deadline").factory()); + private static final int MAX_RECORD_CHARS = 2_000_000; + + /** An error body is a short JSON object; read no more than that before deciding. */ + private static final int MAX_ERROR_BYTES = 8192; + + static final class Reorg extends NodeClientException { + Reorg(String message) { super(message); } + } + + private WalletScanTransport() { } + + /** Yano's answer to a scan range its index does not hold. */ + private static final String OUTSIDE_COVERAGE = "outside complete filter coverage"; + + /** The node's error body: its message, and the index coverage when it sent one. */ + private record NodeError(String reason, JsonNode coverage) { + static NodeError read(ObjectMapper mapper, InputStream body) { + try { + JsonNode error = mapper.readTree(body.readNBytes(MAX_ERROR_BYTES)); + return new NodeError(error.path("error").asText(""), error.path("coverage")); + } catch (IOException | RuntimeException unreadable) { + // An unreadable body establishes nothing, least of all a permanent absence. + return new NodeError("", MissingNode.getInstance()); + } + } + + boolean says(String phrase) { + return reason.toLowerCase(Locale.ROOT).contains(phrase); + } + + String suffix() { + return reason.isBlank() ? "" : ": " + reason; + } + } + + /** + * Whether a node that cannot serve a scan never will. Only that may become + * {@link HistoryNotSupportedException}, because the caller answers it by + * showing the wallet's own local list instead of the node's history — right + * for a node that keeps none, wrong for one that is momentarily busy, where + * it hides real transactions. A node too old for the route answers 404, + * handled separately. + * + *

Permanent on pre16: UTxO state switched off ({@code "Wallet scan + * requires UTxO state"}), the index switched off ({@code coverage.enabled} + * false), and an index the node itself says needs a fresh sync ({@code + * "Index missing; enable before a fresh sync"}, {@code "... fresh sync + * required"}). Everything else stays an error, which includes the index + * trailing the applied block ({@code "Index is not at the applied canonical + * point"}), storage being replaced, and the concurrency limit. Coverage + * alone cannot draw that line: "missing" and "being replaced" both arrive + * enabled, with no range, so the node's own remedy wording decides. + */ + private static RuntimeException unavailable(NodeError error) { + JsonNode enabled = error.coverage().path("enabled"); + if (enabled.isBoolean() && !enabled.booleanValue() + || error.says("fresh sync") || error.says("requires utxo state")) { + return new HistoryNotSupportedException(error.reason().isBlank() + ? "This node cannot serve wallet scans" : error.reason()); + } + return new NodeClientException("Wallet scan unavailable (HTTP 503)" + error.suffix()); + } + + /** + * A range outside the index is a 400, and what it means depends on where + * the scan starts. From origin it is permanent: the index was switched on + * after this chainstate synced, so its coverage starts partway through the + * chain and never reaches back — what every chainstate upgraded to a node + * with the index gets, on every refresh. From a saved cursor it is not + * necessarily: an index trailing the cursor gives the same answer and + * catches up, so that stays an error rather than trading the wallet's + * scanned history for its local list. + */ + private static RuntimeException rejected(NodeError error, JsonNode payload) { + boolean fromOrigin = payload.path("after").path("blockNumber").asLong(0) == -1; + if (fromOrigin && error.says(OUTSIDE_COVERAGE)) { + return new HistoryNotSupportedException("This node's wallet index does not reach back to the " + + "start of the chain: it was enabled after the node synced" + error.suffix()); + } + return new NodeClientException("Wallet scan unavailable (HTTP 400)" + error.suffix()); + } + + static void scan(HttpClient client, ObjectMapper mapper, URI uri, JsonNode payload, Consumer sink) { + try { + HttpRequest request = HttpRequest.newBuilder(uri).timeout(Duration.ofMinutes(30)) + .header("Content-Type", "application/json").header("Accept", "application/x-ndjson") + .POST(HttpRequest.BodyPublishers.ofByteArray(mapper.writeValueAsBytes(payload))).build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()); + try (InputStream body = response.body()) { + if (response.statusCode() == 404) throw new HistoryNotSupportedException("This node does not support wallet scans"); + if (response.statusCode() == 409) throw new Reorg("Saved scan cursor is no longer canonical"); + if (response.statusCode() == 400) throw rejected(NodeError.read(mapper, body), payload); + if (response.statusCode() == 503) throw unavailable(NodeError.read(mapper, body)); + if (response.statusCode() != 200) throw new NodeClientException("Wallet scan unavailable (HTTP " + response.statusCode() + ")"); + var deadline = DEADLINES.schedule(() -> { + try { body.close(); } catch (IOException ignored) { } + }, 30, TimeUnit.MINUTES); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(body, StandardCharsets.UTF_8))) { + boolean done = false; + StringBuilder line = new StringBuilder(); + int character; + while ((character = reader.read()) != -1) { + if (Thread.currentThread().isInterrupted()) throw new NodeClientException("Wallet scan cancelled"); + if (character != '\n') { + if (line.length() >= MAX_RECORD_CHARS) throw new NodeClientException("Scan record exceeds size limit"); + line.append((char) character); + continue; + } + if (line.isEmpty()) continue; + if (done) throw new NodeClientException("Scan data received after completion"); + JsonNode record = mapper.readTree(line.toString()); + line.setLength(0); + String type = record.path("type").asText(); + if (type.equals("rollback")) throw new Reorg("Chain rolled back during wallet scan"); + if (type.equals("error")) throw new NodeClientException("Wallet scan failed: " + record.path("error").asText()); + sink.accept(record); + done = type.equals("done"); + } + if (!line.isEmpty() || !done) throw new NodeClientException("Wallet scan ended without a complete done record"); + } finally { + deadline.cancel(false); + } + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new NodeClientException("Wallet scan interrupted", interrupted); + } catch (IOException failure) { + throw new NodeClientException("Wallet scan connection failed", failure); + } + } +} diff --git a/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/YanoNodeBackend.java b/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/YanoNodeBackend.java index 7fcad01..fb6498e 100644 --- a/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/YanoNodeBackend.java +++ b/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/YanoNodeBackend.java @@ -6,11 +6,11 @@ import com.bloxbean.cardano.client.backend.api.BackendService; import com.bloxbean.cardano.client.backend.api.DefaultProtocolParamsSupplier; import com.bloxbean.cardano.client.backend.api.DefaultTransactionProcessor; -import com.bloxbean.cardano.client.backend.api.DefaultUtxoSupplier; import com.bloxbean.cardano.client.backend.blockfrost.service.BFBackendService; import com.bloxbean.cardano.yano.wallet.core.config.WalletNetwork; import java.util.Objects; +import java.nio.file.Path; /** * Wallet-side view of a local Yano node: cardano-client-lib suppliers backed @@ -28,17 +28,24 @@ public class YanoNodeBackend { private final YanoNodeClient nodeClient; private final BackendService backendService; private final UtxoSupplier utxoSupplier; + private final UtxoSupplier selectionUtxoSupplier; private final ProtocolParamsSupplier protocolParamsSupplier; private final TransactionProcessor transactionProcessor; private final YanoNodePorts ports; + private final PendingInputs pendingInputs; private YanoNodeBackend(WalletNetwork network, YanoNodeClient nodeClient, BackendService backendService) { this.network = network; this.nodeClient = nodeClient; this.backendService = backendService; - this.utxoSupplier = new DefaultUtxoSupplier(backendService.getUtxoService()); + this.utxoSupplier = new YanoUtxoSupplier(backendService.getUtxoService(), nodeClient); + this.pendingInputs = new PendingInputs(); + this.selectionUtxoSupplier = nodeClient.isBlockfrostFlavor() ? utxoSupplier + : new YanoUtxoSupplier(backendService.getUtxoService(), nodeClient, pendingInputs); this.protocolParamsSupplier = new DefaultProtocolParamsSupplier(backendService.getEpochService()); - this.transactionProcessor = new DefaultTransactionProcessor(backendService.getTransactionService()); + this.transactionProcessor = nodeClient.isBlockfrostFlavor() + ? new DefaultTransactionProcessor(backendService.getTransactionService()) + : new YanoTransactionProcessor(backendService.getTransactionService(), pendingInputs, nodeClient); this.ports = new YanoNodePorts(nodeClient); } @@ -77,6 +84,16 @@ public UtxoSupplier utxoSupplier() { return utxoSupplier; } + /** Transaction-building view; confirmed balances must use {@link #utxoSupplier()}. */ + public UtxoSupplier selectionUtxoSupplier() { + return selectionUtxoSupplier; + } + + /** Call before exposing the connection to transaction builders/submission. */ + public void persistPendingInputs(Path file) { + if (!nodeClient.isBlockfrostFlavor()) pendingInputs.persistAt(file); + } + public ProtocolParamsSupplier protocolParamsSupplier() { return protocolParamsSupplier; } diff --git a/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/YanoNodeClient.java b/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/YanoNodeClient.java index 3f64c1d..3f092eb 100644 --- a/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/YanoNodeClient.java +++ b/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/YanoNodeClient.java @@ -1,6 +1,11 @@ package com.bloxbean.cardano.yano.wallet.nodeclient; +import com.bloxbean.cardano.client.address.Address; +import com.bloxbean.cardano.client.api.common.OrderEnum; +import com.bloxbean.cardano.client.api.model.Utxo; +import com.bloxbean.cardano.client.api.model.Amount; import com.bloxbean.cardano.yano.wallet.core.config.WalletNetwork; +import com.bloxbean.cardano.yano.wallet.core.service.HistoryPort.HistoryNotSupportedException; import com.bloxbean.cardano.yano.wallet.core.simulate.AssetQuantity; import com.bloxbean.cardano.yano.wallet.core.simulate.ResolvedOutput; import com.bloxbean.cardano.yano.wallet.core.simulate.ScriptEvaluation; @@ -23,6 +28,9 @@ import java.util.HexFormat; import java.util.Locale; import java.util.Objects; +import java.util.Optional; +import java.util.HashSet; +import java.util.function.Consumer; /** * Thin client for Yano-specific (non-Blockfrost) endpoints of a local node: @@ -71,11 +79,66 @@ public String baseUrl() { return baseUri.toString(); } + void scanWallet(JsonNode request, Consumer records) { + WalletScanTransport.scan(httpClient, objectMapper, baseUri.resolve("scan"), request, records); + } + /** True when this backend is yaci-store rather than a Yano node (ADR-038). */ public boolean isBlockfrostFlavor() { return blockfrostFlavor; } + /** Selection only: never used by confirmed balances or history. */ + List getSelectionUtxos(String address, boolean credential, int count, int page, OrderEnum order) { + if (blockfrostFlavor) throw new IllegalStateException("Yano backend required"); + String path = credential + ? "credentials/" + HexFormat.of().formatHex(new Address(address).getPaymentCredentialHash().orElseThrow()) + : "addresses/" + new Address(address).toBech32(); + JsonNode rows = getJson(path + "/utxos?page=" + page + "&count=" + count + + "&order=" + order + "&include_mempool=true"); + if (!rows.isArray()) throw new NodeClientException("Invalid selection UTxO response"); + List result = new ArrayList<>(); + for (JsonNode row : rows) result.add(selectionUtxo(row)); + return result; + } + + Optional getSelectionOutput(String hash, int index) { + if (blockfrostFlavor) throw new IllegalStateException("Yano backend required"); + if (!isTxHash(hash) || index < 0) throw new IllegalArgumentException("Invalid output reference"); + JsonNode row = getJsonOrNull("utxos/" + hash + "/" + index + "?include_mempool=true"); + if (row == null) return Optional.empty(); + Utxo output = selectionUtxo(row); + if (!hash.equals(output.getTxHash()) || index != output.getOutputIndex()) { + throw new NodeClientException("Selection output does not match requested reference"); + } + return Optional.of(output); + } + + private Utxo selectionUtxo(JsonNode row) { + if (!isTxHash(row.path("tx_hash").asText()) || !row.path("output_index").isIntegralNumber() + || !row.path("output_index").canConvertToInt() || row.path("output_index").asInt() < 0 + || !row.path("address").isTextual() || row.path("address").asText().isBlank() + || !row.path("amount").isArray()) { + throw new NodeClientException("Invalid selection UTxO entry"); + } + List amounts = new ArrayList<>(); + var units = new HashSet(); + for (JsonNode amount : row.path("amount")) { + String unit = amount.path("unit").asText(); + String quantity = amount.path("quantity").asText(); + if ((!"lovelace".equals(unit) && !unit.matches("[0-9a-f]{56}([0-9a-f]{2}){0,32}")) + || !quantity.matches("[0-9]+") || !units.add(unit)) { + throw new NodeClientException("Invalid selection UTxO amount"); + } + amounts.add(new Amount(unit, new BigInteger(quantity))); + } + if (!units.contains("lovelace")) throw new NodeClientException("Selection UTxO is missing lovelace"); + return Utxo.builder().txHash(row.path("tx_hash").asText()).outputIndex(row.path("output_index").asInt()) + .address(row.path("address").asText()).amount(amounts) + .dataHash(row.path("data_hash").asText(null)).inlineDatum(row.path("inline_datum").asText(null)) + .referenceScriptHash(row.path("reference_script_hash").asText(null)).build(); + } + public NodeStatus getStatus() { JsonNode root = getJsonOrNull("status"); if (root == null) { @@ -337,6 +400,83 @@ public java.util.List getAddressTransactions(String address, int page return root == null ? null : parseAddressTxs(root); } + /** Distinguishes unused addresses, absent/disabled history, and failed history requests. */ + public boolean isAddressUsed(String address) { + if (!blockfrostFlavor) { + Boolean used = addressFirstSeen(address); + if (used != null) return used; + } + String path = "addresses/" + address + "/transactions?page=1&count=1&order=asc"; + RawResponse response = getRaw(path, requestTimeout); + // Blockfrost-compatible stores return 404 for a never-used address. + // Yano returns [] for that case; its 404 means the route is missing. + if (blockfrostFlavor && response.status() == 404) return false; + if (!blockfrostFlavor && response.status() == 404) { + throw new com.bloxbean.cardano.yano.wallet.core.service.HistoryPort.HistoryNotSupportedException( + "Address history unavailable: this node does not serve the history endpoint (HTTP 404)"); + } + if (!blockfrostFlavor && response.status() == 503) { + try { + JsonNode errorBody = objectMapper.readTree(response.body()); + String error = errorBody == null ? "" : errorBody.path("error").asText("").toLowerCase(Locale.ROOT); + if (error.contains("address") && (error.contains("disabled") || error.contains("not selected"))) { + throw new com.bloxbean.cardano.yano.wallet.core.service.HistoryPort.HistoryNotSupportedException( + "Address history unavailable: the node's address history index is disabled"); + } + } catch (IOException e) { + throw new NodeClientException("Unreadable address history error response", e); + } + } + if (response.status() != 200) { + throw new NodeClientException("Address history unavailable (HTTP " + response.status() + + "). Enable address transaction history on the node and retry."); + } + try { + JsonNode root = objectMapper.readTree(response.body()); + if (root == null || !root.isArray()) { + throw new NodeClientException("Invalid address history response: expected an array"); + } + return !root.isEmpty(); + } catch (IOException e) { + throw new NodeClientException("Unreadable address history response", e); + } + } + + /** Null means an older node lacks the route; an incomplete index never means unused. */ + private Boolean addressFirstSeen(String address) { + RawResponse response = getRaw("addresses/" + address + "/first-seen", requestTimeout); + if (response.status() == 404) return null; + if (response.status() == 503) { + throw new HistoryNotSupportedException("Address discovery index is disabled or incomplete; enable it before a fresh node sync"); + } + if (response.status() != 200) throw new NodeClientException("First-seen lookup failed (HTTP " + response.status() + ")"); + try { + JsonNode root = objectMapper.readTree(response.body()); + JsonNode coverage = root.path("coverage"); + if (!root.has("firstSeenSlot") || !coverage.path("enabled").asBoolean() + || !coverage.path("completeFromOrigin").asBoolean() + || coverage.hasNonNull("unavailableReason") + || !coverage.path("indexedThrough").path("blockNumber").isIntegralNumber() + || !root.path("liveTip").path("blockNumber").isIntegralNumber()) { + throw new NodeClientException("First-seen response does not establish complete coverage"); + } + JsonNode first = root.get("firstSeenSlot"); + if (first.isNull()) { + if (coverage.path("indexedThrough").path("blockNumber").longValue() + < root.path("liveTip").path("blockNumber").longValue()) { + throw new NodeClientException("First-seen index is catching up; retry address discovery"); + } + return false; + } + if (!first.isIntegralNumber() || !first.canConvertToLong() || first.longValue() < 0) { + throw new NodeClientException("Invalid first-seen slot"); + } + return true; + } catch (IOException failure) { + throw new NodeClientException("Unreadable first-seen response", failure); + } + } + /** * Stake account state from {@code GET /accounts/{stake}}. A 404 (account * never seen) maps to an unregistered view rather than an error. diff --git a/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/YanoNodePorts.java b/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/YanoNodePorts.java index 0894a37..109eaf8 100644 --- a/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/YanoNodePorts.java +++ b/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/YanoNodePorts.java @@ -10,12 +10,15 @@ import java.util.List; import java.util.Locale; import java.util.Objects; +import java.util.Optional; +import java.nio.file.Path; /** * wallet-core port implementations over the Yano REST API (ADR-033 M2, ADR-042). */ public class YanoNodePorts implements NodeStatusPort, HistoryPort, TxSimulationPort { private final YanoNodeClient client; + private WalletScanHistory scanHistory; /** * Probed once and reused: the answer only changes when the node is replaced, @@ -29,6 +32,11 @@ public YanoNodePorts(YanoNodeClient client) { this.client = Objects.requireNonNull(client, "client is required"); } + /** Wallet application supplies a network-scoped directory for durable scan state. */ + public void enableScanHistory(Path directory) { + if (!client.isBlockfrostFlavor()) scanHistory = new WalletScanHistory(client, Objects.requireNonNull(directory)); + } + @Override public NodeView status() { NodeStatus status = client.getStatus(); @@ -61,6 +69,13 @@ public AccountView accountInfo(String stakeAddress) { public List walletTransactions(String stakeAddress, String paymentAddress, int page, int count, boolean newestFirst) { try { + if (scanHistory != null) { + try { + return scanHistory.transactions(stakeAddress, page, count, newestFirst); + } catch (HistoryNotSupportedException missingRoute) { + // An older node may still offer the existing optional history APIs. + } + } String order = newestFirst ? "desc" : "asc"; // yaci-store has no /accounts/{stake}/transactions route at all // (verified against a live DevKit), so asking it there 404s and the @@ -86,6 +101,11 @@ public List walletTransactions(String stakeAddress, String paymentAddress } } + @Override + public Optional scanProgress() { + return scanHistory == null ? Optional.empty() : scanHistory.progress(); + } + @Override public List rewards(String stakeAddress, int page, int count) { try { diff --git a/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/YanoTransactionProcessor.java b/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/YanoTransactionProcessor.java new file mode 100644 index 0000000..2ac0bd4 --- /dev/null +++ b/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/YanoTransactionProcessor.java @@ -0,0 +1,81 @@ +package com.bloxbean.cardano.yano.wallet.nodeclient; + +import com.bloxbean.cardano.client.backend.api.DefaultTransactionProcessor; +import com.bloxbean.cardano.client.backend.api.TransactionService; +import com.bloxbean.cardano.client.api.model.Result; +import com.bloxbean.cardano.client.api.exception.ApiException; +import com.bloxbean.cardano.yano.wallet.core.service.MempoolConflictException; + +import java.util.Locale; + +/** Retains the node rejection as terminal; never retries a conflicting signed transaction. */ +final class YanoTransactionProcessor extends DefaultTransactionProcessor { + private final PendingInputs pendingInputs; + private final YanoNodeClient client; + + YanoTransactionProcessor(TransactionService service, PendingInputs pendingInputs, YanoNodeClient client) { + super(service); + this.pendingInputs = pendingInputs; + this.client = client; + } + + @Override + public Result submitTransaction(byte[] cbor) throws ApiException { + pendingInputs.refresh(client); + String hash = pendingInputs.reserve(cbor); + // Transport failures and 5xx responses have an unknown outcome. Keep reservations until TTL. + Result result = super.submitTransaction(cbor); + if (result != null && !result.isSuccessful()) { + String reason = String.valueOf(result.getResponse()); + String normalized = reason.toLowerCase(Locale.ROOT); + boolean definite = result.code() >= 400 && result.code() < 500; + if (normalized.contains("mempool admission failed (conflict)") + || normalized.contains("regular input is already claimed by")) { + // Claimed by this very transaction: it is in the mempool already. + // pre16 answers a duplicate 200 before it checks conflicts, so this + // guards other builds — recorded as a conflict it would be marked + // failed and its real TTL lost. + if (PendingInputs.claimant(reason).filter(hash::equalsIgnoreCase).isPresent()) { + return submitted(hash, "Transaction is already in the mempool"); + } + boolean recorded = pendingInputs.recordConflict(hash, reason); + if (!recorded && definite) pendingInputs.release(hash); + throw new MempoolConflictException(reason); + } + if (definite) { + if (alreadyInBlock(hash, reason)) return submitted(hash, "Transaction is already on chain"); + pendingInputs.release(hash); + } + } + return result; + } + + /** + * Whether a refused transaction is refused because it has already landed. A + * resend after an unknown outcome gets exactly that when the first attempt + * went through: its inputs are spent — by itself. Recorded as a failure, the + * draft is discarded and a rebuild selects other funds, paying twice. A + * resend still in the mempool needs no check; the node answers it 200. + * + *

Asked on every definite refusal, not only on resends: the reservation a + * resend would be recognised by is gone once {@link PendingInputs#refresh} + * sees the transaction in a block, which is exactly this case. + */ + private boolean alreadyInBlock(String hash, String reason) throws ApiException { + try { + return "in_block".equals(client.getTxStatus(hash).status()); + } catch (NodeClientException unanswered) { + // A refusal that cannot be told apart from "already on chain" has an + // unknown outcome: keep the reservation and report it as a transport + // failure, so the caller keeps the draft instead of marking it failed. + throw new ApiException("The node refused the transaction (" + reason + + "), but whether it is already on chain could not be checked: " + + unanswered.getMessage(), unanswered); + } + } + + @SuppressWarnings("unchecked") + private static Result submitted(String hash, String response) { + return Result.success(response).withValue(hash).code(200); + } +} diff --git a/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/YanoUtxoSupplier.java b/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/YanoUtxoSupplier.java new file mode 100644 index 0000000..ae3d195 --- /dev/null +++ b/wallet-node-client/src/main/java/com/bloxbean/cardano/yano/wallet/nodeclient/YanoUtxoSupplier.java @@ -0,0 +1,112 @@ +package com.bloxbean.cardano.yano.wallet.nodeclient; + +import com.bloxbean.cardano.client.address.Address; +import com.bloxbean.cardano.client.api.UtxoSupplier; +import com.bloxbean.cardano.client.api.common.OrderEnum; +import com.bloxbean.cardano.client.api.exception.ApiException; +import com.bloxbean.cardano.client.api.exception.ApiRuntimeException; +import com.bloxbean.cardano.client.api.model.Utxo; +import com.bloxbean.cardano.client.backend.api.DefaultUtxoSupplier; +import com.bloxbean.cardano.client.backend.api.UtxoService; + +import java.util.List; +import java.util.ArrayList; +import java.util.Optional; +import java.util.Set; + +/** Preserve node failures that CCL's default supplier otherwise turns into empty results. */ +final class YanoUtxoSupplier extends DefaultUtxoSupplier { + private final UtxoService service; + private final YanoNodeClient client; + private boolean searchByAddressVkh; + private final PendingInputs pendingInputs; + + YanoUtxoSupplier(UtxoService service, YanoNodeClient client) { + this(service, client, null); + } + + YanoUtxoSupplier(UtxoService service, YanoNodeClient client, PendingInputs pendingInputs) { + super(service); + this.service = service; + this.client = client; + this.pendingInputs = pendingInputs; + } + + @Override + public List getPage(String address, Integer count, Integer page, OrderEnum order) { + int size = count == null ? UtxoSupplier.DEFAULT_NR_OF_ITEMS_TO_FETCH : count; + int number = page == null ? 0 : page; + if (size <= 0 || number < 0) throw new IllegalArgumentException("Invalid UTxO page/count"); + Set blocked = blockedInputs(); + if (blocked.isEmpty()) return sourcePage(address, size, number, order); + // Paginate AFTER filtering: a wholly reserved server page must not end CCL's getAll loop. + long skip = Math.multiplyExact((long) number, size); + List selected = new ArrayList<>(); + for (int sourcePage = 0; ; sourcePage++) { + List outputs = sourcePage(address, size, sourcePage, order); + if (outputs.isEmpty()) return selected; + for (Utxo output : outputs) { + if (blocked.contains(output.getTxHash() + "#" + output.getOutputIndex())) continue; + if (skip > 0) { skip--; continue; } + selected.add(output); + if (selected.size() == size) return selected; + } + } + } + + @Override + public List getAll(String address) { + Set blocked = blockedInputs(); + List result = new ArrayList<>(); + for (int page = 0; ; page++) { + List outputs = sourcePage(address, DEFAULT_NR_OF_ITEMS_TO_FETCH, page, OrderEnum.asc); + if (outputs.isEmpty()) return result; + outputs.stream().filter(u -> !blocked.contains(u.getTxHash() + "#" + u.getOutputIndex())) + .forEach(result::add); + } + } + + @Override + public Optional getTxOutput(String txHash, int outputIndex) { + if (blockedInputs().contains(txHash + "#" + outputIndex)) return Optional.empty(); + if (pendingInputs == null) return super.getTxOutput(txHash, outputIndex); + return client.getSelectionOutput(txHash, outputIndex); + } + + private Set blockedInputs() { + if (pendingInputs == null || pendingInputs.isEmpty()) return Set.of(); + pendingInputs.refresh(client); + return pendingInputs.snapshot(); + } + + private List sourcePage(String address, Integer count, Integer page, OrderEnum order) { + if (pendingInputs != null) { + return client.getSelectionUtxos(address, searchByAddressVkh, count, page + 1, + order == null ? OrderEnum.asc : order); + } + String key = searchByAddressVkh + ? new Address(address).getBech32VerificationKeyHash().orElseThrow() : address; + try { + var result = service.getUtxos(key, count == null ? UtxoSupplier.DEFAULT_NR_OF_ITEMS_TO_FETCH : count, + page == null ? 1 : page + 1, order == null ? OrderEnum.asc : order); + if (result != null && result.code() == 404) return List.of(); + if (result == null || !result.isSuccessful() || result.getValue() == null) { + throw new IllegalStateException("UTxO lookup failed (HTTP " + + (result == null ? "unknown" : result.code()) + "); balance is unavailable"); + } + return result.getValue(); + } catch (ApiException e) { + throw new ApiRuntimeException(e); + } + } + + @Override + public boolean isUsedAddress(Address address) { + return client.isAddressUsed(address.toBech32()); + } + + @Override + public void setSearchByAddressVkh(boolean flag) { + searchByAddressVkh = flag; + } +} diff --git a/wallet-node-client/src/test/java/com/bloxbean/cardano/yano/wallet/nodeclient/BlockfrostFlavorPathTest.java b/wallet-node-client/src/test/java/com/bloxbean/cardano/yano/wallet/nodeclient/BlockfrostFlavorPathTest.java index f71d504..3a0872c 100644 --- a/wallet-node-client/src/test/java/com/bloxbean/cardano/yano/wallet/nodeclient/BlockfrostFlavorPathTest.java +++ b/wallet-node-client/src/test/java/com/bloxbean/cardano/yano/wallet/nodeclient/BlockfrostFlavorPathTest.java @@ -4,9 +4,11 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import java.io.IOException; import java.math.BigInteger; +import java.nio.file.Path; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -40,6 +42,9 @@ class BlockfrostFlavorPathTest { private StubYanoNode node; + @TempDir + Path temporaryDirectory; + @BeforeEach void setUp() throws IOException { node = new StubYanoNode(); @@ -87,6 +92,21 @@ void yaciStoreIsAskedForAddressHistoryInstead() { .noneMatch(request -> request.path().contains("/accounts/")); } + @Test + void yaciStoreNeverUsesYanoWalletIndexEndpoints() { + node.on("/api/v1/addresses/" + ADDRESS + "/transactions", addressTxs()); + YanoNodeClient client = new YanoNodeClient(node.baseUrl(), true); + YanoNodePorts ports = new YanoNodePorts(client); + + ports.enableScanHistory(temporaryDirectory.resolve("scan-history")); + assertThat(client.isAddressUsed(ADDRESS)).isTrue(); + assertThat(ports.walletTransactions(STAKE, ADDRESS, 1, 10, true)).hasSize(1); + + assertThat(node.requests()).allMatch(request -> + request.path().contains("/addresses/") + && request.path().contains("/transactions")); + } + @Test void aMissingHistoryRouteIsReportedNotSwallowed() { // Nothing registered → 404. Never a convincing empty list: the caller has diff --git a/wallet-node-client/src/test/java/com/bloxbean/cardano/yano/wallet/nodeclient/FirstSeenDiscoveryTest.java b/wallet-node-client/src/test/java/com/bloxbean/cardano/yano/wallet/nodeclient/FirstSeenDiscoveryTest.java new file mode 100644 index 0000000..b90705a --- /dev/null +++ b/wallet-node-client/src/test/java/com/bloxbean/cardano/yano/wallet/nodeclient/FirstSeenDiscoveryTest.java @@ -0,0 +1,56 @@ +package com.bloxbean.cardano.yano.wallet.nodeclient; + +import com.bloxbean.cardano.yano.wallet.core.service.HistoryPort.HistoryNotSupportedException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FirstSeenDiscoveryTest { + @Test void slotZeroCountsAsUsedAndCompleteNullAsUnused() throws Exception { + try (StubYanoNode node = new StubYanoNode()) { + YanoNodeClient client = new YanoNodeClient(node.baseUrl(), false); + node.on("/api/v1/addresses/test/first-seen", response("0", true, 3, 3)); + assertThat(client.isAddressUsed("test")).isTrue(); + node.on("/api/v1/addresses/test/first-seen", response("null", true, 3, 3)); + assertThat(client.isAddressUsed("test")).isFalse(); + assertThat(node.requests()).allMatch(r -> r.path().endsWith("/first-seen")); + } + } + + @Test void LaggingNullAndIncompletePositiveNeverDriveGapCounter() throws Exception { + try (StubYanoNode node = new StubYanoNode()) { + YanoNodeClient client = new YanoNodeClient(node.baseUrl(), false); + node.on("/api/v1/addresses/test/first-seen", response("null", true, 2, 3)); + assertThatThrownBy(() -> client.isAddressUsed("test")).isInstanceOf(NodeClientException.class) + .hasMessageContaining("catching up"); + node.on("/api/v1/addresses/test/first-seen", response("10", false, 3, 3)); + assertThatThrownBy(() -> client.isAddressUsed("test")).isInstanceOf(NodeClientException.class) + .hasMessageContaining("complete coverage"); + } + } + + @Test void DisabledIndexDoesNotFallBackToPossiblyIncompleteHistory() throws Exception { + try (StubYanoNode node = new StubYanoNode()) { + node.on("/api/v1/addresses/test/first-seen", r -> new StubYanoNode.Response(503, "application/json", "{}")); + assertThatThrownBy(() -> new YanoNodeClient(node.baseUrl(), false).isAddressUsed("test")) + .isInstanceOf(HistoryNotSupportedException.class); + assertThat(node.requests()).hasSize(1); + } + } + + @Test void OlderNodeUsesExistingHistoryEndpoint() throws Exception { + try (StubYanoNode node = new StubYanoNode()) { + node.on("/api/v1/addresses/test/transactions", "[{\"tx_hash\":\"abc\"}]"); + assertThat(new YanoNodeClient(node.baseUrl(), false).isAddressUsed("test")).isTrue(); + assertThat(node.requests()).hasSize(2); + } + } + + private String response(String slot, boolean complete, int indexed, int tip) { + return """ + {"firstSeenSlot":%s,"coverage":{"enabled":true,"completeFromOrigin":%s, + "indexedThrough":{"blockNumber":%d}},"liveTip":{"blockNumber":%d}} + """.formatted(slot, complete, indexed, tip); + } +} diff --git a/wallet-node-client/src/test/java/com/bloxbean/cardano/yano/wallet/nodeclient/PendingInputsTest.java b/wallet-node-client/src/test/java/com/bloxbean/cardano/yano/wallet/nodeclient/PendingInputsTest.java new file mode 100644 index 0000000..8a1f01b --- /dev/null +++ b/wallet-node-client/src/test/java/com/bloxbean/cardano/yano/wallet/nodeclient/PendingInputsTest.java @@ -0,0 +1,404 @@ +package com.bloxbean.cardano.yano.wallet.nodeclient; + +import com.bloxbean.cardano.client.address.Address; +import com.bloxbean.cardano.client.api.common.OrderEnum; +import com.bloxbean.cardano.client.api.exception.ApiException; +import com.bloxbean.cardano.client.transaction.util.TransactionUtil; +import com.bloxbean.cardano.client.api.model.Utxo; +import com.bloxbean.cardano.client.api.model.Amount; +import com.bloxbean.cardano.client.api.model.ProtocolParams; +import com.bloxbean.cardano.client.common.model.Networks; +import com.bloxbean.cardano.hdwallet.Wallet; +import com.bloxbean.cardano.yano.wallet.core.tx.QuickAdaTxService; +import com.bloxbean.cardano.yano.wallet.core.wallet.WalletAddresses; +import com.bloxbean.cardano.client.transaction.spec.Transaction; +import com.bloxbean.cardano.client.transaction.spec.TransactionBody; +import com.bloxbean.cardano.client.transaction.spec.TransactionInput; +import com.bloxbean.cardano.client.transaction.spec.TransactionWitnessSet; +import com.bloxbean.cardano.yano.wallet.core.config.WalletNetwork; +import com.bloxbean.cardano.yano.wallet.core.service.MempoolConflictException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.math.BigInteger; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HexFormat; +import java.util.List; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.*; + +class PendingInputsTest { + static final String ADDRESS = "addr_test1qzx9hu8j4ah3auytk0mwcupd69hpc52t0cw39a65ndrah86djs784u92a3m5w475w3w35tyd6v3qumkze80j8a6h5tuqq5xe8y"; + static final String HASH = "11".repeat(32); + static final String ROUTE = "/api/v1/addresses/" + ADDRESS + "/utxos"; + @TempDir Path directory; + + static byte[] tx(int count) throws Exception { + return Transaction.builder().body(TransactionBody.builder() + .inputs(IntStream.range(0, count).mapToObj(i -> new TransactionInput(HASH, i)).toList()) + .outputs(List.of()).fee(BigInteger.valueOf(200000)).ttl(1000).build()) + .witnessSet(new TransactionWitnessSet()).build().serialize(); + } + + /** The same inputs in a different transaction — a stale draft, not a resend. */ + static byte[] withTtl(byte[] cbor, long ttl) throws Exception { + Transaction transaction = Transaction.deserialize(cbor); + transaction.getBody().setTtl(ttl); + return transaction.serialize(); + } + + static StubYanoNode.Response unknownOutcome() { + return new StubYanoNode.Response(503, "text/plain", "unavailable"); + } + + static StubYanoNode.Response refused(String reason) { + return new StubYanoNode.Response(400, "application/json", + "{\"error\":\"Failed to submit transaction: " + reason + "\"}"); + } + + static long submits(StubYanoNode node) { + return node.requests().stream().filter(r -> r.path().contains("tx/submit")).count(); + } + + static String utxos(int first, int end) { + return IntStream.range(first, end).mapToObj(i -> """ + {"tx_hash":"%s","output_index":%d,"address":"%s", + "amount":[{"unit":"lovelace","quantity":"10000000"}, + {"unit":"%s","quantity":"20"}]} + """.formatted(HASH, i, ADDRESS, "ab".repeat(28) + "746f6b656e")) + .collect(java.util.stream.Collectors.joining(",", "[", "]")); + } + + static void status(StubYanoNode node, long slot, int lag) { + node.on("/api/v1/status", """ + {"chain":{"slot":%d,"blockNumber":10}, + "utxo":{"enabled":true,"lagBlocks":%d,"lastAppliedBlock":10}} + """.formatted(slot, lag)); + } + + @Test void submittedInputsAreExcludedAcrossPagesButConfirmedAndDevkitViewsAreUnchanged() throws Exception { + try (var node = new StubYanoNode()) { + status(node, 100, 0); + node.on(ROUTE, r -> StubYanoNode.Response.json(r.path().contains("page=1&") + ? utxos(0, 100) : r.path().contains("page=2&") ? utxos(100, 102) : "[]")); + node.on("/api/v1/tx/submit", "\"" + HASH + "\""); + var backend = YanoNodeBackend.connect(WalletNetwork.PREPROD, node.baseUrl()); + backend.persistPendingInputs(directory.resolve("pending-inputs.json")); + assertThat(backend.transactionProcessor().submitTransaction(tx(100)).isSuccessful()).isTrue(); + assertThat(backend.selectionUtxoSupplier().getAll(ADDRESS)).extracting(Utxo::getOutputIndex) + .containsExactly(100, 101); + assertThat(backend.selectionUtxoSupplier().getPage(ADDRESS, 100, 0, OrderEnum.asc)) + .extracting(Utxo::getOutputIndex).containsExactly(100, 101); + assertThat(backend.selectionUtxoSupplier().getPage(ADDRESS, 100, 1, OrderEnum.asc)).isEmpty(); + assertThat(backend.selectionUtxoSupplier().getAll(ADDRESS).getFirst().getAmount()).hasSize(2); + assertThat(backend.selectionUtxoSupplier().getTxOutput(HASH, 0)).isEmpty(); + assertThat(backend.utxoSupplier().getAll(ADDRESS)).hasSize(102); + var restarted = YanoNodeBackend.connect(WalletNetwork.PREPROD, node.baseUrl()); + restarted.persistPendingInputs(directory.resolve("pending-inputs.json")); + assertThat(restarted.selectionUtxoSupplier().getAll(ADDRESS)).hasSize(2); + var devkit = YanoNodeBackend.connect(WalletNetwork.YACI_DEVKIT, node.baseUrl()); + assertThat(devkit.selectionUtxoSupplier()).isSameAs(devkit.utxoSupplier()); + assertThat(devkit.selectionUtxoSupplier().getAll(ADDRESS)).hasSize(102); + assertThat(node.requests()).anyMatch(r -> r.path().contains("include_mempool=true")); + } + } + + @Test void credentialSelectionFiltersInputsAndPreservesOrder() throws Exception { + try (var node = new StubYanoNode()) { + status(node, 100, 0); + String credential = HexFormat.of().formatHex(new Address(ADDRESS).getPaymentCredentialHash().orElseThrow()); + node.on("/api/v1/credentials/" + credential + "/utxos", r -> + StubYanoNode.Response.json(r.path().contains("page=1&") ? utxos(0, 2) : "[]")); + node.on("/api/v1/tx/submit", "\"" + HASH + "\""); + var backend = YanoNodeBackend.connect(WalletNetwork.PREPROD, node.baseUrl()); + backend.transactionProcessor().submitTransaction(tx(1)); + backend.selectionUtxoSupplier().setSearchByAddressVkh(true); + assertThat(backend.selectionUtxoSupplier().getPage(ADDRESS, 2, 0, OrderEnum.desc)) + .extracting(Utxo::getOutputIndex).containsExactly(1); + assertThat(node.requests().stream().filter(r -> r.path().contains("/credentials/"))) + .allMatch(r -> r.path().contains("count=2") && r.path().contains("order=desc")); + } + } + + @Test void realAdaAndTokenDraftsSpendPendingChangeFromOverlay() throws Exception { + try (var node = new StubYanoNode()) { + status(node, 100, 0); + Wallet wallet = Wallet.createFromMnemonic(Networks.preprod(), + "drive useless envelope shine range ability time copper alarm museum near flee wrist " + + "live type device meadow allow churn purity wisdom praise drop code"); + for (int role = 0; role < 2; role++) { + for (int index = 0; index < 22; index++) { + String address = WalletAddresses.baseAddress(wallet, role, index).toBech32(); + node.on("/api/v1/addresses/" + address + "/transactions", "[]"); + node.on("/api/v1/addresses/" + address + "/utxos", "[]"); + } + } + String sender = wallet.getBaseAddressString(0); + node.on("/api/v1/addresses/" + sender + "/utxos", r -> StubYanoNode.Response.json( + !r.path().contains("page=1&") ? "[]" : r.path().contains("include_mempool=true") + ? utxos(0, 1).replace(HASH, "22".repeat(32)).replace(ADDRESS, sender) + .replace("\"amount\":", "\"block\":null,\"amount\":") + : utxos(0, 1).replace(ADDRESS, sender))); + node.on("/api/v1/tx/submit", "\"" + HASH + "\""); + var backend = YanoNodeBackend.connect(WalletNetwork.PREPROD, node.baseUrl()); + backend.transactionProcessor().submitTransaction(tx(1)); + var params = ProtocolParams.builder().minFeeA(44).minFeeB(155381).minUtxo("1000000") + .coinsPerUtxoSize("4312").minFeeRefScriptCostPerByte(BigDecimal.valueOf(15)).build(); + for (boolean token : List.of(false, true)) { + var draft = new QuickAdaTxService().buildSignedDraft(wallet, backend.selectionUtxoSupplier(), + () -> params, backend.transactionProcessor(), ADDRESS, BigInteger.valueOf(2_000_000), + token ? List.of(new Amount("ab".repeat(28) + "746f6b656e", BigInteger.ONE)) : List.of(), null); + Transaction built = Transaction.deserialize(HexFormat.of().parseHex(draft.cborHex())); + assertThat(built.getBody().getInputs()).hasSize(1); + assertThat(built.getBody().getInputs().getFirst().getIndex()).isEqualTo(0); + assertThat(built.getBody().getInputs().getFirst().getTransactionId()).isEqualTo("22".repeat(32)); + } + assertThat(backend.utxoSupplier().getAll(sender)).extracting(Utxo::getTxHash).containsExactly(HASH); + } + } + + @Test void definiteRejectionReleasesInputsAndConflictIsNeverAutomaticallyRetried() throws Exception { + try (var node = new StubYanoNode()) { + node.on("/api/v1/tx/submit", r -> new StubYanoNode.Response(400, "text/plain", + "Mempool admission failed (CONFLICT): regular input is already claimed by other")); + node.on(ROUTE, r -> StubYanoNode.Response.json(r.path().contains("page=1&") ? utxos(0, 1) : "[]")); + var backend = YanoNodeBackend.connect(WalletNetwork.PREPROD, node.baseUrl()); + assertThatThrownBy(() -> backend.transactionProcessor().submitTransaction(tx(1))) + .isInstanceOf(MempoolConflictException.class).hasMessageContaining("Rebuild"); + assertThat(backend.selectionUtxoSupplier().getAll(ADDRESS)).hasSize(1); + assertThat(node.requests().stream().filter(r -> r.path().contains("tx/submit"))).hasSize(1); + } + } + + @Test void uncertainSubmissionStaysReservedUntilExpiryAndLagDoesNotReleaseIt() throws Exception { + try (var node = new StubYanoNode()) { + status(node, 100, 0); + node.on("/api/v1/tx/submit", r -> new StubYanoNode.Response(503, "text/plain", "unavailable")); + node.on(ROUTE, r -> StubYanoNode.Response.json(r.path().contains("page=1&") ? utxos(0, 1) : "[]")); + var backend = YanoNodeBackend.connect(WalletNetwork.PREPROD, node.baseUrl()); + assertThat(backend.transactionProcessor().submitTransaction(tx(1)).isSuccessful()).isFalse(); + assertThat(backend.selectionUtxoSupplier().getAll(ADDRESS)).isEmpty(); + // A different transaction over the same input is a stale draft: refused + // before it reaches the node. (The identical one may be resent — below.) + assertThatThrownBy(() -> backend.transactionProcessor().submitTransaction(withTtl(tx(1), 2000))) + .isInstanceOf(MempoolConflictException.class); + assertThat(node.requests().stream().filter(r -> r.path().contains("tx/submit"))).hasSize(1); + status(node, 1001, 2); + assertThat(backend.selectionUtxoSupplier().getAll(ADDRESS)).isEmpty(); + status(node, 1001, 0); + assertThat(backend.selectionUtxoSupplier().getAll(ADDRESS)).hasSize(1); + } + } + + @Test void resendingTheSameTransactionAfterAnUnknownOutcomeReachesTheNode() throws Exception { + // The first answer was lost, so the payment may or may not have gone. The + // identical signed transaction is how that is settled: it cannot pay + // twice, and Yano answers one it already holds with 200 (DUPLICATE). + try (var node = new StubYanoNode()) { + status(node, 100, 0); + node.on(ROUTE, r -> StubYanoNode.Response.json(r.path().contains("page=1&") ? utxos(0, 1) : "[]")); + node.on("/api/v1/tx/submit", r -> unknownOutcome()); + Path file = directory.resolve("pending-inputs.json"); + var backend = YanoNodeBackend.connect(WalletNetwork.PREPROD, node.baseUrl()); + backend.persistPendingInputs(file); + byte[] signed = tx(1); + String hash = TransactionUtil.getTxHash(signed); + assertThat(backend.transactionProcessor().submitTransaction(signed).isSuccessful()).isFalse(); + String reserved = Files.readString(file); + + node.on("/api/v1/tx/submit", "\"" + hash + "\""); + var resent = backend.transactionProcessor().submitTransaction(signed); + + assertThat(resent.isSuccessful()).isTrue(); + assertThat(resent.getValue()).isEqualTo(hash); + assertThat(submits(node)).isEqualTo(2); + // Accepted is not confirmed: the input stays out of selection, under + // the same reservation and TTL. + assertThat(backend.selectionUtxoSupplier().getAll(ADDRESS)).isEmpty(); + assertThat(Files.readString(file)).isEqualTo(reserved); + } + } + + @Test void resendOfATransactionAlreadyInABlockIsReportedAsSubmitted() throws Exception { + // The lost answer was an acceptance and the transaction has landed since, + // so the resend is refused for spending its own inputs. As a failure it + // would discard a payment that has gone, and a rebuild would pay again. + try (var node = new StubYanoNode()) { + status(node, 100, 0); + node.on("/api/v1/tx/submit", r -> unknownOutcome()); + var backend = YanoNodeBackend.connect(WalletNetwork.PREPROD, node.baseUrl()); + byte[] signed = tx(1); + String hash = TransactionUtil.getTxHash(signed); + assertThat(backend.transactionProcessor().submitTransaction(signed).isSuccessful()).isFalse(); + + node.on("/api/v1/txs/" + hash, "{\"hash\":\"" + hash + "\",\"block_height\":10,\"slot\":100}"); + node.on("/api/v1/tx/submit", r -> refused("Transaction validation failed: UtxoNotFound " + HASH + "#0")); + var resent = backend.transactionProcessor().submitTransaction(signed); + + assertThat(resent.isSuccessful()).isTrue(); + assertThat(resent.getValue()).isEqualTo(hash); + assertThat(submits(node)).isEqualTo(2); + } + } + + @Test void aRefusalThatCannotBeCheckedAgainstTheChainKeepsItsReservation() throws Exception { + // Refused, and the node cannot say whether the transaction is already in + // a block: an unknown outcome. Nothing is released, and the caller hears + // "retry" (a transport-style failure) rather than "this payment failed". + try (var node = new StubYanoNode()) { + status(node, 100, 0); + node.on(ROUTE, r -> StubYanoNode.Response.json(r.path().contains("page=1&") ? utxos(0, 1) : "[]")); + var backend = YanoNodeBackend.connect(WalletNetwork.PREPROD, node.baseUrl()); + byte[] signed = tx(1); + String hash = TransactionUtil.getTxHash(signed); + node.on("/api/v1/tx/submit", r -> refused("Transaction validation failed: UtxoNotFound " + HASH + "#0")); + node.on("/api/v1/txs/" + hash, r -> new StubYanoNode.Response(503, "application/json", + "{\"error\":\"UTxO state unavailable\"}")); + + assertThatThrownBy(() -> backend.transactionProcessor().submitTransaction(signed)) + .isInstanceOf(ApiException.class).hasMessageContaining("could not be checked"); + + node.on("/api/v1/txs/" + hash, r -> new StubYanoNode.Response(404, "application/json", "{}")); + assertThat(backend.selectionUtxoSupplier().getAll(ADDRESS)).isEmpty(); + } + } + + @Test void aDefiniteRefusalOfATransactionNotOnChainReleasesItsInputs() throws Exception { + try (var node = new StubYanoNode()) { + status(node, 100, 0); + node.on(ROUTE, r -> StubYanoNode.Response.json(r.path().contains("page=1&") ? utxos(0, 1) : "[]")); + node.on("/api/v1/tx/submit", r -> refused("Transaction validation failed: ValueNotConserved")); + var backend = YanoNodeBackend.connect(WalletNetwork.PREPROD, node.baseUrl()); + + var result = backend.transactionProcessor().submitTransaction(tx(1)); + + assertThat(result.isSuccessful()).isFalse(); + assertThat(result.code()).isEqualTo(400); + assertThat(backend.selectionUtxoSupplier().getAll(ADDRESS)).hasSize(1); + // Asked the chain before believing the refusal. + assertThat(node.requests()).anyMatch(r -> r.path().startsWith("/api/v1/txs/")); + } + } + + @Test void aConflictClaimedByTheSameTransactionIsAlreadySubmitted() throws Exception { + // pre16 answers a duplicate 200 before it checks conflicts; a build that + // did not would name this very transaction as the claimant. Recorded as a + // conflict it would be marked failed, and its reservation lose its TTL. + try (var node = new StubYanoNode()) { + status(node, 100, 0); + node.on(ROUTE, r -> StubYanoNode.Response.json(r.path().contains("page=1&") ? utxos(0, 1) : "[]")); + var backend = YanoNodeBackend.connect(WalletNetwork.PREPROD, node.baseUrl()); + byte[] signed = tx(1); + String hash = TransactionUtil.getTxHash(signed); + node.on("/api/v1/tx/submit", r -> refused("Mempool admission failed (CONFLICT): " + + "regular input is already claimed by " + hash + ": " + HASH + "#0")); + + var result = backend.transactionProcessor().submitTransaction(signed); + + assertThat(result.isSuccessful()).isTrue(); + assertThat(result.getValue()).isEqualTo(hash); + assertThat(backend.selectionUtxoSupplier().getAll(ADDRESS)).isEmpty(); + status(node, 1001, 0); // Its own TTL (1000) still releases it. + assertThat(backend.selectionUtxoSupplier().getAll(ADDRESS)).hasSize(1); + } + } + + @Test void rebuildExcludesNodeReportedConflictEvenWhenOriginalSubmissionWasNotTracked() throws Exception { + try (var node = new StubYanoNode()) { + String owner = "70".repeat(32); + status(node, 100, 0); + node.on(ROUTE, r -> StubYanoNode.Response.json(r.path().contains("page=1&") ? utxos(0, 2) : "[]")); + node.on("/api/v1/tx/submit", r -> new StubYanoNode.Response(400, "application/json", + "{\"error\":\"Failed to submit transaction: Mempool admission failed (CONFLICT): " + + "regular input is already claimed by " + owner + ": " + HASH + "#0\"}")); + Path file = directory.resolve("pending-inputs.json"); + var backend = YanoNodeBackend.connect(WalletNetwork.PREPROD, node.baseUrl()); + backend.persistPendingInputs(file); + assertThatThrownBy(() -> backend.transactionProcessor().submitTransaction(tx(2))) + .isInstanceOf(MempoolConflictException.class); + assertThat(backend.selectionUtxoSupplier().getAll(ADDRESS)).extracting(Utxo::getOutputIndex) + .containsExactly(1); // Other inputs from the rejected draft are released. + assertThat(backend.utxoSupplier().getAll(ADDRESS)).hasSize(2); + var restarted = YanoNodeBackend.connect(WalletNetwork.PREPROD, node.baseUrl()); + restarted.persistPendingInputs(file); + status(node, 1001, 0); // Rejected draft's TTL must not unlock the other transaction's input. + var available = restarted.selectionUtxoSupplier().getAll(ADDRESS); + assertThat(available).extracting(Utxo::getOutputIndex).containsExactly(1); + Transaction rebuilt = Transaction.deserialize(tx(1)); + rebuilt.getBody().setInputs(List.of(new TransactionInput(available.getFirst().getTxHash(), + available.getFirst().getOutputIndex()))); + rebuilt.getBody().setTtl(2000); + node.on("/api/v1/tx/submit", "\"" + HASH + "\""); + assertThat(restarted.transactionProcessor().submitTransaction(rebuilt.serialize()).isSuccessful()).isTrue(); + assertThat(node.requests().stream().filter(r -> r.path().contains("tx/submit"))).hasSize(2); + node.on("/api/v1/txs/" + owner, "{\"hash\":\"" + owner + "\",\"slot\":1001,\"block_height\":10}"); + node.on(ROUTE, "[]"); + assertThat(restarted.selectionUtxoSupplier().getAll(ADDRESS)).isEmpty(); + assertThat(Files.readString(file)).doesNotContain(owner); + } + } + + @Test void devkitConflictResponseDoesNotEnableYanoTrackingOrChangeItsUtxos() throws Exception { + try (var node = new StubYanoNode()) { + node.on(ROUTE, r -> StubYanoNode.Response.json(r.path().contains("page=1&") ? utxos(0, 2) : "[]")); + node.on("/api/v1/tx/submit", r -> new StubYanoNode.Response(400, "application/json", + "{\"error\":\"Mempool admission failed (CONFLICT): regular input is already claimed by " + + "70".repeat(32) + ": " + HASH + "#0\"}")); + var devkit = YanoNodeBackend.connect(WalletNetwork.YACI_DEVKIT, node.baseUrl()); + Path file = directory.resolve("devkit-pending-inputs.json"); + devkit.persistPendingInputs(file); + assertThat(devkit.transactionProcessor().submitTransaction(tx(1)).isSuccessful()).isFalse(); + assertThat(devkit.selectionUtxoSupplier()).isSameAs(devkit.utxoSupplier()); + assertThat(devkit.selectionUtxoSupplier().getAll(ADDRESS)).extracting(Utxo::getOutputIndex) + .containsExactly(0, 1); + assertThat(file).doesNotExist(); + assertThat(node.requests()).allMatch(r -> r.path().startsWith(ROUTE) + || r.path().equals("/api/v1/tx/submit")); + } + } + + @Test void lookupAndStorageFailuresAreExplicit() throws Exception { + try (var node = new StubYanoNode()) { + status(node, 100, 0); + node.on("/api/v1/tx/submit", "\"" + HASH + "\""); + node.on(ROUTE, r -> r.path().contains("page=1&") ? StubYanoNode.Response.json(utxos(0, 100)) + : new StubYanoNode.Response(500, "text/plain", "broken index")); + var backend = YanoNodeBackend.connect(WalletNetwork.PREPROD, node.baseUrl()); + node.on("/api/v1/utxos/" + HASH + "/0", r -> new StubYanoNode.Response(500, "text/plain", "unavailable")); + assertThatThrownBy(() -> backend.selectionUtxoSupplier().getTxOutput(HASH, 0)) + .isInstanceOf(NodeClientException.class); + backend.transactionProcessor().submitTransaction(tx(100)); + assertThatThrownBy(() -> backend.selectionUtxoSupplier().getAll(ADDRESS)) + .isInstanceOf(NodeClientException.class); + node.on("/api/v1/status", r -> new StubYanoNode.Response(500, "text/plain", "unavailable")); + assertThatThrownBy(() -> backend.selectionUtxoSupplier().getAll(ADDRESS)).isInstanceOf(NodeClientException.class); + Path corrupt = directory.resolve("corrupt.json"); + Files.writeString(corrupt, "broken"); + assertThatThrownBy(() -> YanoNodeBackend.connect(WalletNetwork.PREPROD, node.baseUrl()) + .persistPendingInputs(corrupt)).isInstanceOf(java.io.UncheckedIOException.class); + } + } + + @Test void confirmationRefreshClearsPersistedReservationsEvenWithoutTtl() throws Exception { + try (var node = new StubYanoNode()) { + status(node, 100, 0); + Transaction transaction = Transaction.deserialize(tx(1)); + transaction.getBody().setTtl(0); + PendingInputs inputs = new PendingInputs(); + Path file = directory.resolve("pending-inputs.json"); + inputs.persistAt(file); + String hash = inputs.reserve(transaction.serialize()); + inputs.refresh(new YanoNodeClient(node.baseUrl())); + assertThat(inputs.snapshot()).containsExactly(HASH + "#0"); + node.on("/api/v1/txs/" + hash, "{\"hash\":\"" + hash + "\",\"block_height\":10,\"slot\":100}"); + inputs.refresh(new YanoNodeClient(node.baseUrl())); + assertThat(inputs.snapshot()).isEmpty(); + PendingInputs restarted = new PendingInputs(); + restarted.persistAt(file); + assertThat(restarted.snapshot()).isEmpty(); + } + } +} diff --git a/wallet-node-client/src/test/java/com/bloxbean/cardano/yano/wallet/nodeclient/ScanProgressTrackerTest.java b/wallet-node-client/src/test/java/com/bloxbean/cardano/yano/wallet/nodeclient/ScanProgressTrackerTest.java new file mode 100644 index 0000000..b5b9e54 --- /dev/null +++ b/wallet-node-client/src/test/java/com/bloxbean/cardano/yano/wallet/nodeclient/ScanProgressTrackerTest.java @@ -0,0 +1,79 @@ +package com.bloxbean.cardano.yano.wallet.nodeclient; + +import com.bloxbean.cardano.yano.wallet.core.service.HistoryPort.ScanProgress; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +class ScanProgressTrackerTest { + private final ObjectMapper mapper = new ObjectMapper(); + + @Test void reportsNothingUntilTheNodeStatesHowFarItReaches() { + ScanProgressTracker tracker = new ScanProgressTracker(-1); + + // A progress record before "ready" leaves the interval unknown, and a + // percentage computed against an unknown tip would be invented. + tracker.observe(record("progress", 500)); + assertThat(tracker.snapshot()).isEmpty(); + + tracker.observe(record("ready", 1_000)); + assertThat(tracker.snapshot()).get().isEqualTo(new ScanProgress(-1, -1, 1_000)); + } + + @Test void walksFromTheResumeCursorToTheTip() { + ScanProgressTracker tracker = new ScanProgressTracker(400); + tracker.observe(record("ready", 800)); + + tracker.observe(record("progress", 600)); + assertThat(tracker.snapshot()).get().isEqualTo(new ScanProgress(400, 600, 800)); + // Fraction covers the interval actually being walked, not the whole + // chain: an incremental scan is half done at its own midpoint. + assertThat(tracker.snapshot().orElseThrow().fraction()).isEqualTo(0.5, within(1e-9)); + + // Transactions carry a point too, so history-dense stretches still advance. + tracker.observe(record("transaction", 700)); + assertThat(tracker.snapshot()).get().isEqualTo(new ScanProgress(400, 700, 800)); + } + + @Test void completionIsNotProgress() { + ScanProgressTracker tracker = new ScanProgressTracker(-1); + tracker.observe(record("ready", 900)); + tracker.observe(record("progress", 450)); + + tracker.observe(record("done", 900)); + + assertThat(tracker.snapshot()).isEmpty(); + } + + @Test void skipsRecordsItCannotRead() { + ScanProgressTracker tracker = new ScanProgressTracker(0); + tracker.observe(record("ready", 100)); + ScanProgress before = tracker.snapshot().orElseThrow(); + + tracker.observe(mapper.createObjectNode().put("type", "progress")); + tracker.observe(mapper.createObjectNode().put("type", "transaction") + .set("point", mapper.createObjectNode().put("blockNumber", "not-a-number"))); + + assertThat(tracker.snapshot()).get().isEqualTo(before); + } + + @Test void aScanWithNothingToWalkIsComplete() { + // Tip equal to the cursor: the wallet is already at the node's tip, so + // the interval is empty rather than a division by zero. + assertThat(new ScanProgress(900, 900, 900).fraction()).isEqualTo(1.0); + } + + @Test void fractionStaysWithinBounds() { + // The node may index past the tip it announced while the scan runs. + assertThat(new ScanProgress(0, 1_200, 1_000).fraction()).isEqualTo(1.0); + assertThat(new ScanProgress(500, 100, 1_000).fraction()).isEqualTo(0.0); + } + + private com.fasterxml.jackson.databind.JsonNode record(String type, long block) { + var node = mapper.createObjectNode().put("type", type); + node.putObject("point").put("blockNumber", block); + return node; + } +} diff --git a/wallet-node-client/src/test/java/com/bloxbean/cardano/yano/wallet/nodeclient/WalletIndexLiveTest.java b/wallet-node-client/src/test/java/com/bloxbean/cardano/yano/wallet/nodeclient/WalletIndexLiveTest.java new file mode 100644 index 0000000..e1ad33e --- /dev/null +++ b/wallet-node-client/src/test/java/com/bloxbean/cardano/yano/wallet/nodeclient/WalletIndexLiveTest.java @@ -0,0 +1,205 @@ +package com.bloxbean.cardano.yano.wallet.nodeclient; + +import com.bloxbean.cardano.client.account.Account; +import com.bloxbean.cardano.client.api.model.Amount; +import com.bloxbean.cardano.client.backend.api.DefaultUtxoSupplier; +import com.bloxbean.cardano.client.backend.blockfrost.service.BFBackendService; +import com.bloxbean.cardano.client.common.model.Networks; +import com.bloxbean.cardano.client.function.helper.SignerProviders; +import com.bloxbean.cardano.client.quicktx.QuickTxBuilder; +import com.bloxbean.cardano.client.quicktx.Tx; +import com.bloxbean.cardano.client.transaction.spec.Asset; +import com.bloxbean.cardano.client.transaction.spec.script.ScriptPubkey; +import com.bloxbean.cardano.yano.wallet.core.service.HistoryPort.TxRef; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** Opt-in: launches only its own disposable devnet; never attaches to a user's node. */ +class WalletIndexLiveTest { + private static final String MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + private final ObjectMapper mapper = new ObjectMapper(); + private final HttpClient http = HttpClient.newHttpClient(); + private Path directory; + private Process node; + private String base; + private List command; + private int run; + + @Test void walletPersistsAssetsAndRecoversAfterNodeRestartCrashAndSnapshotReorg() throws Exception { + String artifact = System.getProperty("yano.wallet.live.artifact"); + String config = System.getProperty("yano.wallet.live.config"); + assumeTrue(artifact != null && config != null, "Set wallet live artifact and devnet config paths to opt in"); + directory = Files.createTempDirectory("yano-wallet-119-live-"); + System.out.println("Wallet live evidence: " + directory); + Account sender = new Account(Networks.testnet(), MNEMONIC, 0); + Account receiver = new Account(Networks.testnet(), "legal winner thank year wave sausage worth useful legal winner thank yellow", 0); + prepare(Path.of(artifact).toAbsolutePath(), Path.of(config).toAbsolutePath(), sender); + try { + start(); + var backend = new BFBackendService(base, "local-test"); + var supplier = new DefaultUtxoSupplier(backend.getUtxoService()); + assertThat(client().isAddressUsed(sender.baseAddress())).isTrue(); + assertThat(client().isAddressUsed(receiver.baseAddress())).isFalse(); + assertThat(history(receiver)).isEmpty(); + post("devnet/snapshot", mapper.createObjectNode().put("name", "before-transfers")); + + var policy = ScriptPubkey.createWithNewKey(); + Tx receive = new Tx().payToAddress(receiver.baseAddress(), Amount.ada(10)) + .mintAssets(policy._1, new Asset("Wallet119", BigInteger.valueOf(100)), receiver.baseAddress()) + .from(sender.baseAddress()); + var received = new QuickTxBuilder(backend).compose(receive) + .withSigner(SignerProviders.signerFrom(sender)) + .withSigner(SignerProviders.signerFrom(policy._2.getSkey())).complete(); + assertThat(received.isSuccessful()).as(received.getResponse()).isTrue(); + awaitUtxo(supplier, receiver.baseAddress(), received.getValue()); + assertThat(history(receiver)).extracting(TxRef::txHash).containsExactly(received.getValue()); + JsonNode saved = saved(); + assertThat(saved.path("current").path("outputs").size()).isPositive(); + assertThat(saved.toString()).contains("57616c6c6574313139"); + assertThat(sender.stakeAddress()).isNotEqualTo(receiver.stakeAddress()); + long firstSeen = get("addresses/" + receiver.baseAddress() + "/first-seen").path("firstSeenSlot").asLong(); + assertThat(firstSeen).isPositive(); + + stop(false); + start(); + assertThat(history(receiver)).extracting(TxRef::txHash).containsExactly(received.getValue()); + assertThat(saved().path("current").path("outputs")).isEqualTo(saved.path("current").path("outputs")); + Tx spend = new Tx().payToAddress(sender.baseAddress(), List.of(Amount.ada(8), + Amount.asset(policy._1.getPolicyId() + HexFormat.of().formatHex("Wallet119".getBytes(StandardCharsets.UTF_8)), 100))) + .from(receiver.baseAddress()).withChangeAddress(sender.baseAddress()); + var spent = new QuickTxBuilder(backend).compose(spend).feePayer(sender.baseAddress()) + .withSigner(SignerProviders.signerFrom(receiver)).withSigner(SignerProviders.signerFrom(sender)).complete(); + assertThat(spent.isSuccessful()).as(spent.getResponse()).isTrue(); + awaitUtxo(supplier, sender.baseAddress(), spent.getValue()); + assertThat(supplier.getAll(receiver.baseAddress())).isEmpty(); + // A newly constructed wallet port must recover its input ownership from disk. + assertThat(history(receiver)).extracting(TxRef::txHash).containsExactly(received.getValue(), spent.getValue()); + assertThat(saved().path("current").path("outputs").size()).isZero(); + assertThat(client().isAddressUsed(receiver.baseAddress())).isTrue(); + assertThat(get("addresses/" + receiver.baseAddress() + "/first-seen").path("firstSeenSlot").asLong()).isEqualTo(firstSeen); + + stop(true); + start(); + assertThat(history(receiver)).extracting(TxRef::txHash).containsExactly(received.getValue(), spent.getValue()); + post("devnet/restore/before-transfers", mapper.createObjectNode()); + assertThat(history(receiver)).isEmpty(); + assertThat(saved().path("current").path("outputs").size()).isZero(); + assertThat(client().isAddressUsed(receiver.baseAddress())).isFalse(); + + var replacement = new QuickTxBuilder(backend).compose(new Tx() + .payToAddress(receiver.baseAddress(), Amount.ada(11)).from(sender.baseAddress())) + .withSigner(SignerProviders.signerFrom(sender)).complete(); + assertThat(replacement.isSuccessful()).as(replacement.getResponse()).isTrue(); + awaitUtxo(supplier, receiver.baseAddress(), replacement.getValue()); + assertThat(history(receiver)).extracting(TxRef::txHash).containsExactly(replacement.getValue()); + assertThat(client().isAddressUsed(receiver.baseAddress())).isTrue(); + } finally { + stop(false); + } + } + + private void prepare(Path artifact, Path config, Account sender) throws Exception { + assertThat(artifact).isRegularFile(); + Path target = directory.resolve("config/network/devnet"); + Files.createDirectories(target); + try (var files = Files.list(config)) { + for (Path file : files.filter(Files::isRegularFile).toList()) Files.copy(file, target.resolve(file.getFileName())); + } + Path genesisFile = target.resolve("shelley-genesis.json"); + ObjectNode genesis = (ObjectNode) mapper.readTree(Files.readAllBytes(genesisFile)); + genesis.put("epochLength", 50).put("slotLength", 0.2); + genesis.withObject("initialFunds").put(HexFormat.of().formatHex(sender.getBaseAddress().getBytes()), 1_000_000_000L); + Files.write(genesisFile, mapper.writeValueAsBytes(genesis)); + int httpPort = freePort(); + int peerPort; + do { peerPort = freePort(); } while (peerPort == httpPort); + base = "http://127.0.0.1:" + httpPort + "/api/v1/"; + command = new ArrayList<>(); + boolean jar = artifact.toString().endsWith(".jar"); + command.add(jar ? Path.of(System.getProperty("java.home"), "bin/java").toString() : artifact.toString()); + command.addAll(List.of("-Xmx1g", "-Dquarkus.profile=devnet", "-Dquarkus.http.host=127.0.0.1", + "-Dquarkus.http.port=" + httpPort, "-Dyano.server.port=" + peerPort, + "-Dyano.scan.index.enabled=true", "-Dyano.address-first-seen.enabled=true", + "-Dyano.history.projection.enabled=false", "-Dyano.chain.block-body-prune-depth=0", + "-Dyano.plugins.enabled=false")); + if (jar) command.addAll(List.of("-jar", artifact.toString())); + Files.writeString(directory.resolve("command.txt"), String.join("\n", command)); + } + + private void start() throws Exception { + node = new ProcessBuilder(command).directory(directory.toFile()).redirectErrorStream(true) + .redirectOutput(directory.resolve("node-" + (++run) + ".log").toFile()).start(); + long deadline = System.nanoTime() + Duration.ofSeconds(90).toNanos(); + while (System.nanoTime() < deadline && node.isAlive()) { + try { + if (get("node/status").isObject() && client().isAddressUsed(new Account(Networks.testnet(), MNEMONIC, 0).baseAddress())) return; + } catch (Exception | AssertionError notReady) { /* wait for ledger and index readiness */ } + Thread.sleep(200); + } + throw new AssertionError("Devnet did not become ready; see " + directory); + } + + private void stop(boolean crash) throws Exception { + if (node == null || !node.isAlive()) return; + if (crash) node.destroyForcibly(); else node.destroy(); + if (!node.waitFor(30, TimeUnit.SECONDS)) { + node.destroyForcibly(); + assertThat(node.waitFor(10, TimeUnit.SECONDS)).isTrue(); + } + } + + private List history(Account account) { + YanoNodePorts ports = new YanoNodePorts(client()); + ports.enableScanHistory(directory.resolve("wallet-history")); + return ports.walletTransactions(account.stakeAddress(), account.baseAddress(), 1, 100, false); + } + private YanoNodeClient client() { return new YanoNodeClient(base, false); } + private JsonNode saved() throws Exception { + try (var files = Files.list(directory.resolve("wallet-history"))) { + return mapper.readTree(Files.readAllBytes(files.filter(p -> p.toString().endsWith(".json")).findFirst().orElseThrow())); + } + } + private void awaitUtxo(DefaultUtxoSupplier supplier, String address, String hash) throws Exception { + long deadline = System.nanoTime() + Duration.ofSeconds(30).toNanos(); + while (System.nanoTime() < deadline) { + if (supplier.getAll(address).stream().anyMatch(u -> u.getTxHash().equals(hash))) return; + Thread.sleep(100); + } + throw new AssertionError("Transaction not applied: " + hash); + } + private JsonNode get(String path) throws Exception { + var response = http.send(HttpRequest.newBuilder(URI.create(base + path)).timeout(Duration.ofSeconds(5)) + .GET().build(), HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).as(response.body()).isEqualTo(200); + return mapper.readTree(response.body()); + } + private void post(String path, JsonNode body) throws Exception { + var response = http.send(HttpRequest.newBuilder(URI.create(base + path)).timeout(Duration.ofSeconds(60)) + .header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(body.toString())).build(), + HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).as(response.body()).isEqualTo(200); + } + private static int freePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { return socket.getLocalPort(); } + } +} diff --git a/wallet-node-client/src/test/java/com/bloxbean/cardano/yano/wallet/nodeclient/WalletScanHistoryTest.java b/wallet-node-client/src/test/java/com/bloxbean/cardano/yano/wallet/nodeclient/WalletScanHistoryTest.java new file mode 100644 index 0000000..247d956 --- /dev/null +++ b/wallet-node-client/src/test/java/com/bloxbean/cardano/yano/wallet/nodeclient/WalletScanHistoryTest.java @@ -0,0 +1,283 @@ +package com.bloxbean.cardano.yano.wallet.nodeclient; + +import com.bloxbean.cardano.client.address.Address; +import com.bloxbean.cardano.yano.wallet.core.service.HistoryPort.HistoryNotSupportedException; +import com.bloxbean.cardano.yano.wallet.core.service.HistoryPort.TxRef; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HexFormat; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class WalletScanHistoryTest { + private final ObjectMapper mapper = new ObjectMapper(); + private static final String STAKE = new Address(HexFormat.of().parseHex("e0" + "01".repeat(28))).toBech32(); + private static final String ADDRESS = new Address(HexFormat.of().parseHex("00" + "02".repeat(28) + "01".repeat(28))).toBech32(); + @TempDir Path directory; + + @Test void restartSuppliesPersistedAssetsAndFindsOutgoingOnlyHistory() throws Exception { + try (StubYanoNode node = new StubYanoNode()) { + node.on("/api/v1/scan", stream(1, transaction(1, false))); + assertThat(history(node).transactions(STAKE, 1, 10, false)).extracting(TxRef::txHash).containsExactly(hash(1)); + node.on("/api/v1/scan", stream(2, transaction(2, true))); + assertThat(history(node).transactions(STAKE, 1, 10, false)).extracting(TxRef::txHash).containsExactly(hash(1), hash(2)); + JsonNode request = mapper.readTree(node.requests().getLast().body()); + assertThat(request.path("after")).isEqualTo(point(1)); + assertThat(request.path("knownOutputs").size()).isEqualTo(1); + assertThat(request.path("knownOutputs").get(0).path("assets").get(0).path("quantity").asInt()).isEqualTo(7); + assertThat(saved().path("current").path("outputs").size()).isZero(); + } + } + + @Test void incompleteStreamDoesNotReplaceSavedState() throws Exception { + try (StubYanoNode node = new StubYanoNode()) { + node.on("/api/v1/scan", stream(1, transaction(1, false))); + history(node).transactions(STAKE, 1, 10, false); + JsonNode before = saved(); + node.on("/api/v1/scan", ready(2) + transaction(2, true)); + assertThatThrownBy(() -> history(node).transactions(STAKE, 1, 10, false)) + .isInstanceOf(NodeClientException.class).hasMessageContaining("without a complete done"); + assertThat(saved()).isEqualTo(before); + } + } + + @Test void reorgRestoresPreviousBoundaryIncludingUnspentOutput() throws Exception { + try (StubYanoNode node = new StubYanoNode()) { + node.on("/api/v1/scan", stream(1, transaction(1, false))); + history(node).transactions(STAKE, 1, 10, false); + node.on("/api/v1/scan", stream(2, transaction(2, true))); + history(node).transactions(STAKE, 1, 10, false); + AtomicInteger attempt = new AtomicInteger(); + node.on("/api/v1/scan", request -> attempt.getAndIncrement() == 0 + ? new StubYanoNode.Response(409, "application/json", "{}") + : StubYanoNode.Response.json(stream(3, ""))); + assertThat(history(node).transactions(STAKE, 1, 10, false)).extracting(TxRef::txHash).containsExactly(hash(1)); + JsonNode resumed = mapper.readTree(node.requests().getLast().body()); + assertThat(resumed.path("after")).isEqualTo(point(1)); + assertThat(resumed.path("knownOutputs").size()).isEqualTo(1); + assertThat(saved().path("current").path("outputs").size()).isEqualTo(1); + } + } + + @Test void deeperReorgRestartsAtOriginWithEmptyState() throws Exception { + try (StubYanoNode node = new StubYanoNode()) { + node.on("/api/v1/scan", stream(1, transaction(1, false))); + history(node).transactions(STAKE, 1, 10, false); + node.on("/api/v1/scan", stream(2, transaction(2, true))); + history(node).transactions(STAKE, 1, 10, false); + AtomicInteger attempt = new AtomicInteger(); + node.on("/api/v1/scan", request -> attempt.getAndIncrement() < 2 + ? new StubYanoNode.Response(409, "application/json", "{}") + : StubYanoNode.Response.json(stream(3, ""))); + assertThat(history(node).transactions(STAKE, 1, 10, false)).isEmpty(); + JsonNode resumed = mapper.readTree(node.requests().getLast().body()); + assertThat(resumed.path("after").path("blockNumber").asInt()).isEqualTo(-1); + assertThat(resumed.path("knownOutputs").size()).isZero(); + } + } + + @Test void mismatchedDoneOrPostDoneDataCannotCommitHistory() throws Exception { + try (StubYanoNode node = new StubYanoNode()) { + node.on("/api/v1/scan", ready(2) + event("done", 1)); + assertThatThrownBy(() -> history(node).transactions(STAKE, 1, 10, false)) + .isInstanceOf(NodeClientException.class).hasMessageContaining("completion point mismatch"); + node.on("/api/v1/scan", stream(1, "") + transaction(1, false)); + assertThatThrownBy(() -> history(node).transactions(STAKE, 1, 10, false)) + .isInstanceOf(NodeClientException.class).hasMessageContaining("after completion"); + try (var files = Files.list(directory)) { assertThat(files).isEmpty(); } + } + } + + @Test void malformedOrOutOfRangeTransactionCannotCommitHistory() throws Exception { + try (StubYanoNode node = new StubYanoNode()) { + node.on("/api/v1/scan", stream(1, transaction(2, false))); + assertThatThrownBy(() -> history(node).transactions(STAKE, 1, 10, false)) + .isInstanceOf(NodeClientException.class).hasMessageContaining("scan point"); + ObjectNode malformed = (ObjectNode) mapper.readTree(transaction(1, false)); + ((ObjectNode) malformed.path("point")).remove("slot"); + node.on("/api/v1/scan", stream(1, malformed + "\n")); + assertThatThrownBy(() -> history(node).transactions(STAKE, 1, 10, false)) + .isInstanceOf(NodeClientException.class).hasMessageContaining("outside scan interval"); + try (var files = Files.list(directory)) { assertThat(files).isEmpty(); } + } + } + + @Test void matchingTransactionMayAlsoPayAnUnrelatedByronAddress() throws Exception { + try (StubYanoNode node = new StubYanoNode()) { + ObjectNode record = (ObjectNode) mapper.readTree(transaction(1, false)); + record.withArray("outputs").addObject() + .put("address", "Ae2tdPwUPEZ3DdaWu8jn553npu6jwEPAJiahruj3xQjPXxgoxfYDWusJz7x") + .put("lovelace", 1000000).putObject("outpoint").put("txHash", hash(1)).put("index", 1); + node.on("/api/v1/scan", stream(1, record + "\n")); + assertThat(history(node).transactions(STAKE, 1, 10, false)).extracting(TxRef::txHash).containsExactly(hash(1)); + assertThat(saved().path("current").path("outputs").size()).isEqualTo(1); + } + } + + @Test void aNodeThatCannotServeScansIsTreatedAsHavingNoIndex() throws Exception { + // 503, not 404: pre16 carries the scan route but answers 503 when the + // index or UTxO state is off, or the index needs a fresh sync. Reported + // as an error it would fill the History screen with a failure on every + // refresh instead of falling back (ADR-043). A chainstate synced before + // the index existed answers 400 instead — see the origin-scan tests. + try (StubYanoNode node = new StubYanoNode()) { + node.on("/api/v1/scan", request -> new StubYanoNode.Response(503, "application/json", + "{\"error\":\"Wallet scan requires UTxO state\"}")); + assertThatThrownBy(() -> history(node).transactions(STAKE, 1, 10, false)) + .isInstanceOf(HistoryNotSupportedException.class) + .hasMessageContaining("UTxO state"); + + node.on("/api/v1/scan", request -> new StubYanoNode.Response(503, "application/json", + "{\"error\":\"Index disabled\",\"coverage\":{\"enabled\":false,\"completeFromOrigin\":false}}")); + assertThatThrownBy(() -> history(node).transactions(STAKE, 1, 10, false)) + .isInstanceOf(HistoryNotSupportedException.class); + + // Enabled but never initialised: the node's own remedy is a fresh sync. + node.on("/api/v1/scan", request -> new StubYanoNode.Response(503, "application/json", + "{\"error\":\"Index missing; enable before a fresh sync\"," + + "\"coverage\":{\"enabled\":true,\"completeFromOrigin\":false}}")); + assertThatThrownBy(() -> history(node).transactions(STAKE, 1, 10, false)) + .isInstanceOf(HistoryNotSupportedException.class) + .hasMessageContaining("fresh sync"); + } + } + + @Test void aBusyNodeStaysAnErrorRatherThanLosingItsHistory() throws Exception { + // Each of these passes. Answering one with "this node keeps no history" + // would swap the node's real history for the local list — hiding + // transactions behind a permanent-sounding answer to a temporary state. + // The last two carry a coverage object and name the index, which is + // exactly why neither can decide the question on its own. + for (String body : new String[] { + "{\"error\":\"Scan concurrency limit reached\"}", + "{\"error\":\"Index is not at the applied canonical point\"," + + "\"coverage\":{\"enabled\":true,\"completeFromOrigin\":true}}", + "{\"error\":\"Contributor storage is closed or being replaced\"," + + "\"coverage\":{\"enabled\":true,\"completeFromOrigin\":false}}"}) { + try (StubYanoNode node = new StubYanoNode()) { + node.on("/api/v1/scan", request -> new StubYanoNode.Response(503, "application/json", body)); + assertThatThrownBy(() -> history(node).transactions(STAKE, 1, 10, false)) + .as(body) + .isInstanceOf(NodeClientException.class) + .isNotInstanceOf(HistoryNotSupportedException.class) + .hasMessageContaining("HTTP 503"); + } + } + } + + @Test void anIndexStartedAfterGenesisFallsBackForAScanFromOrigin() throws Exception { + // What every chainstate upgraded to an index-carrying node gets: the index + // starts at the block after it was switched on, so a scan from origin is + // refused with 400 on every refresh. There is no history to read — it + // falls back to the local list (ADR-043) instead of erroring forever. + try (StubYanoNode node = new StubYanoNode()) { + node.on("/api/v1/scan", request -> new StubYanoNode.Response(400, "application/json", + "{\"error\":\"Requested range is outside complete filter coverage\"}")); + assertThatThrownBy(() -> history(node).transactions(STAKE, 1, 10, false)) + .isInstanceOf(HistoryNotSupportedException.class) + .hasMessageContaining("start of the chain"); + assertThat(mapper.readTree(node.requests().getLast().body()).path("after").path("blockNumber").asInt()) + .isEqualTo(-1); + } + } + + @Test void anIndexBehindASavedCursorStaysAnError() throws Exception { + // Same 400, but from a cursor this node itself handed out: an index + // trailing it says the same thing and catches up. Falling back would + // replace the scanned history with the local list in the meantime. + try (StubYanoNode node = new StubYanoNode()) { + node.on("/api/v1/scan", stream(1, transaction(1, false))); + history(node).transactions(STAKE, 1, 10, false); + JsonNode before = saved(); + node.on("/api/v1/scan", request -> new StubYanoNode.Response(400, "application/json", + "{\"error\":\"Requested range is outside complete filter coverage\"}")); + assertThatThrownBy(() -> history(node).transactions(STAKE, 1, 10, false)) + .isInstanceOf(NodeClientException.class) + .isNotInstanceOf(HistoryNotSupportedException.class) + .hasMessageContaining("outside complete filter coverage"); + assertThat(saved()).isEqualTo(before); + } + } + + @Test void anyOtherRefusalOfAScanFromOriginStaysAnError() throws Exception { + // A malformed request is the wallet's bug, not the node's missing index. + try (StubYanoNode node = new StubYanoNode()) { + node.on("/api/v1/scan", request -> new StubYanoNode.Response(400, "application/json", + "{\"error\":\"Unsupported scan version\"}")); + assertThatThrownBy(() -> history(node).transactions(STAKE, 1, 10, false)) + .isInstanceOf(NodeClientException.class) + .isNotInstanceOf(HistoryNotSupportedException.class) + .hasMessageContaining("Unsupported scan version"); + } + } + + @Test void progressIsReportedOnlyWhileAScanIsRunning() throws Exception { + try (StubYanoNode node = new StubYanoNode()) { + WalletScanHistory history = history(node); + assertThat(history.progress()).isEmpty(); + + node.on("/api/v1/scan", stream(1, transaction(1, false))); + history.transactions(STAKE, 1, 10, false); + // A finished scan is not progress: its rows are about to be shown. + assertThat(history.progress()).isEmpty(); + + // And a scan that fails must not leave a percentage frozen on screen + // claiming work is still going on. + node.on("/api/v1/scan", ready(2) + transaction(2, true)); + assertThatThrownBy(() -> history.transactions(STAKE, 1, 10, false)) + .isInstanceOf(NodeClientException.class); + assertThat(history.progress()).isEmpty(); + } + } + + private WalletScanHistory history(StubYanoNode node) { + return new WalletScanHistory(new YanoNodeClient(node.baseUrl(), false), directory); + } + + private JsonNode saved() throws Exception { + try (var files = Files.list(directory)) { + return mapper.readTree(Files.readAllBytes(files.filter(p -> p.toString().endsWith(".json")).findFirst().orElseThrow())); + } + } + + private ObjectNode point(int block) { + return mapper.createObjectNode().put("blockNumber", block).put("slot", block * 10).put("blockHash", hash(block)); + } + + private String ready(int block) { + ObjectNode record = mapper.createObjectNode().put("type", "ready"); + record.set("point", point(block)); + record.putObject("coverage").put("identity", "test-genesis"); + return record + "\n"; + } + + private String event(String type, int block) { + ObjectNode record = mapper.createObjectNode().put("type", type); + record.set("point", point(block)); + return record + "\n"; + } + + private String stream(int block, String data) { return ready(block) + data + event("done", block); } + + private String transaction(int block, boolean spend) { + ObjectNode record = mapper.createObjectNode().put("type", "transaction").put("txHash", hash(block)).put("blockTime", block * 10); + record.set("point", point(block)); + if (spend) record.putArray("inputs").addObject().put("txHash", hash(1)).put("index", 0); + else { + ObjectNode output = record.putArray("outputs").addObject().put("address", ADDRESS).put("lovelace", 1000000); + output.putObject("outpoint").put("txHash", hash(block)).put("index", 0); + output.putArray("assets").addObject().put("unit", "ab".repeat(28)).put("quantity", 7); + } + return record + "\n"; + } + + private static String hash(int value) { return "%02x".formatted(value).repeat(32); } +} diff --git a/wallet-node-client/src/test/java/com/bloxbean/cardano/yano/wallet/nodeclient/YanoMempoolViewTest.java b/wallet-node-client/src/test/java/com/bloxbean/cardano/yano/wallet/nodeclient/YanoMempoolViewTest.java new file mode 100644 index 0000000..fa091c1 --- /dev/null +++ b/wallet-node-client/src/test/java/com/bloxbean/cardano/yano/wallet/nodeclient/YanoMempoolViewTest.java @@ -0,0 +1,69 @@ +package com.bloxbean.cardano.yano.wallet.nodeclient; + +import com.bloxbean.cardano.client.address.Address; +import com.bloxbean.cardano.client.api.common.OrderEnum; +import com.bloxbean.cardano.client.api.model.Utxo; +import com.bloxbean.cardano.yano.wallet.core.config.WalletNetwork; +import org.junit.jupiter.api.Test; + +import java.util.HexFormat; + +import static com.bloxbean.cardano.yano.wallet.nodeclient.PendingInputsTest.*; +import static org.assertj.core.api.Assertions.*; + +class YanoMempoolViewTest { + @Test void overlayPagesIncludePendingOutputsAndExcludeInputsUnknownToLocalTracker() throws Exception { + try (var node = new StubYanoNode()) { + String pending = "22".repeat(32); + node.on(ROUTE, r -> { + if (!r.path().contains("include_mempool=true")) { + return StubYanoNode.Response.json(r.path().contains("page=1&") ? utxos(0, 1) : "[]"); + } + String rows = r.path().contains("page=1&") ? utxos(0, 100) + : r.path().contains("page=2&") ? utxos(100, 101) : "[]"; + return StubYanoNode.Response.json(rows.replace(HASH, pending) + .replace("\"amount\":", "\"block\":null,\"amount\":")); + }); + var backend = YanoNodeBackend.connect(WalletNetwork.PREPROD, node.baseUrl()); + assertThat(backend.selectionUtxoSupplier().getAll(ADDRESS)).hasSize(101) + .allMatch(u -> pending.equals(u.getTxHash())); + assertThat(backend.utxoSupplier().getAll(ADDRESS)).extracting(Utxo::getTxHash).containsExactly(HASH); + assertThat(node.requests().stream().filter(r -> r.path().contains("include_mempool"))) + .hasSize(3).allMatch(r -> r.path().contains("count=100&order=asc&include_mempool=true")); + } + } + + @Test void credentialsPreservePageCountAndOrderAndSingleOutputUsesOverlay() throws Exception { + try (var node = new StubYanoNode()) { + String credential = HexFormat.of().formatHex(new Address(ADDRESS).getPaymentCredentialHash().orElseThrow()); + String route = "/api/v1/credentials/" + credential + "/utxos"; + node.on(route, utxos(5, 6)); + node.on("/api/v1/utxos/" + HASH + "/5", utxos(5, 6).substring(1, utxos(5, 6).length() - 1)); + var backend = YanoNodeBackend.connect(WalletNetwork.PREPROD, node.baseUrl()); + backend.selectionUtxoSupplier().setSearchByAddressVkh(true); + assertThat(backend.selectionUtxoSupplier().getPage(ADDRESS, 2, 3, OrderEnum.desc)) + .extracting(Utxo::getOutputIndex).containsExactly(5); + assertThat(node.requests().getFirst().path()).isEqualTo(route + "?page=4&count=2&order=desc&include_mempool=true"); + assertThat(backend.selectionUtxoSupplier().getTxOutput(HASH, 5)).isPresent(); + assertThat(backend.selectionUtxoSupplier().getTxOutput(HASH, 6)).isEmpty(); + assertThat(node.requests()).allMatch(r -> r.path().contains("include_mempool=true")); + } + } + + @Test void unavailableAndMalformedOverlayNeverFallBackToConfirmedListings() throws Exception { + try (var node = new StubYanoNode()) { + var backend = YanoNodeBackend.connect(WalletNetwork.PREPROD, node.baseUrl()); + for (int code : new int[]{404, 503}) { + node.on(ROUTE, r -> new StubYanoNode.Response(code, "application/json", "{\"error\":\"unavailable\"}")); + assertThatThrownBy(() -> backend.selectionUtxoSupplier().getAll(ADDRESS)).isInstanceOf(NodeClientException.class); + } + node.on(ROUTE, "[{}]"); + assertThatThrownBy(() -> backend.selectionUtxoSupplier().getAll(ADDRESS)).isInstanceOf(NodeClientException.class); + node.on(ROUTE, utxos(0, 1).replace("10000000", "not-a-number")); + assertThatThrownBy(() -> backend.selectionUtxoSupplier().getAll(ADDRESS)).isInstanceOf(NodeClientException.class); + node.on("/api/v1/utxos/" + HASH + "/5", utxos(0, 1).substring(1, utxos(0, 1).length() - 1)); + assertThatThrownBy(() -> backend.selectionUtxoSupplier().getTxOutput(HASH, 5)).isInstanceOf(NodeClientException.class); + assertThat(node.requests()).allMatch(r -> r.path().contains("include_mempool=true")); + } + } +} diff --git a/wallet-node-client/src/test/java/com/bloxbean/cardano/yano/wallet/nodeclient/YanoNodeBackendTest.java b/wallet-node-client/src/test/java/com/bloxbean/cardano/yano/wallet/nodeclient/YanoNodeBackendTest.java index a89cdb7..7ee50ab 100644 --- a/wallet-node-client/src/test/java/com/bloxbean/cardano/yano/wallet/nodeclient/YanoNodeBackendTest.java +++ b/wallet-node-client/src/test/java/com/bloxbean/cardano/yano/wallet/nodeclient/YanoNodeBackendTest.java @@ -15,6 +15,7 @@ import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * Verifies the cardano-client-lib backend wiring against a stub that serves @@ -131,6 +132,80 @@ void utxoSupplierPagesThroughYanoUtxos() { .isEqualTo(new BigInteger("5000000000")); } + @Test + void historyDistinguishesUsedUnusedAndUnavailable() { + var backend = YanoNodeBackend.connect(WalletNetwork.MAINNET, stub.baseUrl()); + var address = com.bloxbean.cardano.hdwallet.Wallet.create( + com.bloxbean.cardano.client.common.model.Networks.mainnet()).getBaseAddress(0); + String path = "/api/v1/addresses/" + address.toBech32() + "/transactions"; + stub.on(path, "[{\"tx_hash\":\"abc\"}]"); + assertThat(backend.utxoSupplier().isUsedAddress(address)).isTrue(); + stub.on(path, "[]"); + assertThat(backend.utxoSupplier().isUsedAddress(address)).isFalse(); + for (int status : new int[]{404, 500, 503}) { + stub.on(path, req -> new StubYanoNode.Response(status, "application/json", "{}")); + assertThatThrownBy(() -> backend.utxoSupplier().isUsedAddress(address)) + .hasMessageContaining("history unavailable"); + } + stub.on(path, "{}"); + assertThatThrownBy(() -> backend.utxoSupplier().isUsedAddress(address)) + .hasMessageContaining("expected an array"); + } + + @Test + void failedLaterUtxoPageDoesNotReturnPartialFunds() { + stub.on("/api/v1/addresses/" + ADDRESS + "/utxos", req -> + req.path().contains("page=1") ? StubYanoNode.Response.json(UTXOS_PAGE_1) + : new StubYanoNode.Response(503, "application/json", "{}")); + var backend = YanoNodeBackend.connect(WalletNetwork.MAINNET, stub.baseUrl()); + assertThatThrownBy(() -> backend.utxoSupplier().getAll(ADDRESS)) + .hasMessageContaining("UTxO lookup failed"); + } + + @Test + void blockfrostStoreCanReturnNotFoundForUnusedAddress() { + var client = new YanoNodeClient(stub.baseUrl(), true); + assertThat(client.isAddressUsed(ADDRESS)).isFalse(); + } + + @Test + void missingHistoryRouteStillReturnsExplicitlyPartialBalanceFromUtxoEndpoints() { + var wallet = com.bloxbean.cardano.hdwallet.Wallet.create( + com.bloxbean.cardano.client.common.model.Networks.mainnet()); + for (int role = 0; role <= 1; role++) { + for (int index = 0; index < 4; index++) { + String address = com.bloxbean.cardano.yano.wallet.core.wallet.WalletAddresses + .baseAddress(wallet, role, index).toBech32(); + boolean funded = role == 1 && index == 3; + stub.on("/api/v1/addresses/" + address + "/utxos", req -> + funded && req.path().contains("page=1") + ? StubYanoNode.Response.json(UTXOS_PAGE_1.replace(ADDRESS, address)) + : StubYanoNode.Response.json("[]")); + } + } + var backend = YanoNodeBackend.connect(WalletNetwork.MAINNET, stub.baseUrl()); + var balance = new com.bloxbean.cardano.yano.wallet.core.wallet.WalletBalanceService() + .scan(wallet, backend.utxoSupplier(), 2, 4); + assertThat(balance.complete()).isFalse(); + assertThat(balance.utxoCount()).isEqualTo(2); + assertThat(balance.lovelace()).isEqualTo(new BigInteger("5001500000")); + assertThat(balance.addressCount()).isEqualTo(8); + assertThat(stub.requests().stream().filter(req -> req.path().contains("/transactions"))).hasSize(1); + } + + @Test + void explicitlyDisabledIndexIsDistinguishedFromServerFailure() { + String path = "/api/v1/addresses/" + ADDRESS + "/transactions"; + var client = new YanoNodeClient(stub.baseUrl()); + stub.on(path, req -> new StubYanoNode.Response(503, "application/json", + "{\"error\":\"Address transaction history is unavailable or not selected\"}")); + assertThatThrownBy(() -> client.isAddressUsed(ADDRESS)) + .isInstanceOf(com.bloxbean.cardano.yano.wallet.core.service.HistoryPort.HistoryNotSupportedException.class); + stub.on(path, req -> new StubYanoNode.Response(503, "application/json", + "{\"error\":\"Address history read failed\"}")); + assertThatThrownBy(() -> client.isAddressUsed(ADDRESS)).isInstanceOf(NodeClientException.class); + } + @Test void protocolParamsSupplierReadsEpochParameters() { // CCL's BF backend resolves the latest epoch number first, then fetches its parameters. @@ -152,7 +227,8 @@ void submitsRawCborToTxSubmit() throws Exception { stub.on("/api/v1/tx/submit", req -> StubYanoNode.Response.json("\"" + txHash + "\"")); YanoNodeBackend backend = YanoNodeBackend.connect(WalletNetwork.DEVNET, stub.baseUrl()); - Result result = backend.transactionProcessor().submitTransaction(new byte[]{(byte) 0x84, 0x01, 0x02}); + byte[] cbor = PendingInputsTest.tx(1); + Result result = backend.transactionProcessor().submitTransaction(cbor); assertThat(result.isSuccessful()).isTrue(); assertThat(result.getValue()).isEqualTo(txHash); @@ -161,7 +237,7 @@ void submitsRawCborToTxSubmit() throws Exception { .findFirst().orElseThrow(); assertThat(submit.method()).isEqualTo("POST"); assertThat(submit.contentType()).contains("application/cbor"); - assertThat(submit.body()).containsExactly((byte) 0x84, 0x01, 0x02); + assertThat(submit.body()).containsExactly(cbor); } @Test diff --git a/wallet-node-launcher/src/main/java/com/bloxbean/cardano/yano/wallet/launcher/ManagedNode.java b/wallet-node-launcher/src/main/java/com/bloxbean/cardano/yano/wallet/launcher/ManagedNode.java index 3b69f55..f10339d 100644 --- a/wallet-node-launcher/src/main/java/com/bloxbean/cardano/yano/wallet/launcher/ManagedNode.java +++ b/wallet-node-launcher/src/main/java/com/bloxbean/cardano/yano/wallet/launcher/ManagedNode.java @@ -340,6 +340,27 @@ public boolean isReachable() { } private Process spawn() throws IOException { + List command = buildCommand(); + Files.createDirectories(spec.logFile().toAbsolutePath().getParent()); + // Put the settings where the user already looks when a node misbehaves: + // the folder holding node.log. Entirely commented out, so it documents + // the defaults without changing them. + NodeOptions.writeTemplateIfMissing(spec.chainstateDir().toAbsolutePath().getParent()); + ProcessBuilder builder = new ProcessBuilder(command) + .directory(spec.workingDir().toFile()) + .redirectErrorStream(true) + .redirectOutput(spec.logFile().toFile()); + log.info("Starting managed node: {} (workingDir={}, http={}, chainstate={}, maxHeap={})", + spec.nodeJar().getFileName(), spec.workingDir(), spec.httpPort(), spec.chainstateDir(), + spec.maxHeap()); + return builder.start(); + } + + /** + * The exact command line, split out so the ordering rules below can be + * tested without starting a node. + */ + List buildCommand() { List command = new ArrayList<>(); // System-property overrides (Quarkus/MicroProfile config beats application.yml): // custom REST + N2N ports and an isolated chainstate keep the managed @@ -349,6 +370,10 @@ private Process spawn() throws IOException { // binary too. if (spec.nativeBinary()) { command.add(spec.nodeJar().toString()); + // A native image accepts -Xmx at runtime, and yano.sh sizes the binary + // exactly this way. Without it the node's heap is unbounded relative + // to the sizing profile we select for its caches. + command.add("-Xmx" + spec.maxHeap()); addSysProp(command, "quarkus.profile", spec.quarkusProfile()); addSysProp(command, "quarkus.http.port", String.valueOf(spec.httpPort())); addSysProp(command, "yano.server.port", String.valueOf(spec.n2nPort())); @@ -356,6 +381,10 @@ private Process spawn() throws IOException { addUpstreamRelays(command); } else { command.add(spec.javaExecutable()); + // Like the -D flags, this MUST precede -jar: after it, the JVM reads + // it as a program argument and silently ignores it. The JVM reads no + // JAVA_OPTS of its own, so this is the only channel. + command.add("-Xmx" + spec.maxHeap()); addSysProp(command, "quarkus.profile", spec.quarkusProfile()); addSysProp(command, "quarkus.http.port", String.valueOf(spec.httpPort())); addSysProp(command, "yano.server.port", String.valueOf(spec.n2nPort())); @@ -364,15 +393,7 @@ private Process spawn() throws IOException { command.add("-jar"); command.add(spec.nodeJar().toString()); } - - Files.createDirectories(spec.logFile().toAbsolutePath().getParent()); - ProcessBuilder builder = new ProcessBuilder(command) - .directory(spec.workingDir().toFile()) - .redirectErrorStream(true) - .redirectOutput(spec.logFile().toFile()); - log.info("Starting managed node: {} (workingDir={}, http={}, chainstate={})", - spec.nodeJar().getFileName(), spec.workingDir(), spec.httpPort(), spec.chainstateDir()); - return builder.start(); + return command; } /** diff --git a/wallet-node-launcher/src/main/java/com/bloxbean/cardano/yano/wallet/launcher/NodeLaunchSpec.java b/wallet-node-launcher/src/main/java/com/bloxbean/cardano/yano/wallet/launcher/NodeLaunchSpec.java index e6258e1..af2b356 100644 --- a/wallet-node-launcher/src/main/java/com/bloxbean/cardano/yano/wallet/launcher/NodeLaunchSpec.java +++ b/wallet-node-launcher/src/main/java/com/bloxbean/cardano/yano/wallet/launcher/NodeLaunchSpec.java @@ -28,6 +28,16 @@ public record NodeLaunchSpec( public static final int DEFAULT_HTTP_PORT = 8090; public static final int DEFAULT_N2N_PORT = 13400; + /** + * Sizing the user can change by hand in {@code node.properties} beside + * {@code node.log}; see {@link NodeOptions}. Read once per spec so a running + * node keeps the values it started with, and an edit takes effect on the + * next start rather than halfway through a sync. + */ + private NodeOptions options() { + return NodeOptions.load(chainstateDir.getParent()); + } + public NodeLaunchSpec { Objects.requireNonNull(network, "network is required"); Objects.requireNonNull(nodeJar, "nodeJar is required"); @@ -52,14 +62,33 @@ public String baseUrl() { } /** - * Quarkus profiles for the managed node: the network profile plus the - * {@code wallet} profile so the wallet APIs (address/tx/reward history, - * address-tx index) are enabled. The devnet profile already turns these on, - * but preprod/mainnet/preview do not — without the wallet profile a managed - * real-network node would serve balances but no history/rewards. Later - * profiles win on conflict, so {@code wallet} is listed last. + * Quarkus profiles for the managed node: the network profile, then + * {@code medium}, then {@code wallet}. + * + *

{@code wallet} enables what this wallet reads — the scan index, + * address-first-seen and the UTxO state. The devnet profile turns those on + * by itself, but preprod/mainnet/preview do not, so without it a managed + * real-network node serves balances and no history. + * + *

The sizing profile — {@code medium} unless {@code node.properties} says + * otherwise — sizes the node for the desktop it is sharing: RocksDB caches, + * open files and the decoded-block queue budget. Note it carries no heap of + * its own; {@code yano.sh} pairs each profile with an {@code -Xmx} and this + * launcher spawns the node directly, so {@link #maxHeap()} supplies it. + * + *

Later profiles win on conflict, so {@code wallet} stays last: its + * switches must not be overridden by a sizing profile, whoever chose it. */ public String quarkusProfile() { - return network.id() + ",wallet"; + return network.id() + "," + options().sizingProfile() + ",wallet"; + } + + /** + * The {@code -Xmx} to launch with. Accepted by both the jar and the native + * binary — a native image reads {@code -Xmx}, {@code -Xms} and {@code -Xss} + * at runtime, which is how {@code yano.sh} sizes it too. + */ + public String maxHeap() { + return options().maxHeap(); } } diff --git a/wallet-node-launcher/src/main/java/com/bloxbean/cardano/yano/wallet/launcher/NodeOptions.java b/wallet-node-launcher/src/main/java/com/bloxbean/cardano/yano/wallet/launcher/NodeOptions.java new file mode 100644 index 0000000..0debfb4 --- /dev/null +++ b/wallet-node-launcher/src/main/java/com/bloxbean/cardano/yano/wallet/launcher/NodeOptions.java @@ -0,0 +1,153 @@ +package com.bloxbean.cardano.yano.wallet.launcher; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.Reader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import java.util.regex.Pattern; + +/** + * Node sizing the user can change without a rebuild: {@code node.properties} in + * the managed node's own directory, beside {@code node.log}. + * + *

The wallet launches the node itself rather than through {@code yano.sh}, so + * none of that script's {@code JAVA_OPTS} handling applies and the defaults here + * are the only sizing the node gets. Everything the script decides from a + * resource profile is therefore mirrored: the profile name and the heap that + * goes with it. + * + *

Every value is optional and a bad one is ignored rather than fatal — this + * file is edited by hand, and a typo in it must not leave the wallet unable to + * start its node. A rejected value is logged with what was used instead. + */ +record NodeOptions(String sizingProfile, String maxHeap) { + private static final Logger log = LoggerFactory.getLogger(NodeOptions.class); + + static final String FILE_NAME = "node.properties"; + static final String PROFILE_KEY = "sizingProfile"; + static final String MAX_HEAP_KEY = "maxHeap"; + + /** Dev/CI escape hatch, checked before the file so a {@code -D} run needs no edit. */ + static final String MAX_HEAP_PROPERTY = "yano.wallet.node.max-heap"; + static final String PROFILE_PROPERTY = "yano.wallet.node.sizing-profile"; + + static final String DEFAULT_PROFILE = "medium"; + + /** yano.sh's own mapping (yano.sh:355-373), so the two cannot disagree. */ + private static final Map HEAP_BY_PROFILE = Map.of( + "xsmall", "384m", + "small", "384m", + "medium", "1536m", + "large", "2g"); + + /** What the JVM and a native image both accept: digits with an optional unit. */ + private static final Pattern HEAP = Pattern.compile("\\d+[kKmMgG]?"); + + static NodeOptions defaults() { + return new NodeOptions(DEFAULT_PROFILE, HEAP_BY_PROFILE.get(DEFAULT_PROFILE)); + } + + /** + * Reads {@code /node.properties}, falling back to the defaults for + * anything absent or unusable. An unreadable file is not an error: a managed + * node that refuses to start because of a stray character in an optional + * settings file would be a worse failure than ignoring it. + */ + static NodeOptions load(Path nodeDir) { + Properties properties = new Properties(); + Path file = nodeDir == null ? null : nodeDir.resolve(FILE_NAME); + if (file != null && Files.isReadable(file)) { + try (Reader reader = Files.newBufferedReader(file)) { + properties.load(reader); + } catch (IOException | IllegalArgumentException unreadable) { + log.warn("Ignoring {}: {}. Using default node sizing.", file, unreadable.toString()); + } + } + String profile = profile(value(PROFILE_PROPERTY, properties, PROFILE_KEY), file); + return new NodeOptions(profile, heap(value(MAX_HEAP_PROPERTY, properties, MAX_HEAP_KEY), + HEAP_BY_PROFILE.get(profile), file)); + } + + private static String value(String systemProperty, Properties properties, String key) { + String override = System.getProperty(systemProperty); + return override != null && !override.isBlank() ? override : properties.getProperty(key); + } + + private static String profile(String requested, Path file) { + if (requested == null || requested.isBlank()) { + return DEFAULT_PROFILE; + } + String name = requested.trim().toLowerCase(Locale.ROOT); + if (!HEAP_BY_PROFILE.containsKey(name)) { + log.warn("{} in {} names no known sizing profile ({}); using {}.", + name, file, HEAP_BY_PROFILE.keySet(), DEFAULT_PROFILE); + return DEFAULT_PROFILE; + } + return name; + } + + /** + * An explicit heap wins over the profile's, matching {@code yano.sh}, where + * an {@code -Xmx} in {@code JAVA_OPTS} overrides the resource profile. + */ + private static String heap(String requested, String fromProfile, Path file) { + if (requested == null || requested.isBlank()) { + return fromProfile; + } + String value = requested.trim(); + if (!HEAP.matcher(value).matches()) { + // Passed through, this reaches the JVM as an unrecognised option and + // the node dies at startup with a message about -Xmx rather than + // about this file — a long way from the edit that caused it. + log.warn("{}={} in {} is not a heap size such as 1536m or 2g; using {}.", + MAX_HEAP_KEY, value, file, fromProfile); + return fromProfile; + } + return value; + } + + /** + * Writes a commented template the first time a managed node starts, so the + * settings are discoverable in the folder the user already visits for + * {@code node.log} instead of only in the docs. Every line is commented out, + * so creating it changes nothing. + */ + static void writeTemplateIfMissing(Path nodeDir) { + if (nodeDir == null) { + return; + } + Path file = nodeDir.resolve(FILE_NAME); + if (Files.exists(file)) { + return; + } + String template = """ + # Yano Wallet — managed node settings. Optional; edit and restart the wallet. + # + # sizingProfile: how the node sizes its RocksDB caches and queues. + # xsmall | small | medium | large (default: medium) + # It is passed to the node as a Quarkus profile, after the network + # profile and before the wallet profile that enables history. + # + #sizingProfile=medium + # + # maxHeap: the node's -Xmx. Set it only to override the profile's + # default (xsmall/small 384m, medium 1536m, large 2g). Digits with an + # optional k/m/g, for example 1536m or 2g. + # + #maxHeap=1536m + """; + try { + Files.createDirectories(nodeDir); + Files.writeString(file, template); + } catch (IOException ignored) { + // Cosmetic: the defaults apply either way, and a node that would not + // start because a template could not be written helps nobody. + } + } +} diff --git a/wallet-node-launcher/src/test/java/com/bloxbean/cardano/yano/wallet/launcher/NodeLaunchCommandTest.java b/wallet-node-launcher/src/test/java/com/bloxbean/cardano/yano/wallet/launcher/NodeLaunchCommandTest.java new file mode 100644 index 0000000..195f8a2 --- /dev/null +++ b/wallet-node-launcher/src/test/java/com/bloxbean/cardano/yano/wallet/launcher/NodeLaunchCommandTest.java @@ -0,0 +1,59 @@ +package com.bloxbean.cardano.yano.wallet.launcher; + +import com.bloxbean.cardano.yano.wallet.core.config.WalletNetwork; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The command line the managed node is started with. Ordering here is not + * cosmetic: a JVM option after {@code -jar} is a program argument, so a + * misplaced {@code -Xmx} is accepted silently and simply does nothing. + */ +class NodeLaunchCommandTest { + @TempDir Path dir; + + private List command(boolean nativeBinary) { + Path nodeDir = dir.resolve("node"); + NodeLaunchSpec spec = new NodeLaunchSpec(WalletNetwork.PREPROD, + dir.resolve(nativeBinary ? "yano" : "yano.jar"), nativeBinary, dir, + 8090, 13400, nodeDir.resolve("chainstate"), nodeDir.resolve("node.log"), + "java", List.of()); + return new ManagedNode(spec).buildCommand(); + } + + @Test void theJarGetsItsHeapBeforeTheJarArgument() { + List command = command(false); + + assertThat(command).contains("-Xmx1536m"); + assertThat(command.indexOf("-Xmx1536m")).isLessThan(command.indexOf("-jar")); + assertThat(command.getFirst()).isEqualTo("java"); + } + + @Test void theNativeBinaryGetsTheSameHeap() { + // A native image reads -Xmx at runtime, which is how yano.sh sizes it. + List command = command(true); + + assertThat(command).contains("-Xmx1536m"); + assertThat(command.getFirst()).endsWith("yano"); + assertThat(command).doesNotContain("-jar"); + } + + @Test void theProfileAndHeapFollowTheUsersFile() throws Exception { + Path nodeDir = Files.createDirectories(dir.resolve("node")); + Files.writeString(nodeDir.resolve(NodeOptions.FILE_NAME), "sizingProfile=xsmall"); + + List command = command(false); + + assertThat(command).contains("-Xmx384m", "-Dquarkus.profile=preprod,xsmall,wallet"); + // Whatever the user picks for sizing, the wallet profile stays last: it + // is what enables the scan index this wallet reads history from. + assertThat(command.stream().filter(arg -> arg.startsWith("-Dquarkus.profile=")).findFirst()) + .get().asString().endsWith(",wallet"); + } +} diff --git a/wallet-node-launcher/src/test/java/com/bloxbean/cardano/yano/wallet/launcher/NodeLaunchSpecTest.java b/wallet-node-launcher/src/test/java/com/bloxbean/cardano/yano/wallet/launcher/NodeLaunchSpecTest.java index e4472c7..9af236f 100644 --- a/wallet-node-launcher/src/test/java/com/bloxbean/cardano/yano/wallet/launcher/NodeLaunchSpecTest.java +++ b/wallet-node-launcher/src/test/java/com/bloxbean/cardano/yano/wallet/launcher/NodeLaunchSpecTest.java @@ -26,9 +26,19 @@ void baseUrlUsesTheConfiguredPort() { @Test void managedProfileAppendsWalletSoRealNetworksGetTheWalletApis() { // Only %devnet enables the wallet APIs on its own; preprod/mainnet need %wallet. - assertThat(spec(WalletNetwork.PREPROD, 8090).quarkusProfile()).isEqualTo("preprod,wallet"); - assertThat(spec(WalletNetwork.MAINNET, 8090).quarkusProfile()).isEqualTo("mainnet,wallet"); - assertThat(spec(WalletNetwork.DEVNET, 8090).quarkusProfile()).isEqualTo("devnet,wallet"); + // %medium sizes the node's caches for a desktop it shares with the wallet. + assertThat(spec(WalletNetwork.PREPROD, 8090).quarkusProfile()).isEqualTo("preprod,medium,wallet"); + assertThat(spec(WalletNetwork.MAINNET, 8090).quarkusProfile()).isEqualTo("mainnet,medium,wallet"); + assertThat(spec(WalletNetwork.DEVNET, 8090).quarkusProfile()).isEqualTo("devnet,medium,wallet"); + } + + @Test + void walletProfileStaysLastSoSizingCannotSwitchItOff() { + // Quarkus lets a later profile win, and the wallet's scan/first-seen + // switches are the reason the managed node can serve history at all. + String profile = spec(WalletNetwork.MAINNET, 8090).quarkusProfile(); + assertThat(profile).endsWith(",wallet"); + assertThat(profile.indexOf("medium")).isLessThan(profile.indexOf("wallet")); } @Test diff --git a/wallet-node-launcher/src/test/java/com/bloxbean/cardano/yano/wallet/launcher/NodeOptionsTest.java b/wallet-node-launcher/src/test/java/com/bloxbean/cardano/yano/wallet/launcher/NodeOptionsTest.java new file mode 100644 index 0000000..77d7ab3 --- /dev/null +++ b/wallet-node-launcher/src/test/java/com/bloxbean/cardano/yano/wallet/launcher/NodeOptionsTest.java @@ -0,0 +1,78 @@ +package com.bloxbean.cardano.yano.wallet.launcher; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class NodeOptionsTest { + @TempDir Path nodeDir; + + @Test void withoutAFileTheNodeIsSizedForADesktopItShares() { + NodeOptions options = NodeOptions.load(nodeDir); + + assertThat(options.sizingProfile()).isEqualTo("medium"); + // yano.sh pairs medium with this heap; the wallet spawns the node itself, + // so if this drifts the node runs on the JVM default instead — tens of + // gigabytes on a large machine, beside caches sized for 4 GiB. + assertThat(options.maxHeap()).isEqualTo("1536m"); + } + + @Test void aChosenProfileBringsItsOwnHeap() throws Exception { + write("sizingProfile=xsmall"); + assertThat(NodeOptions.load(nodeDir)).isEqualTo(new NodeOptions("xsmall", "384m")); + + write("sizingProfile=large"); + assertThat(NodeOptions.load(nodeDir)).isEqualTo(new NodeOptions("large", "2g")); + } + + @Test void anExplicitHeapWinsOverTheProfileDefault() throws Exception { + // Matches yano.sh, where an -Xmx in JAVA_OPTS overrides the profile. + write("sizingProfile=xsmall\nmaxHeap=3g"); + + assertThat(NodeOptions.load(nodeDir)).isEqualTo(new NodeOptions("xsmall", "3g")); + } + + @Test void unusableValuesFallBackInsteadOfStoppingTheNode() throws Exception { + // This file is hand-edited. A typo must cost the setting, not the wallet: + // "1.5gb" passed through would reach the JVM as an unrecognised option + // and kill the node at startup, far from the edit that caused it. + write("sizingProfile=enormous\nmaxHeap=1.5gb"); + + assertThat(NodeOptions.load(nodeDir)).isEqualTo(new NodeOptions("medium", "1536m")); + } + + @Test void commentsAndBlanksAreJustAbsentValues() throws Exception { + write("# sizingProfile=large\nmaxHeap=\n"); + + assertThat(NodeOptions.load(nodeDir)).isEqualTo(NodeOptions.defaults()); + } + + @Test void theTemplateIsWrittenOnceAndChangesNothing() throws Exception { + NodeOptions.writeTemplateIfMissing(nodeDir); + Path file = nodeDir.resolve(NodeOptions.FILE_NAME); + assertThat(file).exists(); + // Every line commented: creating it must not alter how the node starts. + assertThat(NodeOptions.load(nodeDir)).isEqualTo(NodeOptions.defaults()); + assertThat(Files.readString(file)).contains(NodeOptions.PROFILE_KEY, NodeOptions.MAX_HEAP_KEY); + + Files.writeString(file, "maxHeap=2g"); + NodeOptions.writeTemplateIfMissing(nodeDir); + + // An existing file is never overwritten: it holds the user's own edits. + assertThat(Files.readString(file)).isEqualTo("maxHeap=2g"); + } + + @Test void aMissingDirectoryIsNotAFailure() { + assertThat(NodeOptions.load(nodeDir.resolve("not-created-yet"))) + .isEqualTo(NodeOptions.defaults()); + assertThat(NodeOptions.load(null)).isEqualTo(NodeOptions.defaults()); + } + + private void write(String contents) throws Exception { + Files.writeString(nodeDir.resolve(NodeOptions.FILE_NAME), contents); + } +} diff --git a/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/FxCip30Prompt.java b/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/FxCip30Prompt.java index 7e9b001..f98746a 100644 --- a/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/FxCip30Prompt.java +++ b/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/FxCip30Prompt.java @@ -56,6 +56,17 @@ public boolean confirmConnect(String origin) { }); } + @Override + public boolean confirmSignData(String origin, String address, String signerDescription, String payload) { + return ask(dialog -> { + dialog.title("Approve data signature"); + dialog.body(origin + "\n\n" + signerDescription + "\n\nAddress: " + address + + "\n\nPayload (hex): " + payload + + "\n\nThis signature may authorize account creation, spending or other actions in the requesting app. Verify that app's request before signing."); + dialog.okLabel("Sign with this key"); + }); + } + @Override public boolean confirmSignData(String origin, String address) { return ask(dialog -> { @@ -76,6 +87,29 @@ public boolean confirmSign(String origin, TxEffectView effect) { }); } + @Override + public boolean confirmExtendedSignerSearch(String origin, String signerDescription) { + return ask(dialog -> { + dialog.title("Search more signing addresses?"); + dialog.body(origin + "\n\n" + signerDescription + + "\n\nSearch receive and change indexes 30–49 in this account?" + + " This only searches keys; it does not sign. Reject keeps the initial search results."); + dialog.okLabel("Search up to 50 addresses per chain"); + }); + } + + @Override + public boolean confirmSign(String origin, TxEffectView effect, String signerDescription) { + return ask(dialog -> { + dialog.title("Approve transaction"); + dialog.body(origin + " asks you to sign a transaction.\n\n" + signerDescription + + "\n\nAll listed keys sign the same transaction body." + + " Unmatched hashes are not signed by this wallet."); + dialog.okLabel("Approve listed signatures"); + dialog.effect(effect); + }); + } + // ---- rendering --------------------------------------------------------- /** Mutable builder handed to each caller so the three dialogs share one shell. */ diff --git a/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/contract/Cip30Prompt.java b/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/contract/Cip30Prompt.java index 62c4c5e..d08d79e 100644 --- a/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/contract/Cip30Prompt.java +++ b/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/contract/Cip30Prompt.java @@ -20,10 +20,26 @@ public interface Cip30Prompt { */ boolean confirmSign(String origin, TxEffectView effect); + /** Show all selected derivation paths together with the transaction effect. */ + default boolean confirmSign(String origin, TxEffectView effect, String signerDescription) { + // Older prompt implementations cannot display the required key review. Fail closed. + return false; + } + + /** Explicitly extend this request from 30 to 50 indexes on each payment chain. */ + default boolean confirmExtendedSignerSearch(String origin, String signerDescription) { + return false; + } + /** * Ask the user to approve a CIP-8 data signature from {@code origin}. Data * signing moves no value and has no effect to simulate, so it deliberately * does not take a {@link TxEffectView} it could not fill. */ boolean confirmSignData(String origin, String address); + + /** Review the exact payload and resolved key path before COSE signing. */ + default boolean confirmSignData(String origin, String address, String signerDescription, String payload) { + return false; + } } diff --git a/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/contract/WalletUiController.java b/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/contract/WalletUiController.java index 64b18d8..f6882fb 100644 --- a/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/contract/WalletUiController.java +++ b/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/contract/WalletUiController.java @@ -11,6 +11,10 @@ * completes off the FX thread and callers hop back via {@code Platform.runLater}. */ public interface WalletUiController { + /** The draft was rejected; rebuilding requires a fresh review, never a retry of its CBOR. */ + class DraftNeedsRebuildException extends RuntimeException { + public DraftNeedsRebuildException(String message) { super(message); } + } // --- connection (managed local node vs external node) --- /** The networks the wallet can connect to. */ @@ -319,8 +323,22 @@ record UpstreamRelaysView(List configured, List custom, List> addresses(int count); + default CompletableFuture addressDetails(int index) { + return CompletableFuture.failedFuture(new UnsupportedOperationException("Public keys are unavailable")); + } + CompletableFuture history(int page, int count); + /** + * Whether a wallet scan is running right now, and how far it has walked. The + * History screen polls this while a page is in flight: a first scan reads the + * whole chain before it can answer, and a screen with nothing on it for that + * long is indistinguishable from one that failed. + */ + default CompletableFuture historyScanStatus() { + return CompletableFuture.completedFuture(HistoryScanView.idle()); + } + CompletableFuture> rewards(int page, int count); CompletableFuture staking(); @@ -463,13 +481,39 @@ record NodeStatusView(long slot, long blockNumber, boolean utxoIndexEnabled, record AssetItem(String unit, String quantity) { } + /** ada/lovelace remain UTxO-only; the dashboard total also includes withdrawable rewards. */ record BalanceView(String ada, String lovelace, int utxoCount, int addressesScanned, - List assets) { + List assets, String scanWarning, String rewardsLovelace) { + public BalanceView(String ada, String lovelace, int utxoCount, int addressesScanned, + List assets, String scanWarning) { + this(ada, lovelace, utxoCount, addressesScanned, assets, scanWarning, "0"); + } + + public BalanceView(String ada, String lovelace, int utxoCount, int addressesScanned, + List assets) { + this(ada, lovelace, utxoCount, addressesScanned, assets, null); + } + + /** Null rewards mean the reward lookup failed, not a zero reward balance. */ + public String rewardsAda() { + return rewardsLovelace == null ? null : new java.math.BigDecimal(rewardsLovelace) + .movePointLeft(6).stripTrailingZeros().toPlainString(); + } + + public String totalAda() { + return new java.math.BigDecimal(lovelace) + .add(new java.math.BigDecimal(rewardsLovelace == null ? "0" : rewardsLovelace)) + .movePointLeft(6).stripTrailingZeros().toPlainString(); + } } record AddressItem(int index, String address, String derivationPath) { } + record AddressDetails(String address, String paymentPath, String stakePath, + String paymentPublicKey, String paymentKeyHash, + String stakePublicKey, String stakeKeyHash) {} + record TxItem(String txHash, long blockHeight, String timeText, String status, String amountText, String direction, String explorerUrl) { } @@ -484,6 +528,22 @@ record TxItem(String txHash, long blockHeight, String timeText, String status, * comparing it against their balance would otherwise conclude the wallet had * lost a transaction. */ + /** + * A scan in flight, or {@link #idle()} when none is. Blocks are absolute + * chain heights so the screen can name where the scan has got to, which is + * what distinguishes slow progress from a stall. + */ + record HistoryScanView(long currentBlock, long tipBlock, int percent) { + public static HistoryScanView idle() { + return new HistoryScanView(0, 0, -1); + } + + /** A scan is running only when the node has told us how far it reaches. */ + public boolean scanning() { + return percent >= 0 && tipBlock > 0; + } + } + record HistoryPage(List items, boolean localOnly) { public HistoryPage { items = items == null ? List.of() : List.copyOf(items); diff --git a/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/screens/ApprovalOverlay.java b/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/screens/ApprovalOverlay.java index ed7d7ee..5f3001f 100644 --- a/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/screens/ApprovalOverlay.java +++ b/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/screens/ApprovalOverlay.java @@ -28,6 +28,12 @@ private ApprovalOverlay() { */ public static void show(StackPane overlay, WalletUiController controller, WalletUiController.DraftView draft, Runnable onSubmitted) { + show(overlay, controller, draft, onSubmitted, null); + } + + /** onRebuild builds a new draft and opens a new review, without submitting it. */ + public static void show(StackPane overlay, WalletUiController controller, + WalletUiController.DraftView draft, Runnable onSubmitted, Runnable onRebuild) { StackPane scrim = new StackPane(); scrim.getStyleClass().add("modal-scrim"); scrim.setOnMouseClicked(javafx.event.Event::consume); // swallow background clicks @@ -65,9 +71,14 @@ public static void show(StackPane overlay, WalletUiController controller, in.setToValue(1); in.play(); + boolean[] needsRebuild = {false}; Ui.onFx(controller.simulateDraft(draft.draftId()), - effect -> describeVerification(verification, effect), - error -> verification.setText("This transaction could not be checked against your node.")); + effect -> { + if (!needsRebuild[0]) describeVerification(verification, effect); + }, + error -> { + if (!needsRebuild[0]) verification.setText("This transaction could not be checked against your node."); + }); Runnable close = () -> overlay.getChildren().remove(scrim); cancel.setOnAction(e -> close.run()); @@ -81,6 +92,18 @@ public static void show(StackPane overlay, WalletUiController controller, onSubmitted.run(); } }, error -> { + if (error instanceof WalletUiController.DraftNeedsRebuildException) { + needsRebuild[0] = true; + verification.setText(error.getMessage()); + cancel.setDisable(false); + confirm.setText(onRebuild == null ? "Close and review again" : "Rebuild & review"); + confirm.setDisable(false); + confirm.setOnAction(rebuild -> { + close.run(); + if (onRebuild != null) onRebuild.run(); + }); + return; + } confirm.setDisable(false); cancel.setDisable(false); Ui.toast(overlay, "Submit failed: " + error.getMessage(), true); diff --git a/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/screens/ConnectScreen.java b/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/screens/ConnectScreen.java index 25c1a26..275729b 100644 --- a/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/screens/ConnectScreen.java +++ b/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/screens/ConnectScreen.java @@ -155,9 +155,10 @@ public String fromString(String label) { if (!canManage) { externalToggle.setSelected(true); String preset = controller.defaultBaseUrl(network); - if (preset != null && urlField.getText().isBlank()) { - urlField.setText(preset); - } + // Do not carry the URL from the previously selected network into + // Yaci DevKit. A saved connection is restored separately below; + // a fresh network selection starts from the network's own URL. + urlField.setText(preset == null ? "" : preset); } else if (!NetworkPrefs.managed(network, true)) { // Restore how this network was last reached, rather than carrying // the previously selected network's mode over to it. diff --git a/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/screens/DashboardScreen.java b/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/screens/DashboardScreen.java index b35d58f..9361aa8 100644 --- a/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/screens/DashboardScreen.java +++ b/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/screens/DashboardScreen.java @@ -24,7 +24,10 @@ public class DashboardScreen implements Shell.Screen { private final Consumer navigate; private final Label walletName = new Label(); + private boolean balanceLoading; + private final Label balanceTitle = Ui.muted("Total balance"); private final Label balanceAda = new Label("—"); + private final Label rewardsBalance = new Label("Rewards: —"); private final Label balanceDetail = new Label(""); private final VBox assetsBox = new VBox(8); private final VBox activityBox = new VBox(8); @@ -40,7 +43,9 @@ public DashboardScreen(WalletUiController controller, StackPane overlay, Consume private ScrollPane build() { walletName.getStyleClass().add("screen-title"); balanceAda.getStyleClass().add("balance-hero"); + rewardsBalance.getStyleClass().addAll("muted", "balance-rewards"); balanceDetail.getStyleClass().add("muted"); + balanceDetail.setWrapText(true); Button send = new Button("Send"); send.getStyleClass().add("primary-button"); @@ -50,7 +55,7 @@ private ScrollPane build() { receive.setOnAction(e -> navigate.accept("Receive")); HBox actions = Ui.row(10, send, receive); - VBox hero = new VBox(6, Ui.muted("Total balance"), balanceAda, balanceDetail, actions); + VBox hero = new VBox(6, balanceTitle, balanceAda, rewardsBalance, balanceDetail, actions); hero.getStyleClass().addAll("card", "hero-card"); hero.setSpacing(10); @@ -78,9 +83,8 @@ public void refresh() { } /** - * Silent periodic refresh (shell status poller). Keeps last-good balance and - * activity on a transient node error so, e.g., a pending tx flips to - * confirmed on its own without the user re-navigating. + * Silent periodic refresh (shell status poller). Balance errors remain visible + * inline so a stale or incomplete balance cannot look like a current total. */ @Override public void poll() { @@ -91,26 +95,40 @@ private void load(boolean silent) { WalletUiController.WalletItem wallet = controller.activeWallet(); walletName.setText(wallet != null ? wallet.name() : ""); - Ui.onFx(controller.balance(), balance -> { - balanceAda.setText("₳ " + balance.ada()); - balanceDetail.setText(balance.utxoCount() + " UTXOs · " - + balance.addressesScanned() + " addresses scanned"); - assetsBox.getChildren().clear(); - if (balance.assets().isEmpty()) { - assetsBox.getChildren().add(Ui.muted("No native assets")); - } else { - balance.assets().forEach(asset -> { - Label unit = new Label(Ui.middleEllipsis(asset.unit(), 14)); - unit.getStyleClass().add("mono"); - Label qty = new Label(asset.quantity()); - assetsBox.getChildren().add(Ui.row(10, unit, Ui.spacer(), qty)); - }); - } - }, error -> { - if (!silent) { - Ui.toast(overlay, "Balance failed: " + error.getMessage(), true); - } - }); + if (!balanceLoading) { + balanceLoading = true; + Ui.onFx(controller.balance(), balance -> { + balanceLoading = false; + balanceTitle.setText(balance.scanWarning() != null ? "Known balance — scan incomplete" + : balance.rewardsLovelace() == null ? "Known balance — rewards unavailable" : "Total balance"); + balanceAda.setText("₳ " + balance.totalAda()); + rewardsBalance.setText(balance.rewardsAda() == null ? "Rewards: unavailable" + : "Rewards: ₳ " + balance.rewardsAda()); + balanceDetail.setText(balance.utxoCount() + " UTXOs · " + + balance.addressesScanned() + " addresses scanned" + + (balance.scanWarning() == null ? "" : "\n" + balance.scanWarning())); + assetsBox.getChildren().clear(); + if (balance.assets().isEmpty()) { + assetsBox.getChildren().add(Ui.muted("No native assets")); + } else { + balance.assets().forEach(asset -> { + Label unit = new Label(Ui.middleEllipsis(asset.unit(), 14)); + unit.getStyleClass().add("mono"); + Label qty = new Label(asset.quantity()); + assetsBox.getChildren().add(Ui.row(10, unit, Ui.spacer(), qty)); + }); + } + }, error -> { + balanceLoading = false; + balanceAda.setText("—"); + rewardsBalance.setText("Rewards: —"); + balanceDetail.setText("Balance unavailable: " + error.getMessage()); + assetsBox.getChildren().setAll(Ui.muted("Assets unavailable until balance refresh succeeds")); + if (!silent) { + Ui.toast(overlay, "Balance failed: " + error.getMessage(), true); + } + }); + } Ui.onFx(controller.history(1, 5), history -> { activityBox.getChildren().clear(); diff --git a/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/screens/HistoryScreen.java b/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/screens/HistoryScreen.java index e045d42..d4bbd6b 100644 --- a/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/screens/HistoryScreen.java +++ b/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/screens/HistoryScreen.java @@ -3,6 +3,8 @@ import com.bloxbean.cardano.yano.wallet.ui.Shell; import com.bloxbean.cardano.yano.wallet.ui.contract.WalletUiController; import com.bloxbean.cardano.yano.wallet.ui.util.Ui; +import javafx.animation.KeyFrame; +import javafx.animation.Timeline; import javafx.geometry.Insets; import javafx.scene.Node; import javafx.scene.control.Button; @@ -10,6 +12,7 @@ import javafx.scene.control.ScrollPane; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; +import javafx.util.Duration; /** * Full transaction history — from the node's address-tx index (ADR-033 M2), or @@ -40,8 +43,10 @@ public class HistoryScreen implements Shell.Screen { "This node keeps no transaction index, so this list is the wallet's own record of what " + "it sent — including transactions a connected dApp submitted through it. Funds " + "received from elsewhere are counted in your balance but do not appear here."); + private final Label progress = Ui.muted(""); private final ScrollPane root; private int page = 1; + private Timeline scanPoller; public HistoryScreen(WalletUiController controller, StackPane overlay) { this.controller = controller; @@ -90,7 +95,18 @@ public void refresh() { } private void loadPage(int newPage, boolean reset) { + if (reset) { + // A first scan walks the whole chain before it can answer — over half + // a minute on mainnet. Saying so up front is the difference between + // "working" and "broken": until this, the screen sat empty either way. + progress.setText("Loading history…"); + listBox.getChildren().setAll(progress); + moreButton.setVisible(false); + moreButton.setManaged(false); + startProgressPolling(); + } Ui.onFx(controller.history(newPage, PAGE_SIZE), history -> { + stopProgressPolling(); if (reset) { listBox.getChildren().clear(); } @@ -98,7 +114,7 @@ private void loadPage(int newPage, boolean reset) { showLocalOnly(history.localOnly()); var txs = history.items(); if (txs.isEmpty() && reset) { - listBox.getChildren().add(Ui.muted("No transactions yet")); + listBox.getChildren().add(Ui.muted(emptyMessage(history.localOnly()))); } txs.forEach(tx -> listBox.getChildren().add(txRow(tx))); // Page 1 may include a few local pending rows on top of the node @@ -107,6 +123,7 @@ private void loadPage(int newPage, boolean reset) { moreButton.setVisible(maybeMore); moreButton.setManaged(maybeMore); }, error -> { + stopProgressPolling(); if (reset) { listBox.getChildren().setAll(Ui.muted("History unavailable: " + error.getMessage())); moreButton.setVisible(false); @@ -117,6 +134,53 @@ private void loadPage(int newPage, boolean reset) { }); } + /** + * Polls only while a page is in flight, and faster than the shell's 5-second + * tick — a scan that takes half a minute deserves a line that visibly moves, + * which is what separates slow work from a stall. + */ + private void startProgressPolling() { + stopProgressPolling(); + scanPoller = new Timeline(new KeyFrame(Duration.seconds(0.5), e -> pollScan())); + scanPoller.setCycleCount(Timeline.INDEFINITE); + scanPoller.play(); + pollScan(); + } + + private void stopProgressPolling() { + if (scanPoller != null) { + scanPoller.stop(); + scanPoller = null; + } + } + + private void pollScan() { + Ui.onFx(controller.historyScanStatus(), status -> { + // Only while the placeholder is still the whole list: a page that has + // arrived must never be overwritten by a late poll. + if (scanPoller != null && listBox.getChildren().size() == 1 + && listBox.getChildren().getFirst() == progress && status.scanning()) { + progress.setText(scanMessage(status)); + } + }, error -> { /* progress is decoration; the page's own error reports failure */ }); + } + + /** + * Named separately from the ordinary empty list because they mean opposite + * things: one wallet has sent nothing yet, the other is connected to a node + * that indexes no transactions at all and has no local record either. + */ + static String emptyMessage(boolean localOnly) { + return localOnly + ? "History not found — this node serves no transaction index, and this wallet has no record of sending anything." + : "No transactions yet"; + } + + static String scanMessage(WalletUiController.HistoryScanView status) { + return "Scanning the chain for your transactions — block %,d of %,d · %d%%" + .formatted(status.currentBlock(), status.tipBlock(), status.percent()); + } + private void showLocalOnly(boolean localOnly) { note.setText(localOnly ? LOCAL_NOTE : NODE_NOTE); localChip.setVisible(localOnly); diff --git a/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/screens/ReceiveScreen.java b/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/screens/ReceiveScreen.java index ec282cf..b6e20d0 100644 --- a/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/screens/ReceiveScreen.java +++ b/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/screens/ReceiveScreen.java @@ -9,6 +9,10 @@ import javafx.scene.control.Button; import javafx.scene.control.Label; import javafx.scene.control.ScrollPane; +import javafx.scene.control.TextField; +import javafx.scene.control.TitledPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; @@ -20,6 +24,10 @@ public class ReceiveScreen implements Shell.Screen { private final StackPane overlay; private final Label primaryAddress = new Label(); private final VBox addressList = new VBox(8); + private final VBox detailsCard = new VBox(14); + private String walletId; + private int detailsRequest; + private int selectedIndex = -1; private final ScrollPane root; public ReceiveScreen(WalletUiController controller, StackPane overlay) { @@ -40,11 +48,18 @@ private ScrollPane build() { Ui.copyToClipboard(primaryAddress.getText()); Ui.toast(overlay, "Address copied", false); }); - VBox primaryCard = Ui.card("Your address", primaryAddress, copy); + Button details = new Button("Address details"); + details.getStyleClass().add("ghost-button-small"); + details.setOnAction(e -> showDetails(0)); + VBox primaryCard = Ui.card("Your address", primaryAddress, Ui.row(12, copy, details)); + + detailsCard.getStyleClass().addAll("card", "address-details-card"); + detailsCard.setVisible(false); + detailsCard.setManaged(false); VBox listCard = Ui.card("Receive addresses (CIP-1852)", addressList); - VBox column = new VBox(16, title, primaryCard, listCard); + VBox column = new VBox(16, title, primaryCard, detailsCard, listCard); column.setPadding(new Insets(24)); column.setMaxWidth(860); ScrollPane scroll = new ScrollPane(column); @@ -61,10 +76,14 @@ public Node root() { @Override public void refresh() { WalletUiController.WalletItem wallet = controller.activeWallet(); - if (wallet != null) { - primaryAddress.setText(wallet.baseAddress()); + String currentId = wallet == null ? null : wallet.walletId(); + if (!java.util.Objects.equals(walletId, currentId)) { + walletId = currentId; + hideDetails(); } + primaryAddress.setText(wallet == null ? "" : wallet.baseAddress()); Ui.onFx(controller.addresses(ADDRESS_COUNT), addresses -> { + if (!java.util.Objects.equals(walletId, currentId)) return; addressList.getChildren().clear(); addresses.forEach(address -> { Label index = Ui.chip("#" + address.index(), "chip-neutral"); @@ -77,8 +96,96 @@ public void refresh() { Ui.copyToClipboard(address.address()); Ui.toast(overlay, "Address #" + address.index() + " copied", false); }); - addressList.getChildren().add(Ui.row(12, index, value, path, Ui.spacer(), copy)); + Button details = new Button("Details"); + details.getStyleClass().add("ghost-button-small"); + details.setAccessibleText("Public key details for address " + address.index()); + details.setOnAction(e -> showDetails(address.index())); + value.setMinWidth(0); + addressList.getChildren().add(Ui.row(12, index, value, Ui.spacer(), details, copy)); + addressList.getChildren().add(path); }); }, error -> Ui.toast(overlay, "Addresses failed: " + error.getMessage(), true)); } + + private void hideDetails() { + detailsRequest++; + selectedIndex = -1; + detailsCard.getChildren().clear(); + detailsCard.setVisible(false); + detailsCard.setManaged(false); + } + + private void showDetails(int index) { + if (selectedIndex == index && detailsCard.isVisible()) { + hideDetails(); + return; + } + selectedIndex = index; + int request = ++detailsRequest; + Label heading = new Label("Address #" + index + " · Public keys"); + heading.getStyleClass().add("card-title"); + Button close = new Button("Close details"); + close.getStyleClass().add("ghost-button-small"); + close.setOnAction(e -> hideDetails()); + HBox header = Ui.row(12, heading, Ui.spacer(), close); + detailsCard.getChildren().setAll(header, Ui.muted("Loading public keys…")); + detailsCard.setVisible(true); + detailsCard.setManaged(true); + root.setVvalue(0); + Ui.onFx(controller.addressDetails(index), details -> { + if (!isCurrent(request)) return; + Label address = new Label(details.address()); + address.getStyleClass().addAll("mono", "muted"); + address.setWrapText(true); + Label explanation = Ui.muted("Public keys verify signatures; they cannot spend funds. Sharing them may link your activity."); + explanation.setWrapText(true); + VBox payment = keySection("Payment", details.paymentPath(), details.paymentPublicKey(), details.paymentKeyHash()); + TitledPane stake = new TitledPane("Stake key details", keySection("Stake", details.stakePath(), + details.stakePublicKey(), details.stakeKeyHash())); + stake.setExpanded(false); + stake.setAnimated(false); + stake.getStyleClass().add("address-stake-details"); + detailsCard.getChildren().setAll(header, address, explanation, payment, stake); + }, error -> { + if (!isCurrent(request)) return; + Label message = Ui.muted("Public keys could not be loaded. Unlock this wallet and try again."); + message.setWrapText(true); + Button retry = new Button("Try again"); + retry.getStyleClass().add("ghost-button-small"); + retry.setOnAction(e -> { selectedIndex = -1; showDetails(index); }); + detailsCard.getChildren().setAll(header, message, retry); + }); + } + + private boolean isCurrent(int request) { + var active = controller.activeWallet(); + return request == detailsRequest && active != null && java.util.Objects.equals(walletId, active.walletId()); + } + + private VBox keySection(String name, String path, String publicKey, String hash) { + Label title = new Label(name + " verification key"); + title.getStyleClass().add("card-title"); + Label derivation = Ui.muted(path); + derivation.getStyleClass().add("mono"); + return new VBox(8, title, derivation, + copyableKey(name + " public key", "Public key · 32 bytes · hex", publicKey), + copyableKey(name + " key hash", "Key hash · 28 bytes · hex", hash)); + } + + private VBox copyableKey(String name, String caption, String value) { + TextField field = new TextField(value); + field.setEditable(false); + field.getStyleClass().add("mono"); + field.setAccessibleText(name + " in hexadecimal"); + HBox.setHgrow(field, Priority.ALWAYS); + Button copy = new Button("Copy", Icons.icon(Icons.COPY, 12, "nav-icon")); + copy.getStyleClass().add("ghost-button-small"); + copy.setMinWidth(Button.USE_PREF_SIZE); + copy.setAccessibleText("Copy " + name.toLowerCase()); + copy.setOnAction(e -> { + Ui.copyToClipboard(value); + Ui.toast(overlay, name + " copied", false); + }); + return new VBox(4, Ui.muted(caption), Ui.row(10, field, copy)); + } } diff --git a/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/screens/SendScreen.java b/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/screens/SendScreen.java index 03c764d..e26e4bf 100644 --- a/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/screens/SendScreen.java +++ b/wallet-ui/src/main/java/com/bloxbean/cardano/yano/wallet/ui/screens/SendScreen.java @@ -108,7 +108,7 @@ private void review() { toField.clear(); amountField.clear(); memoField.clear(); - }); + }, this::review); }, error -> { reviewButton.setDisable(false); Ui.toast(overlay, "Draft failed: " + error.getMessage(), true); @@ -126,7 +126,8 @@ public void refresh() { Ui.onFx(controller.balance(), balance -> { AssetOption previous = assetPicker.getValue(); List options = new ArrayList<>(); - options.add(new AssetOption("lovelace", "ADA", "₳ " + balance.ada())); + options.add(new AssetOption("lovelace", "ADA", "₳ " + balance.ada() + + (balance.scanWarning() == null ? "" : " (known balance; scan incomplete)"))); for (WalletUiController.AssetItem asset : balance.assets()) { options.add(new AssetOption(asset.unit(), assetLabel(asset.unit()), asset.quantity())); } @@ -161,4 +162,3 @@ private static String assetLabel(String unit) { return unit.length() > 14 ? unit.substring(0, 14) + "…" : unit; } } - diff --git a/wallet-ui/src/main/resources/com/bloxbean/cardano/yano/wallet/ui/wallet.css b/wallet-ui/src/main/resources/com/bloxbean/cardano/yano/wallet/ui/wallet.css index d3fa506..f5e8942 100644 --- a/wallet-ui/src/main/resources/com/bloxbean/cardano/yano/wallet/ui/wallet.css +++ b/wallet-ui/src/main/resources/com/bloxbean/cardano/yano/wallet/ui/wallet.css @@ -87,11 +87,26 @@ -fx-padding: 18; } .card-title { -fx-text-fill: -text-2; -fx-font-size: 12px; -fx-font-weight: 700; } + +.address-details-card { -fx-border-color: -accent; } +.address-stake-details { -fx-text-fill: -text-1; } +.address-stake-details > .title { + -fx-background-color: -surface-2; + -fx-background-radius: 6; + -fx-padding: 10 12; +} +.address-stake-details > .title > .arrow-button > .arrow { -fx-background-color: -text-2; } +.address-stake-details > .content { + -fx-background-color: -surface-1; + -fx-border-color: -border; + -fx-padding: 12; +} .hero-card { -fx-background-color: linear-gradient(to bottom right, -hero-from, -hero-to); -fx-border-color: -hero-border; } .balance-hero { -fx-font-size: 38px; -fx-font-weight: 800; -fx-text-fill: -text-1; } +.balance-rewards { -fx-font-size: 12px; } .balance-sub { -fx-font-size: 17px; -fx-font-weight: 700; } .total-amount { -fx-font-weight: 800; } .list-row { diff --git a/wallet-ui/src/test/java/com/bloxbean/cardano/yano/wallet/ui/screens/HistoryMessagesTest.java b/wallet-ui/src/test/java/com/bloxbean/cardano/yano/wallet/ui/screens/HistoryMessagesTest.java new file mode 100644 index 0000000..e0ea49b --- /dev/null +++ b/wallet-ui/src/test/java/com/bloxbean/cardano/yano/wallet/ui/screens/HistoryMessagesTest.java @@ -0,0 +1,34 @@ +package com.bloxbean.cardano.yano.wallet.ui.screens; + +import com.bloxbean.cardano.yano.wallet.ui.contract.WalletUiController.HistoryScanView; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class HistoryMessagesTest { + @Test void anEmptyListSaysWhichKindOfEmptyItIs() { + assertThat(HistoryScreen.emptyMessage(false)).isEqualTo("No transactions yet"); + // Local-only means the node indexes no transactions at all, so an empty + // list is not "this wallet has done nothing" — it is "nothing can be read + // from here", which is a different thing for the user to act on. + assertThat(HistoryScreen.emptyMessage(true)) + .contains("History not found") + .contains("no transaction index"); + } + + @Test void progressNamesWhereTheScanHasReached() { + String message = HistoryScreen.scanMessage(new HistoryScanView(11_184_128L, 13_973_520L, 80)); + + // Grouped digits: a raw 11184128 next to 13973520 is unreadable at a glance. + assertThat(message).isEqualTo( + "Scanning the chain for your transactions — block 11,184,128 of 13,973,520 · 80%"); + } + + @Test void idleIsNotAScan() { + assertThat(HistoryScanView.idle().scanning()).isFalse(); + // Percent 0 is a scan that has just started, not the absence of one. + assertThat(new HistoryScanView(0, 13_973_520L, 0).scanning()).isTrue(); + // A node that reported no tip cannot place the scan on a scale. + assertThat(new HistoryScanView(500, 0, 50).scanning()).isFalse(); + } +}