diff --git a/bridge-sdk/AGENTS.md b/bridge-sdk/AGENTS.md index fb66018..9be9f75 100644 --- a/bridge-sdk/AGENTS.md +++ b/bridge-sdk/AGENTS.md @@ -61,7 +61,7 @@ back are dict entries instead — ``{"next": "failed", "error", "error_type"}`` when no store is bound. Finish any entry with ``recover`` → ``wait`` / ``resume`` / ``complete``, never by starting a new transfer. -### `quote(self, *, source_chain: 'str | None' = None, source_asset: 'str | None' = None, destination_chain: 'str | None' = None, destination_asset: 'str | None' = None, bridge_protocol: 'str | None' = None, route=None, amount=None, amount_atomic=None, recipient: 'str', sender: 'str | None' = None, mint_mode: 'str' = 'public', secret_nonce: 'str' = '0scalar', cctp=None)` +### `quote(self, *, source_chain: 'str | None' = None, source_asset: 'str | None' = None, destination_chain: 'str | None' = None, destination_asset: 'str | None' = None, bridge_protocol: 'str | None' = None, route=None, amount=None, amount_atomic=None, recipient: 'str', sender: 'str | None' = None, mint_mode: 'str' = 'public', secret_nonce: 'str | None' = None, cctp=None)` Price a transfer and get the plan that ``execute`` takes. Nothing is signed. @@ -73,7 +73,10 @@ only when more than one route fits — or ``route=`` with a ``Route`` from or ``amount_atomic`` (int). ``recipient`` is the destination-chain address. ``mint_mode`` (xReserve into Aleo only): ``"public"`` balance, ``"record"`` minted by the relayer, or ``"private"`` — you finish it -yourself with ``complete`` and must keep ``secret_nonce``. Returns a +yourself with ``complete``. For a private mint to the bridge's own +account the nonce is derived from the view key and kept in the bound +identity store, so no ``secret_nonce`` is needed; pass your own to +override it (then keep it). Returns a kind-specific ``Quote`` (``quote.kind`` in evm-hyperlane / solana-hyperlane / aleo-hyperlane / evm-xreserve / aleo-xreserve) with ``fees`` and ``amount_out`` in human units and ``quote.plan``. Show the @@ -127,14 +130,18 @@ Finish an interrupted source submission (``progress.next == "resume"``). Rebroadcasts the identical proved Aleo transaction (a duplicate response is success) or, on EVM, re-scans history and only then authorizes the single -missing deposit/dispatch. Never repeats a confirmed step. +missing deposit/dispatch. Never repeats a confirmed step. A private +xReserve deposit for the bridge's own account finds its nonce in the bound +identity store; pass ``secret_nonce`` only if the deposit was made with one. ### `complete(self, progress, *, secret_nonce: 'str | None' = None, on_checkpoint=None, proving: 'str' = 'delegate', manual_mint: 'bool' = False)` Claim a private USDCx or native-USDC CCTP destination mint. -Requires the same ``secret_nonce`` given to ``execute``; the SDK never -stored it. Submits exactly one ``private_mint`` and returns +A private USDCx mint to the bridge's own account needs no ``secret_nonce``: +the scalar comes from the bound identity store, re-derived from the view +key if the store lost it. A deposit made with a caller-supplied nonce +needs that same nonce back. Submits exactly one ``private_mint`` and returns ``DESTINATION_CONFIRMING`` progress to ``wait`` on. CCTP needs no secret nonce, but requires a destination signer and gas. Set ``manual_mint=True`` to explicitly authorize fallback for stalled forwarding; an already @@ -216,11 +223,22 @@ still in flight and check again later. ### Private mints and the secret nonce 6. `mint_mode="private"` (USDC → USDCx) commits `(recipient, secret_nonce)` on - Ethereum. The same `secret_nonce` is required by `complete`; the SDK - **never stores** it and checkpoints exclude it (and every other secret). - Tell the user to keep it (the default `0scalar` needs no storage but adds - no entropy). Only the recipient's Aleo key can complete a private mint — - make sure the recipient IS the configured Aleo address before depositing. + Ethereum, and `complete` must reveal the same nonce. When the recipient IS + the bridge's own Aleo address and a journal directory is bound (the + profile's, or `BRIDGE_CHECKPOINT_DIR`), the bridge derives the nonce from + the account's view key the way the Shield wallet does, keeps it in + `.private-mint-identities.json` beside the journal, and re-derives it if + that file is lost — no `secret_nonce` is needed anywhere. Its `.counter` + companion is what keeps each deposit on a fresh nonce: if BOTH files are + lost, tell the user to set the counter above the last one used before + depositing again, or a later deposit can be linked to an earlier one. A + deposit made under `eth.deposit_usdc`'s Tier-2 `0scalar` default still + completes without a nonce. A user-supplied + `secret_nonce` overrides that: the SDK **never stores** it and checkpoints + exclude it (and every other secret), so tell the user to keep it for + `complete` (the default `0scalar` adds no entropy). A deposit to any other + recipient needs a supplied nonce — only the recipient's Aleo key can + complete a private mint, so confirm the recipient before depositing. 7. Aleo-origin Hyperlane transfers spend PUBLIC balances: `unshield` a private record first. Hyperlane delivers into public balances; `shield` afterwards if the user wants privacy. Private xReserve burns spend records directly. @@ -290,10 +308,15 @@ from the published list and cached until the list changes. Pass one must exceed the 2 USDCx withdrawal fee. The Aleo burn-attestation service forwards accepted burns to Circle. -### `xreserve.private_mint(self, attestation: 'Attestation', recipient: 'str', *, secret_nonce: 'str' = '0scalar', route: 'Route | None' = None) -> 'AleoCall[MintReceipt]'` +### `xreserve.private_mint(self, attestation: 'Attestation', recipient: 'str', *, secret_nonce: 'str | None' = None, route: 'Route | None' = None) -> 'AleoCall[MintReceipt]'` Finish a private-mode deposit: the only user-signed Aleo step of the inbound flow (``wrapper.private_mint``). +An explicit ``secret_nonce`` is used as given. Without one, the bridge resolves the scalar +for its own account from the identity store that reserved the deposit (re-deriving it from +the view key when the store lacks it); a deposit made with a caller-supplied nonce needs +that nonce back. + ### `xreserve.get_attestation(self, message_hash: "'str | bytes'", *, route: 'Route | None' = None) -> 'Attestation | None'` One Circle request for *message_hash*; ``None`` while pending (404). diff --git a/bridge-sdk/README.md b/bridge-sdk/README.md index 650d4ec..aaa5dfb 100644 --- a/bridge-sdk/README.md +++ b/bridge-sdk/README.md @@ -259,8 +259,9 @@ if progress.next == "resume": ``` A Private Bridge USDCx deposit needs the recipient to claim the funds with -their Aleo account and original secret nonce. The [USDC Bridging Guide](#usdc-bridging-guide) -explains that choice and the `complete` call used to claim. +their Aleo account; the bridge derives and keeps the claim nonce for its own +account. The [USDC Bridging Guide](#usdc-bridging-guide) explains that choice +and the `complete` call used to claim. ## Recover Funds @@ -336,12 +337,14 @@ funds. It does not authorize those actions. Follow `progress.next`: | --- | --- | | `wait` | Call `bridge.wait(progress)` to continue monitoring. | | `resume` | Call `bridge.resume(progress)` to submit the unfinished source operation. | -| `complete` | Call `bridge.complete(progress, secret_nonce=secret_nonce)` to mint private USDCx on Aleo. | +| `complete` | Call `bridge.complete(progress)` to mint private USDCx on Aleo (add `secret_nonce=` only if you supplied one at deposit). | | `done` | Delivery is complete; no further action is needed. | | `failed` | Inspect `progress.error` before deciding what to do. | -`resume` and `complete` can submit transactions. A private USDCx mint requires -the original secret nonce, which must be stored separately from the journal. +`resume` and `complete` can submit transactions. A private USDCx mint deposited +under a nonce you supplied requires that same nonce, which must be stored +separately from the journal; a derived nonce is found again from the identities +file beside the journal or re-derived from the account's view key. **Do not call `execute` again after an ambiguous submission.** A timeout or lost RPC response does not establish that the original transaction failed. @@ -429,9 +432,10 @@ above supplies the same transfer details that a journal would have retained. Other routes may need additional information: - **Private USDCx deposits:** recover the original commitment data from the - Ethereum deposit and retain the original secret nonce for the claim. A lost - nonce cannot be reconstructed from public chain data; the recipient cannot - complete the claim through this flow without it. + Ethereum deposit. A nonce the bridge derived is re-derived from the account's + view key at claim time. A nonce the application supplied cannot be + reconstructed from public chain data; the recipient cannot complete the + claim through this flow without it. - **Aleo-origin transfers:** recovery can track source confirmation, but some delivery checks depend on the destination balance recorded before submission. Without that information, confirm receipt on the destination chain; the SDK @@ -444,9 +448,11 @@ The SDK does not yet reconstruct every route from a transaction hash alone. The example above applies specifically to confirmed Ethereum-to-Aleo Hyperlane transfers; do not reuse its recovery data for a different route. -Keep any private-mint nonce separately: restoring a checkpoint cannot replace -that claim secret. Checkpoints exclude private keys and private record -plaintext, but can include an authorized Aleo transaction awaiting broadcast. +Keep any private-mint nonce you supplied yourself separately: restoring a +checkpoint cannot replace that claim secret. Derived nonces sit beside the +journal in `.private-mint-identities.json`; back it up with the journal. +Checkpoints exclude private keys and private record plaintext, but can include +an authorized Aleo transaction awaiting broadcast. Protect the journal accordingly. Applications with existing storage can save checkpoints from `on_checkpoint` using `checkpoint.to_json()`. @@ -535,33 +541,22 @@ Private Bridge conceals the recipient's Aleo address in the Ethereum deposit and delivers funds as a private balance. Unlike automatic delivery, it requires the recipient to claim the funds before spending them. -The flow has three stages: retain a secret nonce, submit the deposit using -that nonce, and claim the USDCx when it is ready. The nonce is a random value -used to conceal the address; the claim requires both the original nonce and -the recipient's Aleo account. - -**Save the nonce before sending funds and retain it until the claim completes.** -Losing it prevents the recipient from completing the claim through this flow. -The bridge journal does not save it, so restoring the journal alone is not -enough. The default `0scalar` provides no secrecy; use a securely generated -nonce for this option. - -This is an alternative to the automatic delivery example above. Store the -nonce as an Aleo scalar literal in `BRIDGE_MINT_SECRET_NONCE`. The example uses -the configured Aleo account as the recipient, so that account can claim the -funds later. Request a quote with `mint_mode="private"`: +The deposit commits the recipient to a secret nonce, and the claim reveals that +nonce. When the recipient is the bridge's own Aleo account and the client keeps +a journal (`Bridge.from_profile()`, or `checkpoints=FileCheckpointStore(...)`), +the bridge derives the nonce from the account's view key the way the Shield +wallet does, keeps it beside the journal, and supplies it again at claim time. +Nothing needs to be saved by hand. Request a quote with `mint_mode="private"`: ```python -secret_nonce = os.environ["BRIDGE_MINT_SECRET_NONCE"] # Aleo scalar: decimal digits + "scalar". private_quote = bridge.quote( source_chain="ethereum", source_asset="usdc", destination_chain="aleo", destination_asset="usdcx", amount="2", # 2 USDC, not 2 atomic units. - recipient=bridge.aleo_address(), + recipient=bridge.aleo_address(), # The bridge's own account: the nonce is derived for it. mint_mode="private", - secret_nonce=secret_nonce, ) print(private_quote.fees) # Review Ethereum fees before submitting. print(private_quote.amount_out) # Expected USDCx amount in display units. @@ -573,19 +568,52 @@ ready. Claiming requires the recipient to authorize an Aleo transaction and can incur an Aleo fee: ```python -private_progress = bridge.execute(private_quote.plan, secret_nonce=secret_nonce) +private_progress = bridge.execute(private_quote.plan) private_progress = bridge.wait(private_progress) if private_progress.next == "complete": # The deposit is ready for the recipient to claim. - private_progress = bridge.complete(private_progress, secret_nonce=secret_nonce) + private_progress = bridge.complete(private_progress) private_progress = bridge.wait(private_progress) print(private_progress.next, private_progress.error) # done None after the claim confirms. ``` +Derived nonces live in `.private-mint-identities.json` inside the journal +directory, with a `.counter` file beside it. Treat both like a view key: the +file holds the secrets that open the deposits, so protect it and back it up +with the journal. Losing the identities file does not lose the funds. The claim +re-derives the nonce from the view key by counting up, then saves it again. The +`.counter` file is what keeps every deposit on a fresh nonce; if it is lost too, +set it to a number above the last counter you used before depositing again, +otherwise a later deposit can reuse a nonce and be linked to an earlier one. +Pass `private_mint_identities=` to `Bridge(...)` to keep the identities +elsewhere. + +#### Bring your own nonce + +Pass `secret_nonce=` to deposit under a nonce the application manages. This is +required when the recipient is not the bridge's own account, since only the +recipient's wallet should hold its secrets, and useful when an application keeps +its own secret store. The same value must come back on `execute`, `resume` and +`complete`; the bridge never stores a supplied nonce and the journal excludes +it. Generate it with `str(aleo.mainnet.Scalar.random())` and save it before +sending funds. The default `0scalar` provides no secrecy. + +```python +secret_nonce = os.environ["BRIDGE_MINT_SECRET_NONCE"] # Aleo scalar: decimal digits + "scalar". +private_quote = bridge.quote( + source_chain="ethereum", source_asset="usdc", + destination_chain="aleo", destination_asset="usdcx", + amount="2", recipient=recipient, mint_mode="private", secret_nonce=secret_nonce, +) +private_progress = bridge.wait(bridge.execute(private_quote.plan, secret_nonce=secret_nonce)) +if private_progress.next == "complete": + private_progress = bridge.wait(bridge.complete(private_progress, secret_nonce=secret_nonce)) +``` + If the application closes or monitoring times out, the deposit may still be -in progress. Recover the existing transfer rather than sending USDC again. -Keep the same nonce for any remaining deposit or claim step; see +in progress. Recover the existing transfer rather than sending USDC again; a +supplied nonce must be given again for any remaining deposit or claim step. See [Recover Funds](#recover-funds). ## Shielding Assets @@ -921,7 +949,7 @@ does not prove delivery: the matching message event and exact USDC mint are requ For Arc → Aleo, select `source_chain="arc"`, `source_asset="usdc"`, and `destination_chain="aleo"`; public, record, and private mint modes work as on -Ethereum. Private mint completion still requires the original secret nonce. +Ethereum, including the derived claim nonce for the bridge's own account. Aleo → Arc burns use domain 26, enforce a two-USDCx minimum, and fetch a live withdrawal fee estimate again before proving. Provider errors are surfaced instead of presenting a static estimate as current. Public outbound xReserve delivery is diff --git a/bridge-sdk/codegen/gen_context.py b/bridge-sdk/codegen/gen_context.py index f0d7a09..e6851a3 100644 --- a/bridge-sdk/codegen/gen_context.py +++ b/bridge-sdk/codegen/gen_context.py @@ -124,11 +124,22 @@ ### Private mints and the secret nonce 6. `mint_mode="private"` (USDC → USDCx) commits `(recipient, secret_nonce)` on - Ethereum. The same `secret_nonce` is required by `complete`; the SDK - **never stores** it and checkpoints exclude it (and every other secret). - Tell the user to keep it (the default `0scalar` needs no storage but adds - no entropy). Only the recipient's Aleo key can complete a private mint — - make sure the recipient IS the configured Aleo address before depositing. + Ethereum, and `complete` must reveal the same nonce. When the recipient IS + the bridge's own Aleo address and a journal directory is bound (the + profile's, or `BRIDGE_CHECKPOINT_DIR`), the bridge derives the nonce from + the account's view key the way the Shield wallet does, keeps it in + `.private-mint-identities.json` beside the journal, and re-derives it if + that file is lost — no `secret_nonce` is needed anywhere. Its `.counter` + companion is what keeps each deposit on a fresh nonce: if BOTH files are + lost, tell the user to set the counter above the last one used before + depositing again, or a later deposit can be linked to an earlier one. A + deposit made under `eth.deposit_usdc`'s Tier-2 `0scalar` default still + completes without a nonce. A user-supplied + `secret_nonce` overrides that: the SDK **never stores** it and checkpoints + exclude it (and every other secret), so tell the user to keep it for + `complete` (the default `0scalar` adds no entropy). A deposit to any other + recipient needs a supplied nonce — only the recipient's Aleo key can + complete a private mint, so confirm the recipient before depositing. 7. Aleo-origin Hyperlane transfers spend PUBLIC balances: `unshield` a private record first. Hyperlane delivers into public balances; `shield` afterwards if the user wants privacy. Private xReserve burns spend records directly. diff --git a/bridge-sdk/examples/README.md b/bridge-sdk/examples/README.md index f18b603..95a93e2 100644 --- a/bridge-sdk/examples/README.md +++ b/bridge-sdk/examples/README.md @@ -32,7 +32,7 @@ real funds. Review a quote before submitting a new transfer. | [bridge_wbtc.py](bridge_wbtc.py) | Send WBTC and monitor its arrival as a public Aleo balance. | | [bridge_sol.py](bridge_sol.py) | Send native SOL from Solana to a public Aleo balance. | | [bridge_usdc_private_balance.py](bridge_usdc_private_balance.py) | Receive private USDCx automatically; the Ethereum deposit still reveals the Aleo recipient. | -| [bridge_usdc_private_recipient.py](bridge_usdc_private_recipient.py) | Hide the Aleo recipient in the deposit, then claim private USDCx using the retained nonce. | +| [bridge_usdc_private_recipient.py](bridge_usdc_private_recipient.py) | Hide the Aleo recipient in the deposit, then claim private USDCx with a nonce the bridge derives and keeps, or one you supply. | | [bridge_wbtc_to_ethereum.py](bridge_wbtc_to_ethereum.py) | Return public Aleo WBTC to Ethereum. | | [bridge_sol_to_solana.py](bridge_sol_to_solana.py) | Return public Aleo SOL as native SOL on Solana. | | [bridge_arc22_hyperlane.py](bridge_arc22_hyperlane.py) | Move BAT, USDG, or ZEC between Aleo and Ethereum or Solana, in either direction. | @@ -93,11 +93,14 @@ For USDC, choose `bridge_usdc_private_balance.py` for automatic private records, or `bridge_usdc_private_recipient.py` to hide the Aleo address in the deposit. Both default to 2 USDC. The Ethereum sender and deposited amount remain public. -The private recipient example needs `BRIDGE_MINT_SECRET_NONCE`: a securely -generated, nonzero Aleo scalar retained in your secret store **before** sending. -`str(aleo.mainnet.Scalar.random())` generates such a value. Supply the same saved -value when recovering or claiming; the journal deliberately excludes it. -The script claims automatically if the deposit becomes ready during monitoring. +The private recipient example derives the claim nonce from the recipient +account's view key and keeps it in `.private-mint-identities.json` beside the +journal; back that file up with the journal. To deposit under a nonce you +manage instead, set `BRIDGE_MINT_SECRET_NONCE` to a securely generated, nonzero +Aleo scalar (`str(aleo.mainnet.Scalar.random())`) retained in your secret store +**before** sending, and supply the same value when recovering or claiming; the +journal deliberately excludes it. The script claims automatically if the +deposit becomes ready during monitoring. RPCs default to public Ethereum, Solana, and Provable endpoints. Override `ETHEREUM_RPC_URL`, `SOLANA_RPC_URL`, or `ALEO_RPC_URL` when needed; all three must @@ -242,8 +245,9 @@ checkpoints also remain supported. For your own transfer, pass its ID and use `--action wait` to monitor. If the reported next step is `resume`, `--action resume` submits the unfinished source step. If it is `complete`, `--action complete` claims private USDCx using -`ALEO_PRIVATE_KEY` and the original `BRIDGE_MINT_SECRET_NONCE`. These actions use -the existing transfer; neither calls `execute` again. +`ALEO_PRIVATE_KEY` (and the original `BRIDGE_MINT_SECRET_NONCE` if the deposit +was made with one). These actions use the existing transfer; neither calls +`execute` again. If no files remain, get the confirmed WBTC bridge dispatch hash, original sender, recipient, and amount from Ethereum history. Pass those values as diff --git a/bridge-sdk/examples/bridge_usdc_private_recipient.py b/bridge-sdk/examples/bridge_usdc_private_recipient.py index dbbd3a3..0ff8c09 100644 --- a/bridge-sdk/examples/bridge_usdc_private_recipient.py +++ b/bridge-sdk/examples/bridge_usdc_private_recipient.py @@ -3,7 +3,9 @@ Choose Private Bridge when the Ethereum deposit should conceal the Aleo recipient. The Ethereum sender and amount remain public. Unlike automatic private-record delivery, this option requires the recipient to claim USDCx -with the original secret nonce and Aleo account. Save that nonce before sending. +with the recipient's Aleo account. The bridge derives the claim nonce from that +account's view key and keeps it beside the journal, so nothing needs saving by +hand; set BRIDGE_MINT_SECRET_NONCE to deposit under a nonce you manage instead. """ import os import sys @@ -40,12 +42,12 @@ def main(argv=None): store = FileCheckpointStore(args.journal) if args.execute else None bridge = Bridge(aleo, ethereum=ethereum, checkpoints=store) # The nonce conceals the Aleo address in the Ethereum deposit and unlocks the later claim. - # Retain the securely generated value separately BEFORE depositing; the journal omits it. - # Losing it prevents completion of the private claim through this flow. - nonce = os.environ.get("BRIDGE_MINT_SECRET_NONCE") - if not nonce: - parser.error("Set BRIDGE_MINT_SECRET_NONCE to the securely generated and saved Aleo scalar.") - if str(mainnet.Scalar.from_string(nonce)) == "0scalar": + # Left unset, the bridge derives it from the recipient account's view key and keeps it in + # .private-mint-identities.json beside the journal (re-deriving it if that file is lost). + # Set BRIDGE_MINT_SECRET_NONCE to use a nonce you manage: then retain it yourself BEFORE + # depositing, because the journal omits it and the claim needs the same value. + nonce = os.environ.get("BRIDGE_MINT_SECRET_NONCE") or None + if nonce and str(mainnet.Scalar.from_string(nonce)) == "0scalar": parser.error("The nonce must be a securely generated, nonzero scalar.") if args.execute and args.recipient != bridge.aleo_address(): parser.error("ALEO_PRIVATE_KEY must belong to --recipient for this example's claim.") @@ -82,7 +84,8 @@ def main(argv=None): if progress.next == "wait": progress = bridge.wait(progress, timeout_seconds=args.timeout) # "complete" means the deposit is ready to claim. The recipient signs an Aleo - # transaction using the same nonce; retain enough Aleo credits for its fee. + # transaction with the nonce the deposit committed to (derived again from the view key, or + # the value you supplied); retain enough Aleo credits for its fee. if progress.next == "complete": progress = bridge.complete( progress, secret_nonce=nonce, @@ -102,7 +105,7 @@ def main(argv=None): if progress.next == "resume": print("Recover this journal entry with --action resume to finish the source submission.") elif progress.next == "complete": - print("Recover with --action complete and the original nonce to claim private USDCx.") + print("Recover with --action complete to claim private USDCx (pass the original nonce if you supplied one).") return 2 # Pending or awaiting another action, not a reason to deposit again. diff --git a/bridge-sdk/examples/recover_from_journal.py b/bridge-sdk/examples/recover_from_journal.py index 5b4e7a8..82ddbb7 100644 --- a/bridge-sdk/examples/recover_from_journal.py +++ b/bridge-sdk/examples/recover_from_journal.py @@ -58,13 +58,13 @@ def main(argv=None): # Recovery reads the existing transfer; it never sends a replacement deposit. progress = bridge.recover(cp) if args.action in ("resume", "complete") and progress.next == args.action: - # Private USDCx still needs the original claim secret; the journal cannot replace it. + # A private USDCx deposit made under a nonce you supplied needs that ORIGINAL value again; + # the journal cannot replace it. A nonce the bridge derived is found in the identities + # file beside the journal, or re-derived from the account's view key. nonce = None if cp.intent.get("mintMode") == "private": - nonce = os.environ.get("BRIDGE_MINT_SECRET_NONCE") - if not nonce: - parser.error("Set BRIDGE_MINT_SECRET_NONCE to the ORIGINAL saved nonce.") - if str(mainnet.Scalar.from_string(nonce)) == "0scalar": + nonce = os.environ.get("BRIDGE_MINT_SECRET_NONCE") or None + if nonce and str(mainnet.Scalar.from_string(nonce)) == "0scalar": parser.error("The private claim nonce must be nonzero.") # Only perform the step the transfer currently requests and the caller selected. if args.action == "resume": @@ -74,7 +74,7 @@ def main(argv=None): on_checkpoint=lambda checkpoint: print("Journal ID:", checkpoint.id, flush=True), ) elif args.action == "complete": - # Claim private USDCx with the recipient account and original nonce; pays an Aleo fee. + # Claim private USDCx with the recipient account and its nonce; pays an Aleo fee. progress = bridge.complete( progress, secret_nonce=nonce, on_checkpoint=lambda checkpoint: print("Journal ID:", checkpoint.id, flush=True), @@ -93,7 +93,7 @@ def main(argv=None): if progress.next == "resume": print("Recover this journal entry with --action resume to finish the source submission.") elif progress.next == "complete": - print("Recover with --action complete and the original nonce to claim private USDCx.") + print("Recover with --action complete to claim private USDCx (pass the original nonce if you supplied one).") return 2 # Pending or awaiting another action, not a reason to deposit again. diff --git a/bridge-sdk/python/aleo_bridge/AGENTS.md b/bridge-sdk/python/aleo_bridge/AGENTS.md index fb66018..9be9f75 100644 --- a/bridge-sdk/python/aleo_bridge/AGENTS.md +++ b/bridge-sdk/python/aleo_bridge/AGENTS.md @@ -61,7 +61,7 @@ back are dict entries instead — ``{"next": "failed", "error", "error_type"}`` when no store is bound. Finish any entry with ``recover`` → ``wait`` / ``resume`` / ``complete``, never by starting a new transfer. -### `quote(self, *, source_chain: 'str | None' = None, source_asset: 'str | None' = None, destination_chain: 'str | None' = None, destination_asset: 'str | None' = None, bridge_protocol: 'str | None' = None, route=None, amount=None, amount_atomic=None, recipient: 'str', sender: 'str | None' = None, mint_mode: 'str' = 'public', secret_nonce: 'str' = '0scalar', cctp=None)` +### `quote(self, *, source_chain: 'str | None' = None, source_asset: 'str | None' = None, destination_chain: 'str | None' = None, destination_asset: 'str | None' = None, bridge_protocol: 'str | None' = None, route=None, amount=None, amount_atomic=None, recipient: 'str', sender: 'str | None' = None, mint_mode: 'str' = 'public', secret_nonce: 'str | None' = None, cctp=None)` Price a transfer and get the plan that ``execute`` takes. Nothing is signed. @@ -73,7 +73,10 @@ only when more than one route fits — or ``route=`` with a ``Route`` from or ``amount_atomic`` (int). ``recipient`` is the destination-chain address. ``mint_mode`` (xReserve into Aleo only): ``"public"`` balance, ``"record"`` minted by the relayer, or ``"private"`` — you finish it -yourself with ``complete`` and must keep ``secret_nonce``. Returns a +yourself with ``complete``. For a private mint to the bridge's own +account the nonce is derived from the view key and kept in the bound +identity store, so no ``secret_nonce`` is needed; pass your own to +override it (then keep it). Returns a kind-specific ``Quote`` (``quote.kind`` in evm-hyperlane / solana-hyperlane / aleo-hyperlane / evm-xreserve / aleo-xreserve) with ``fees`` and ``amount_out`` in human units and ``quote.plan``. Show the @@ -127,14 +130,18 @@ Finish an interrupted source submission (``progress.next == "resume"``). Rebroadcasts the identical proved Aleo transaction (a duplicate response is success) or, on EVM, re-scans history and only then authorizes the single -missing deposit/dispatch. Never repeats a confirmed step. +missing deposit/dispatch. Never repeats a confirmed step. A private +xReserve deposit for the bridge's own account finds its nonce in the bound +identity store; pass ``secret_nonce`` only if the deposit was made with one. ### `complete(self, progress, *, secret_nonce: 'str | None' = None, on_checkpoint=None, proving: 'str' = 'delegate', manual_mint: 'bool' = False)` Claim a private USDCx or native-USDC CCTP destination mint. -Requires the same ``secret_nonce`` given to ``execute``; the SDK never -stored it. Submits exactly one ``private_mint`` and returns +A private USDCx mint to the bridge's own account needs no ``secret_nonce``: +the scalar comes from the bound identity store, re-derived from the view +key if the store lost it. A deposit made with a caller-supplied nonce +needs that same nonce back. Submits exactly one ``private_mint`` and returns ``DESTINATION_CONFIRMING`` progress to ``wait`` on. CCTP needs no secret nonce, but requires a destination signer and gas. Set ``manual_mint=True`` to explicitly authorize fallback for stalled forwarding; an already @@ -216,11 +223,22 @@ still in flight and check again later. ### Private mints and the secret nonce 6. `mint_mode="private"` (USDC → USDCx) commits `(recipient, secret_nonce)` on - Ethereum. The same `secret_nonce` is required by `complete`; the SDK - **never stores** it and checkpoints exclude it (and every other secret). - Tell the user to keep it (the default `0scalar` needs no storage but adds - no entropy). Only the recipient's Aleo key can complete a private mint — - make sure the recipient IS the configured Aleo address before depositing. + Ethereum, and `complete` must reveal the same nonce. When the recipient IS + the bridge's own Aleo address and a journal directory is bound (the + profile's, or `BRIDGE_CHECKPOINT_DIR`), the bridge derives the nonce from + the account's view key the way the Shield wallet does, keeps it in + `.private-mint-identities.json` beside the journal, and re-derives it if + that file is lost — no `secret_nonce` is needed anywhere. Its `.counter` + companion is what keeps each deposit on a fresh nonce: if BOTH files are + lost, tell the user to set the counter above the last one used before + depositing again, or a later deposit can be linked to an earlier one. A + deposit made under `eth.deposit_usdc`'s Tier-2 `0scalar` default still + completes without a nonce. A user-supplied + `secret_nonce` overrides that: the SDK **never stores** it and checkpoints + exclude it (and every other secret), so tell the user to keep it for + `complete` (the default `0scalar` adds no entropy). A deposit to any other + recipient needs a supplied nonce — only the recipient's Aleo key can + complete a private mint, so confirm the recipient before depositing. 7. Aleo-origin Hyperlane transfers spend PUBLIC balances: `unshield` a private record first. Hyperlane delivers into public balances; `shield` afterwards if the user wants privacy. Private xReserve burns spend records directly. @@ -290,10 +308,15 @@ from the published list and cached until the list changes. Pass one must exceed the 2 USDCx withdrawal fee. The Aleo burn-attestation service forwards accepted burns to Circle. -### `xreserve.private_mint(self, attestation: 'Attestation', recipient: 'str', *, secret_nonce: 'str' = '0scalar', route: 'Route | None' = None) -> 'AleoCall[MintReceipt]'` +### `xreserve.private_mint(self, attestation: 'Attestation', recipient: 'str', *, secret_nonce: 'str | None' = None, route: 'Route | None' = None) -> 'AleoCall[MintReceipt]'` Finish a private-mode deposit: the only user-signed Aleo step of the inbound flow (``wrapper.private_mint``). +An explicit ``secret_nonce`` is used as given. Without one, the bridge resolves the scalar +for its own account from the identity store that reserved the deposit (re-deriving it from +the view key when the store lacks it); a deposit made with a caller-supplied nonce needs +that nonce back. + ### `xreserve.get_attestation(self, message_hash: "'str | bytes'", *, route: 'Route | None' = None) -> 'Attestation | None'` One Circle request for *message_hash*; ``None`` while pending (404). diff --git a/bridge-sdk/python/aleo_bridge/__init__.py b/bridge-sdk/python/aleo_bridge/__init__.py index 4bc7377..c9c2431 100644 --- a/bridge-sdk/python/aleo_bridge/__init__.py +++ b/bridge-sdk/python/aleo_bridge/__init__.py @@ -53,6 +53,8 @@ def _require_aleo_sdk() -> None: from .circle import CircleClient # noqa: E402 from .client import Bridge # noqa: E402 from .eth import Ethereum, EthModule # noqa: E402 +from .private_mint import (FilePrivateMintIdentityStore, MemoryPrivateMintIdentityStore, PrivateMintIdentity, # noqa: E402 + PrivateMintIdentityStore) from .freezelist import EMPTY_MERKLE_PROOF_PAIR, ComplianceProof, ComplianceProofs, FreezeList, MerkleProofInput, MerkleProofPair # noqa: E402 from .hyperlane import HyperlaneModule # noqa: E402 from . import lifecycle # noqa: E402 @@ -78,6 +80,7 @@ def _require_aleo_sdk() -> None: "ComplianceProof", "ComplianceProofs", "MerkleProofInput", "MerkleProofPair", "HyperlaneModule", "PrivacyModule", "Profile", "XReserveModule", "Checkpoint", "CheckpointLoadResult", "CheckpointProblem", "CheckpointStore", "FileCheckpointStore", "create_checkpoint", + "FilePrivateMintIdentityStore", "MemoryPrivateMintIdentityStore", "PrivateMintIdentity", "PrivateMintIdentityStore", "EthModule", "Ethereum", "EvmCall", "DEFAULT_SOLANA_RPC_URL", "Solana", "SolCall", "SolModule", "lifecycle", "prepare", diff --git a/bridge-sdk/python/aleo_bridge/_fs.py b/bridge-sdk/python/aleo_bridge/_fs.py new file mode 100644 index 0000000..83a46c7 --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/_fs.py @@ -0,0 +1,65 @@ +"""Filesystem primitives shared by the checkpoint journal and the private mint identity store: +a cross-process counter lock and an atomic 0600 file write.""" +from __future__ import annotations + +import os +import tempfile +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator + + +@contextmanager +def counter_lock(path: Path) -> Iterator[None]: + """Hold an exclusive lock on *path* (created 0600 if missing) for the duration of the block. + + A separate, persistent lock inode survives atomic replacement of the data it guards. Uses + ``fcntl.flock`` on POSIX and ``msvcrt.locking`` on Windows, so instances in other processes + that share the path serialize behind it. + """ + fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600) + with os.fdopen(fd, "r+b") as lock: + if os.name == "nt": + import msvcrt + if not path.stat().st_size: + lock.write(b"0") + lock.flush() + lock.seek(0) + msvcrt.locking(lock.fileno(), msvcrt.LK_LOCK, 1) + else: + import fcntl + fcntl.flock(lock.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + if os.name == "nt": + lock.seek(0) + msvcrt.locking(lock.fileno(), msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(lock.fileno(), fcntl.LOCK_UN) + + +def atomic_write_text(directory: Path, target: Path, text: str) -> None: + """Write *text* (plus a trailing newline) to *target* via a fsynced temp file in *directory*, + chmod 0600, then ``os.replace`` — readers see the old file or the new one, never a partial.""" + fd, tmp = tempfile.mkstemp(dir=directory, prefix=".", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(text) + fh.write("\n") + fh.flush() + os.fsync(fh.fileno()) + os.chmod(tmp, 0o600) + os.replace(tmp, target) + if os.name != "nt": + directory_fd = os.open(directory, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except BaseException: + try: + os.unlink(tmp) + except FileNotFoundError: + pass + raise diff --git a/bridge-sdk/python/aleo_bridge/agent.py b/bridge-sdk/python/aleo_bridge/agent.py index 1746f03..a5b3b9d 100644 --- a/bridge-sdk/python/aleo_bridge/agent.py +++ b/bridge-sdk/python/aleo_bridge/agent.py @@ -19,7 +19,8 @@ stays on the quote and inside the checkpoint, because ``lifecycle.resume`` refuses to resume a deposit whose checkpoint has lost it.) * **A private mint never gets a default secret.** ``mint_mode="private"`` - without a ``secret_nonce`` is a structured error, never a quiet ``"0scalar"`` + without a ``secret_nonce`` either derives one (the bridge's own account, an + identity store bound) or is a structured error — never a quiet ``"0scalar"`` that commits to a hook nobody can reproduce. * **Failures come back as data.** Every :class:`~aleo_bridge.errors.BridgeError` is rendered ``{"error", "error_type", "how_to_fix"}`` so the model can fix the @@ -52,8 +53,10 @@ "ethereum=Ethereum(...) to Bridge(...)) and retry") SOLANA_HOW_TO_FIX = ("set SOLANA_PRIVATE_KEY (and optionally SOLANA_RPC_URL) in the environment (or pass " "solana=Solana(...) to Bridge(...)) and retry") -NONCE_HOW_TO_FIX = ("re-call with secret_nonce set to the value the user kept from execute — the SDK " - "never stored it, and no other value can open the commitment") +NONCE_HOW_TO_FIX = ("either re-call with secret_nonce set to the value the user kept (a deposit made with a " + "supplied nonce opens only with that nonce), or let the bridge derive and keep the nonce " + "itself: the recipient must be the bridge's own Aleo address and a checkpoint directory " + "(or private_mint_identities=) must be bound to Bridge(...)") RECOVER_HOW_TO_FIX = ("the source step may already be on the wire: call bridge_get_progress with the " "checkpoint (or bridge_pending) before doing anything else — never bridge_execute again") @@ -195,11 +198,18 @@ def _connection_gap(bridge: Any, source_chain: Any) -> dict[str, Any] | None: def _missing_nonce(what: str) -> dict[str, Any]: return _error_payload(ConfigurationError( - f"a secret_nonce is required for a private mint: {what} commits to (recipient, secret_nonce) " - "and bridge_complete needs the same value again — keep it, the SDK never stores it"), + f"a private mint needs a secret_nonce and this bridge cannot derive one: {what} commits to " + "(recipient, secret_nonce). Pass the user's own secret_nonce (and keep it for bridge_complete), or " + "make the recipient the bridge's own Aleo address with a checkpoint directory or " + "private_mint_identities= bound so the nonce is derived and kept"), how_to_fix=NONCE_HOW_TO_FIX) +def _can_derive(bridge: Any, recipient: Any) -> bool: + from .private_mint import can_derive + return isinstance(recipient, str) and can_derive(bridge, recipient) + + # ── shared argument handling ────────────────────────────────────────────────── _QUOTE_PROPS = { @@ -216,9 +226,12 @@ def _missing_nonce(what: str) -> dict[str, Any]: "speed": {**_S, "enum": ["standard", "fast"]}, "forwarding": _B, "max_fee": {**_S, "description": "Maximum USDC deduction, preserved through recovery and fee refresh."}}}, "mint_mode": {**_S, "enum": ["public", "record", "private"], - "description": "xReserve into Aleo only. 'private' requires the user to complete the mint later with the same secret_nonce."}, - "secret_nonce": {**_S, "description": "Private-mint commitment secret, REQUIRED when mint_mode is 'private' " - "(there is no default). The user must keep it for bridge_complete; the SDK never stores it."}, + "description": "xReserve into Aleo only. 'private' requires the recipient to complete the mint later " + "with bridge_complete."}, + "secret_nonce": {**_S, "description": "Optional private-mint commitment secret. Omit it when the recipient is the " + "bridge's own Aleo account and a journal directory is bound: the nonce is " + "derived from the view key and kept in the identity store. Pass the user's " + "own to override — then they must keep it for bridge_complete."}, } _QUOTE_REQUIRED = ["source_chain", "source_asset", "destination_chain", "amount", "recipient"] _CONFIRM = {"confirm": {**_B, "description": "Set true to move funds. Without it the quote is returned and nothing is submitted."}} @@ -232,20 +245,22 @@ def _missing_nonce(what: str) -> dict[str, Any]: def _quote_kwargs(args: dict[str, Any]) -> dict[str, Any]: - """Quote inputs. ``secret_nonce`` defaults to ``"0scalar"`` only for a non-private mint — - a private one must carry its own (checked by :func:`_private_nonce_gap` first).""" + """Quote inputs. ``secret_nonce`` passes through as given (None lets the lifecycle derive or, for a + quote, use its placeholder); ``bridge_execute`` checks :func:`_private_nonce_gap` first.""" mint_mode = args.get("mint_mode") or "public" secret_nonce = args.get("secret_nonce") return dict(source_chain=args["source_chain"], source_asset=args["source_asset"], destination_chain=args["destination_chain"], destination_asset=args.get("destination_asset"), bridge_protocol=args.get("bridge_protocol"), amount=str(args["amount"]), recipient=args["recipient"], sender=args.get("sender"), mint_mode=mint_mode, - secret_nonce=secret_nonce if mint_mode == "private" else (secret_nonce or "0scalar"), + secret_nonce=secret_nonce or None, **({"cctp": args["cctp"]} if "cctp" in args else {})) -def _private_nonce_gap(args: dict[str, Any]) -> dict[str, Any] | None: - if (args.get("mint_mode") or "public") == "private" and not args.get("secret_nonce"): +def _private_nonce_gap(bridge: Any, args: dict[str, Any]) -> dict[str, Any] | None: + """The structured error for a private deposit that neither carries a nonce nor can derive one.""" + if ((args.get("mint_mode") or "public") == "private" and not args.get("secret_nonce") + and not _can_derive(bridge, args.get("recipient"))): return _missing_nonce("the deposit") return None @@ -302,7 +317,8 @@ def _h_list_routes(b, a): def _h_quote(b, a): - gap = _private_nonce_gap(a) or _connection_gap(b, a.get("source_chain")) + # A quote never needs a nonce: it prices the transfer and deposits nothing. bridge_execute gates. + gap = _connection_gap(b, a.get("source_chain")) if gap is not None: return gap return _serialize(lifecycle.quote(b, **_quote_kwargs(a)), b.registry) @@ -355,7 +371,7 @@ def _h_pending(b, a): # ── writes (confirm-gated) ──────────────────────────────────────────────────── def _h_execute(b, a): - gap = _private_nonce_gap(a) or _connection_gap(b, a.get("source_chain")) + gap = _private_nonce_gap(b, a) or _connection_gap(b, a.get("source_chain")) if gap is not None: return gap quote = lifecycle.quote(b, **_quote_kwargs(a)) @@ -385,7 +401,8 @@ def _resume_needs_nonce(bridge: Any, progress: Any) -> bool: def _h_resume(b, a): progress = lifecycle.recover(b, a["checkpoint"]) # reads only - if _resume_needs_nonce(b, progress) and not a.get("secret_nonce"): + if (_resume_needs_nonce(b, progress) and not a.get("secret_nonce") + and not _can_derive(b, progress.plan.recipient)): # Pre-checked like _h_complete: lifecycle.resume refuses this before any RPC, but routed # through _write that refusal would come back as next: "recover" — "it may already be on # the wire" — for a transfer that never left the process. Say what is actually missing. @@ -405,7 +422,8 @@ def _has_prepared_destination(progress: Any) -> bool: def _h_complete(b, a): progress = lifecycle.recover(b, a["checkpoint"]) # reads only secret_nonce = a.get("secret_nonce") - if progress.plan.protocol != "cctp" and not secret_nonce and not _has_prepared_destination(progress): + if (progress.plan.protocol != "cctp" and not secret_nonce and not _has_prepared_destination(progress) + and not _can_derive(b, progress.plan.recipient)): return _missing_nonce("the deposit this mint finishes") if not a.get("confirm"): return _confirmation(progress=_serialize(progress, b.registry)) @@ -452,7 +470,7 @@ def _h_unshield(b, a): ("bridge_quote", "Validate and price a transfer: route, fees (human units), amount_out, approval needs. Reads chain state, " "never signs. ALWAYS quote before bridge_execute and show the user fees and amount_out. A private mint " - "(mint_mode='private') must carry the user's own secret_nonce — there is no default.", + "(mint_mode='private') needs no secret_nonce to quote.", _schema(_QUOTE_PROPS, _QUOTE_REQUIRED), _h_quote), ("bridge_get_progress", "Recover a transfer's state from a checkpoint (reads only). Returns {progress, checkpoint}: progress.next tells " @@ -471,7 +489,8 @@ def _h_unshield(b, a): "Start a transfer: re-quotes the same inputs, then commits funds on the source chain. Requires confirm=true; " "without it returns the quote and moves nothing. The source step is IRREVERSIBLE once broadcast — afterwards " "use bridge_get_progress with the returned checkpoint, never bridge_execute again. For mint_mode=private the " - "user must supply secret_nonce here (no default) and keep it for bridge_complete.", + "bridge derives and keeps the nonce when the recipient is its own Aleo account and a journal is bound; a " + "user-supplied secret_nonce overrides that and must then be kept for bridge_complete.", _schema({**_QUOTE_PROPS, "mode": {**_S, "description": "Aleo-origin only: caller|signer (Hyperlane) or " "private|public|public-as-signer (xReserve burn)."}, "gas_payment_microcredits": _I, "proving": {**_S, "enum": ["delegate", "local"]}, **_CONFIRM}, @@ -479,17 +498,21 @@ def _h_unshield(b, a): ("bridge_resume", "Finish an interrupted source submission (progress.next == 'resume'): rebroadcasts the identical proved Aleo " "transaction or authorizes the single missing EVM step after re-scanning history. Requires confirm=true. " - "An interrupted EVM xReserve deposit needs the same secret_nonce used at execute.", - _schema({**_CHECKPOINT, "secret_nonce": {**_S, "description": "The value used at bridge_execute; required to " - "resume a private-mint xReserve deposit."}, + "An interrupted private EVM xReserve deposit finds its nonce in the bridge's identity store; only a " + "deposit made with a user-supplied secret_nonce needs that same value again.", + _schema({**_CHECKPOINT, "secret_nonce": {**_S, "description": "Only for a private-mint deposit made with a " + "user-supplied nonce: the same value. A derived " + "nonce is resolved from the identity store."}, **_CONFIRM}, ["checkpoint"]), _h_resume), ("bridge_complete", - "Complete a private USDCx or CCTP mint. Private USDCx requires the original secret_nonce. " - "CCTP forwarding fallback requires manual_mint=true. Requires confirm=true.", + "Complete a private USDCx or CCTP mint. A private USDCx mint to the bridge's own account resolves its nonce " + "from the identity store (re-derived from the view key if the store lost it); a deposit made with a " + "user-supplied secret_nonce needs that value again. CCTP forwarding fallback requires manual_mint=true. " + "Requires confirm=true.", _schema({**_CHECKPOINT, "manual_mint": {**_B, "description": "Explicitly mint a stalled CCTP forwarded transfer."}, - "secret_nonce": {**_S, "description": "The private-mint secret used at bridge_execute — required " - "(there is no default); only an already-proved mint can be " - "rebroadcast without it."}, + "secret_nonce": {**_S, "description": "Only for a private-mint deposit made with a user-supplied " + "nonce: the same value. A derived nonce is resolved from the " + "identity store; an already-proved mint rebroadcasts without one."}, **_CONFIRM}, ["checkpoint"]), _h_complete), ("bridge_shield", "Move a public Aleo balance of a bridged asset (aleo/eth, aleo/wbtc, aleo/usdt, aleo/sol, aleo/usdcx) into a " diff --git a/bridge-sdk/python/aleo_bridge/checkpoint.py b/bridge-sdk/python/aleo_bridge/checkpoint.py index 4cc603f..ee8ba3f 100644 --- a/bridge-sdk/python/aleo_bridge/checkpoint.py +++ b/bridge-sdk/python/aleo_bridge/checkpoint.py @@ -10,15 +10,13 @@ from __future__ import annotations import json -import os import re -import tempfile -from contextlib import contextmanager from datetime import datetime, timezone from dataclasses import dataclass from pathlib import Path from typing import Any, Protocol, runtime_checkable +from ._fs import atomic_write_text, counter_lock from .errors import BridgeError, CheckpointInvalidError from .registry import Registry from .types import Plan, Receipt @@ -260,29 +258,7 @@ def delete(self, checkpoint_id: str) -> None: ... _JOURNAL_ID = re.compile(r"\d{4}-\d{2}-\d{2}_\d{3,}_[A-Za-z0-9_-]+_to_[A-Za-z0-9_-]+_[0-9]+(?:\.[0-9]+)?") -@contextmanager -def _counter_lock(path: Path): - # A separate, persistent lock inode survives atomic replacement of counter data. - fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600) - with os.fdopen(fd, "r+b") as lock: - if os.name == "nt": - import msvcrt - if not path.stat().st_size: - lock.write(b"0") - lock.flush() - lock.seek(0) - msvcrt.locking(lock.fileno(), msvcrt.LK_LOCK, 1) - else: - import fcntl - fcntl.flock(lock.fileno(), fcntl.LOCK_EX) - try: - yield - finally: - if os.name == "nt": - lock.seek(0) - msvcrt.locking(lock.fileno(), msvcrt.LK_UNLCK, 1) - else: - fcntl.flock(lock.fileno(), fcntl.LOCK_UN) +_counter_lock = counter_lock # journal metadata lock, shared with the private mint identity store _UNSAFE = re.compile(r"[^A-Za-z0-9_-]") @@ -342,27 +318,7 @@ def save(self, checkpoint: Checkpoint) -> None: self._write(self._path(checkpoint.id), checkpoint.to_json()) def _write(self, target: Path, text: str) -> None: - fd, tmp = tempfile.mkstemp(dir=self.directory, prefix=".", suffix=".tmp") - try: - with os.fdopen(fd, "w", encoding="utf-8") as fh: - fh.write(text) - fh.write("\n") - fh.flush() - os.fsync(fh.fileno()) - os.chmod(tmp, 0o600) - os.replace(tmp, target) - if os.name != "nt": - directory_fd = os.open(self.directory, os.O_RDONLY) - try: - os.fsync(directory_fd) - finally: - os.close(directory_fd) - except BaseException: - try: - os.unlink(tmp) - except FileNotFoundError: - pass - raise + atomic_write_text(self.directory, target, text) def load(self, checkpoint_id: str) -> Checkpoint | None: path = self._path(checkpoint_id) diff --git a/bridge-sdk/python/aleo_bridge/client.py b/bridge-sdk/python/aleo_bridge/client.py index 68880c6..f045598 100644 --- a/bridge-sdk/python/aleo_bridge/client.py +++ b/bridge-sdk/python/aleo_bridge/client.py @@ -16,6 +16,7 @@ from . import lifecycle as _lifecycle from . import _evm_connections +from . import private_mint as _private_mint from ._calls import AleoCall from .cctp import CctpModule from .errors import BridgeError, ConfigurationError, UnsupportedRouteError @@ -124,7 +125,8 @@ class Bridge: """Typed bridge client over the Aleo facade (and, from plans 2/3, Ethereum/Solana connections).""" def __init__(self, aleo: Any, *, ethereum: Any = None, solana: Any = None, environment: str | None = None, - registry: Registry | None = None, checkpoints: Any = None, evm: Any = None) -> None: + registry: Registry | None = None, checkpoints: Any = None, evm: Any = None, + private_mint_identities: Any = None) -> None: network = getattr(aleo, "network_name", None) if network not in NETWORKS: raise ConfigurationError(f"The aleo facade must report network_name mainnet or testnet, got {network!r}") @@ -140,6 +142,10 @@ def __init__(self, aleo: Any, *, ethereum: Any = None, solana: Any = None, envir if not self.registry.chains(environment=environment): raise ConfigurationError(f"Registry {self.registry.version} has no chains for {environment}") self.checkpoints = checkpoints + #: Where derived private-mint nonces live: the given store, or a hidden file beside a bound + #: ``FileCheckpointStore`` journal, or None (then a private mint needs ``secret_nonce=``). + self.private_mint_identities: Any = (private_mint_identities if private_mint_identities is not None + else _private_mint.default_identity_store(checkpoints)) self.ethereum: Ethereum | None = _coerce_ethereum(ethereum) self._evm_connections = _evm_connections.normalize(self.registry, environment, evm, self.ethereum) self._evm_modules: dict[str, EthModule] = {} @@ -353,7 +359,7 @@ def routes(self, *, source_chain: str | None = None, source_asset: str | None = def quote(self, *, source_chain: str | None = None, source_asset: str | None = None, destination_chain: str | None = None, destination_asset: str | None = None, bridge_protocol: str | None = None, route=None, amount=None, amount_atomic=None, recipient: str, - sender: str | None = None, mint_mode: str = "public", secret_nonce: str = "0scalar", cctp=None): + sender: str | None = None, mint_mode: str = "public", secret_nonce: str | None = None, cctp=None): """Price a transfer and get the plan that ``execute`` takes. Nothing is signed. Name the route the way veil's ``quote`` does: ``source_chain`` + ``source_asset`` @@ -364,7 +370,10 @@ def quote(self, *, source_chain: str | None = None, source_asset: str | None = N or ``amount_atomic`` (int). ``recipient`` is the destination-chain address. ``mint_mode`` (xReserve into Aleo only): ``"public"`` balance, ``"record"`` minted by the relayer, or ``"private"`` — you finish it - yourself with ``complete`` and must keep ``secret_nonce``. Returns a + yourself with ``complete``. For a private mint to the bridge's own + account the nonce is derived from the view key and kept in the bound + identity store, so no ``secret_nonce`` is needed; pass your own to + override it (then keep it). Returns a kind-specific ``Quote`` (``quote.kind`` in evm-hyperlane / solana-hyperlane / aleo-hyperlane / evm-xreserve / aleo-xreserve) with ``fees`` and ``amount_out`` in human units and ``quote.plan``. Show the @@ -443,7 +452,9 @@ def resume(self, progress, *, on_checkpoint=None, secret_nonce: str | None = Non Rebroadcasts the identical proved Aleo transaction (a duplicate response is success) or, on EVM, re-scans history and only then authorizes the single - missing deposit/dispatch. Never repeats a confirmed step. + missing deposit/dispatch. Never repeats a confirmed step. A private + xReserve deposit for the bridge's own account finds its nonce in the bound + identity store; pass ``secret_nonce`` only if the deposit was made with one. """ return _lifecycle.resume(self, progress, on_checkpoint=on_checkpoint, secret_nonce=secret_nonce, poll_seconds=poll_seconds, timeout_seconds=timeout_seconds, proving=proving) @@ -452,8 +463,10 @@ def complete(self, progress, *, secret_nonce: str | None = None, on_checkpoint=N manual_mint: bool = False): """Claim a private USDCx or native-USDC CCTP destination mint. - Requires the same ``secret_nonce`` given to ``execute``; the SDK never - stored it. Submits exactly one ``private_mint`` and returns + A private USDCx mint to the bridge's own account needs no ``secret_nonce``: + the scalar comes from the bound identity store, re-derived from the view + key if the store lost it. A deposit made with a caller-supplied nonce + needs that same nonce back. Submits exactly one ``private_mint`` and returns ``DESTINATION_CONFIRMING`` progress to ``wait`` on. CCTP needs no secret nonce, but requires a destination signer and gas. Set ``manual_mint=True`` to explicitly authorize fallback for stalled forwarding; an already @@ -501,7 +514,7 @@ def pending(self) -> list: @classmethod def from_env(cls, **overrides: Any) -> "Bridge": """Everything from the environment (spec §3.3); writes nothing to disk. Overrides: ethereum, solana, registry, checkpoints.""" - unexpected = set(overrides) - {"ethereum", "solana", "registry", "checkpoints", "evm"} + unexpected = set(overrides) - {"ethereum", "solana", "registry", "checkpoints", "evm", "private_mint_identities"} if unexpected: raise TypeError(f"Bridge.from_env() got unexpected overrides: {sorted(unexpected)}") private_key = os.environ.get("BRIDGE_PRIVATE_KEY") @@ -514,7 +527,8 @@ def from_env(cls, **overrides: Any) -> "Bridge": ethereum_from_env() if _evm_connections.needs_legacy_environment(evm) else None) solana = overrides["solana"] if "solana" in overrides else solana_from_env() checkpoints = overrides["checkpoints"] if "checkpoints" in overrides else checkpoints_from_env() - return cls(aleo, ethereum=ethereum, solana=solana, registry=overrides.get("registry"), checkpoints=checkpoints, evm=evm) + return cls(aleo, ethereum=ethereum, solana=solana, registry=overrides.get("registry"), checkpoints=checkpoints, evm=evm, + private_mint_identities=overrides.get("private_mint_identities")) @classmethod def from_profile(cls, home: Any = None, *, network: str | None = None, endpoint: str | None = None, diff --git a/bridge-sdk/python/aleo_bridge/encoding.py b/bridge-sdk/python/aleo_bridge/encoding.py index 725322a..70a46a4 100644 --- a/bridge-sdk/python/aleo_bridge/encoding.py +++ b/bridge-sdk/python/aleo_bridge/encoding.py @@ -263,16 +263,22 @@ def xreserve_hook_data(mode: str, recipient: str, network: str, secret_nonce: st out = bytearray(HOOK_DATA_BYTES) out[0] = MINT_MODES.index(mode) if mode == "private": - validate_scalar(secret_nonce) - net = network_module(network) - bits = net.Plaintext.from_string(recipient).to_bits_le() - commitment = field_bytes_le(net.BHP256().commit(bits, net.Scalar.from_string(secret_nonce))) - if len(commitment) != 32: - raise ConfigurationError("Private mint commitment must contain 32 bytes") - out[1:33] = commitment + out[1:33] = private_mint_commitment(recipient, secret_nonce, network) return bytes(out) +def private_mint_commitment(recipient: str, secret_nonce: str, network: str) -> bytes: + """The 32 little-endian bytes of ``BHP256.commit(bits(recipient), secret_nonce)`` — the public + recipient commitment a private xReserve deposit carries in hook bytes 1..33.""" + validate_scalar(secret_nonce) + net = network_module(network) + bits = net.Plaintext.from_string(recipient).to_bits_le() + commitment = field_bytes_le(net.BHP256().commit(bits, net.Scalar.from_string(secret_nonce))) + if len(commitment) != 32: + raise ConfigurationError("Private mint commitment must contain 32 bytes") + return commitment + + def aleo_program_address(program_id: str, network: str) -> str: """The ``aleo1…`` account owned by a deployed program (private xReserve deposits target the wrapper's).""" return str(network_module(network).Address.from_program_id(program_id)) @@ -282,7 +288,8 @@ def aleo_program_address(program_id: str, network: str) -> str: "BECH32_ALPHABET", "HOOK_DATA_BYTES", "MINT_MODES", "NETWORKS", "XRESERVE_PAYLOAD_BYTES", "aleo_address_to_bytes32", "aleo_program_address", "bytes32_to_aleo_address", "bytes32_to_u128_limbs", "evm_address_to_bytes32", "evm_address_to_hyperlane_recipient", "field_bytes_le", "hex_to_bytes", - "hyperlane_delivery_key", "is_evm_address", "network_module", "solana_address_to_hyperlane_recipient", + "hyperlane_delivery_key", "is_evm_address", "network_module", "private_mint_commitment", + "solana_address_to_hyperlane_recipient", "to_checksum_address", "to_hex", "u128_pair_literal", "u8_array_literal", "validate_scalar", "xreserve_deposit_nonce", "xreserve_deposit_payload", "xreserve_hook_data", "xreserve_message_hash", "xreserve_nonce_from_payload", diff --git a/bridge-sdk/python/aleo_bridge/lifecycle.py b/bridge-sdk/python/aleo_bridge/lifecycle.py index e710ee5..f555317 100644 --- a/bridge-sdk/python/aleo_bridge/lifecycle.py +++ b/bridge-sdk/python/aleo_bridge/lifecycle.py @@ -22,6 +22,7 @@ from typing import Any, Callable, cast from . import _sealevel +from . import private_mint as _private_mint from ._calls import is_duplicate_submission from .encoding import HOOK_DATA_BYTES from ._plan import build_plan @@ -210,9 +211,13 @@ def quote(bridge, *, source_chain: str | None = None, source_asset: str | None = destination_chain: str | None = None, destination_asset: str | None = None, bridge_protocol: str | None = None, route: "Route | str | None" = None, amount=None, amount_atomic=None, recipient: str, sender: str | None = None, - mint_mode: str = "public", secret_nonce: str = "0scalar", cctp=None) -> Quote: + mint_mode: str = "public", secret_nonce: str | None = None, cctp=None) -> Quote: """Price a transfer: ``prepare`` + the source-side live read for the route kind. + A private xReserve quote never needs a nonce: an explicit ``secret_nonce`` is used as given; + otherwise the hook previews the nonce the bridge would derive for its own account (nothing is + reserved), or a placeholder when nothing can derive — ``execute`` is the gate. + Returns one of ``EvmHyperlaneQuote`` / ``SolanaHyperlaneQuote`` / ``AleoHyperlaneQuote`` / ``EvmXReserveQuote`` / ``AleoXReserveQuote`` (``quote.kind``), each carrying the canonical ``plan`` that ``execute`` takes. @@ -255,7 +260,8 @@ def quote(bridge, *, source_chain: str | None = None, source_asset: str | None = gas_price=gas.gas_price, exchange_rate=gas.exchange_rate, payment_microcredits=gas.payment_microcredits) if plan.protocol == "xreserve" and family == "evm": - q = _module(bridge, "eth", resolved.source_chain.id).quote_deposit_usdc(plan=plan, secret_nonce=secret_nonce) + nonce = _private_nonce(bridge, plan, resolved, secret_nonce, placeholder=True) + q = _module(bridge, "eth", resolved.source_chain.id).quote_deposit_usdc(plan=plan, secret_nonce=nonce) return replace(q, plan=plan) if plan.protocol == "xreserve" and family == "aleo": fee_atomic = _xreserve_withdrawal_fee_atomic(resolved) @@ -537,21 +543,29 @@ def _xreserve_burn_mode(mode: str | None) -> str: f"Aleo xReserve mode must be 'private', 'public' or 'public-as-signer', got {mode!r}") -def _mint_secret(plan: Plan, secret_nonce: str | None) -> str: - """The secret the EVM deposit commits to. A private mint must supply its own. +def _derives_here(bridge, plan: Plan, secret_nonce: str | None) -> bool: + """True when this verb will look a derived nonce up: a private plan, no explicit nonce, and a + bridge that can derive for the plan's recipient.""" + return plan.mint_mode == "private" and secret_nonce is None and _private_mint.can_derive(bridge, plan.recipient) + + +def _private_nonce(bridge, plan: Plan, resolved: ResolvedRoute, secret_nonce: str | None, *, + address_commitment: str | None = None, placeholder: bool = False) -> str: + """The scalar an EVM xReserve deposit commits to, or its mint opens with. - ``secret_nonce`` is not a ``Plan`` field and the SDK never stores it: a private deposit that - quietly fell back to the ``"0scalar"`` default would commit to a hook nobody can reproduce, - and ``complete`` needs the same value again to mint the record. + Non-private modes use the caller's value or ``"0scalar"`` (the hook ignores it). A private mint + resolves through :func:`private_mint.resolve_secret_nonce`: an explicit ``secret_nonce`` as + given; otherwise, for the bridge's own account with an identity store bound, a nonce derived + from the view key — reserved fresh when ``address_commitment`` is None (execute), else looked + up or re-derived for that commitment (resume / complete). ``placeholder`` is the quote-only + relaxation: ``"0scalar"`` when nothing can derive, since a quote deposits nothing. + ``secret_nonce`` is never a ``Plan`` field and never reaches a checkpoint. """ - if plan.mint_mode == "private": - if not secret_nonce: - raise ConfigurationError( - "a secret_nonce is required for a private xReserve mint: the deposit commits to " - "(recipient, secret_nonce) and complete() needs the same value again — keep it, " - "the SDK never stores it") - return secret_nonce - return secret_nonce or "0scalar" + if plan.mint_mode != "private": + return secret_nonce or "0scalar" + return _private_mint.resolve_secret_nonce( + bridge, recipient=plan.recipient, program=resolved.route.meta_str("wrapperProgram"), + address_commitment=address_commitment, secret_nonce=secret_nonce, placeholder=placeholder) # ── execute ─────────────────────────────────────────────────────────────────── @@ -569,8 +583,9 @@ def execute(bridge, plan: Plan, *, on_checkpoint: Callable | None = None, provin "public-as-signer"`` for Aleo xReserve burns (``record`` / ``compliance_proofs`` feed a private burn; ``merkle_proof`` is the earlier name for one literal bundle). The Hyperlane hook payment is re-quoted right before proving unless ``gas_payment_microcredits`` pins it. ``secret_nonce`` is the private-mint commitment secret - for an EVM xReserve deposit — required when ``plan.mint_mode == "private"``; keep it, - ``complete`` needs it again and the SDK never stores it. + for an EVM xReserve deposit: used as given when passed (keep it — ``complete`` needs it again + and the SDK never stores it); otherwise, for the bridge's own account with an identity store + bound, derived from the view key and reserved in that store before the deposit. EVM and Solana legs are dispatched through the module's own ``plan=`` surface, so the route, registry version, sender and every plan field are re-validated by the module that builds the @@ -627,7 +642,7 @@ def execute(bridge, plan: Plan, *, on_checkpoint: Callable | None = None, provin if plan.protocol == "xreserve" and family == "evm": eth = _module(bridge, "eth", resolved.source_chain.id) _assert_sender(plan, (getattr(eth, "conn", None) or bridge.ethereum).address, family=family) - nonce = _mint_secret(plan, secret_nonce) + nonce = _private_nonce(bridge, plan, resolved, secret_nonce) # reserves a derived identity call = eth.deposit_usdc(plan=plan, secret_nonce=nonce) receipt = _send_call(call, emit, poll_seconds, timeout_seconds) emit.finalize() @@ -1407,9 +1422,9 @@ def resume(bridge, progress: Progress, *, on_checkpoint: Callable | None = None, Solana source: ``SolCall`` has no approval step and no pre-broadcast state to continue, so there is nothing to resume — ``recover``/``wait`` observe the signature instead. - ``secret_nonce`` is mandatory when ``plan.mint_mode == "private"``, and is checked before any - RPC: the SDK never stored it, and a silent ``"0scalar"`` fallback would commit the deposit to a - hook nobody can open. ``proving`` is accepted for symmetry with ``execute``/``complete`` and is + A private deposit's nonce is resolved before any RPC: ``secret_nonce`` as given, else the + identity the checkpointed hook commits to, from the bridge's store or re-derived from its view + key; a silent ``"0scalar"`` fallback would commit the deposit to a hook nobody can open. ``proving`` is accepted for symmetry with ``execute``/``complete`` and is never used — no resume path ever proves anything: an Aleo leg rebroadcasts bytes that were already proved, and an EVM leg has no proofs at all. """ @@ -1464,9 +1479,9 @@ def resume(bridge, progress: Progress, *, on_checkpoint: Callable | None = None, eth = _module(bridge, "eth", resolved.source_chain.id) _assert_sender(plan, (getattr(eth, "conn", None) or bridge.ethereum).address, family="evm") is_xreserve = resolved.route.protocol == "xreserve" - nonce = _mint_secret(plan, secret_nonce) if is_xreserve else None # before any RPC saved_hook = state.get("hookData") - if is_xreserve and _hex_bytes(saved_hook, length=HOOK_DATA_BYTES) is None: + hook_bytes = _hex_bytes(saved_hook, length=HOOK_DATA_BYTES) if is_xreserve else None + if is_xreserve and hook_bytes is None: # Without the hook the approval committed to there is nothing to compare the re-quote # against, so the guard below would silently pass and the deposit could be re-hooked to a # different commitment. Refuse here, before any RPC, rather than resume half-blind. @@ -1474,6 +1489,13 @@ def resume(bridge, progress: Progress, *, on_checkpoint: Callable | None = None, "This transfer's checkpoint carries no xReserve hook data (a 65-byte 0x hex string); " "recover() and re-quote instead of resuming — resume() will not re-derive the hook the " "approval committed to") + nonce = None # before any RPC + if is_xreserve: + # Only a derivable bridge reads the commitment out of the hook: an underivable one gets the + # "pass secret_nonce= or bind a store" error, not a complaint about the hook's shape. + commitment = (_private_mint.commitment_from_hook_data(cast(bytes, hook_bytes)) + if _derives_here(bridge, plan, secret_nonce) else None) + nonce = _private_nonce(bridge, plan, resolved, secret_nonce, address_commitment=commitment) existing = create_checkpoint(plan, receipt, bridge.registry) emit.supersede(existing.id) # whatever this resume writes replaces the record it started from @@ -1488,9 +1510,9 @@ def resume(bridge, progress: Progress, *, on_checkpoint: Callable | None = None, if cast(str, saved_hook).lower() != ("0x" + quoted.hook_data.hex()).lower(): # validated above raise NotResumableError( "The re-quoted hook data does not match the hook this transfer's approval committed " - "to: the secret nonce differs from the one used at execute(). Pass that same " - "secret_nonce — depositing under another hook mints to a commitment the recipient " - "can never open.") + "to: the secret nonce differs from the one the deposit was quoted with. Pass that same " + "secret_nonce=, or restore the identity store that reserved it — depositing under " + "another hook mints to a commitment the recipient can never open.") else: quoted = eth.quote_transfer_remote(plan=plan) if quoted.approval_required: @@ -1536,8 +1558,9 @@ def complete(bridge, progress: Progress, *, secret_nonce: str | None = None, The source deposit is never repeated, and the secret nonce, the attestation and the hook data are never written to a receipt, a checkpoint or a ``Progress``. ``secret_nonce`` must be the - value used at ``execute``; it is required for a private plan on the proving path - (``ConfigurationError``, raised before any RPC). + value the deposit was made with when the caller supplied one; otherwise the scalar behind the + attested commitment comes from the bridge's identity store, or is re-derived from its view + key (``ConfigurationError`` before any RPC when neither applies). """ if progress.plan.protocol == "cctp": plan = progress.plan @@ -1591,7 +1614,9 @@ def complete(bridge, progress: Progress, *, secret_nonce: str | None = None, emit.finalize() return to_progress(plan, submitted) - nonce = _mint_secret(plan, secret_nonce) # before any RPC + commitment = (_private_mint.commitment_from_hook_data(payload[-HOOK_DATA_BYTES:]) + if _derives_here(bridge, plan, secret_nonce) else None) + nonce = _private_nonce(bridge, plan, resolved, secret_nonce, address_commitment=commitment) # before any RPC att = Attestation(payload=payload, message_hash=message_hash, attestation=attestation, status="complete") call = bridge.xreserve.private_mint(att, plan.recipient, secret_nonce=nonce, route=resolved.route) prepared = _prepare_aleo(call, proving) diff --git a/bridge-sdk/python/aleo_bridge/private_mint.py b/bridge-sdk/python/aleo_bridge/private_mint.py new file mode 100644 index 0000000..17fb3cc --- /dev/null +++ b/bridge-sdk/python/aleo_bridge/private_mint.py @@ -0,0 +1,432 @@ +"""Private USDCx mint identities: Shield-compatible nonce derivation (veil PR #166). + +A private xReserve deposit commits the Aleo recipient to a secret scalar on the source chain, and +only that scalar lets the recipient run ``private_mint`` later. Shield derives the scalar from the +account's view key and a counter; this module reproduces that derivation exactly (shield-core +``cc29a60``, vendored vectors in ``tests/fixtures/shield-core-private-mint.json``) so a local Aleo +account never has to invent, pass or safeguard a nonce itself. + +Nothing here contacts a network. The view key and the derived scalar never appear in a ``repr``, +a checkpoint or an error message. +""" +from __future__ import annotations + +import json +import re +import threading +import weakref +from contextlib import contextmanager, nullcontext +from dataclasses import dataclass +from pathlib import Path +from typing import Any, ContextManager, Iterable, Iterator, Protocol, cast + +from . import encoding +from ._fs import atomic_write_text, counter_lock +from .errors import ConfigurationError + +#: Field literals of the little-endian ASCII labels ``shielded_usdcx_mainnet`` / ``_testnet`` — +#: Shield's per-network domain separators, copied character-exact from veil. +SHIELDED_USDCX_DOMAIN_MAINNET = "43548994738795619508419071049301721479887331556681843field" +SHIELDED_USDCX_DOMAIN_TESTNET = "43548994873472583992984066613247233935881976249149555field" +_DOMAINS = {"mainnet": SHIELDED_USDCX_DOMAIN_MAINNET, "testnet": SHIELDED_USDCX_DOMAIN_TESTNET} + +DEFAULT_WRAPPER_PROGRAM = "shielded_usdcx_wrapper.aleo" +MAX_COUNTER = 0xFFFFFFFF + + +def domain_field(network: str) -> str: + """The Poseidon domain for *network*: ``shielded_usdcx_`` as a little-endian field literal.""" + try: + return _DOMAINS[network] + except KeyError: + raise ConfigurationError(f"network must be one of {tuple(_DOMAINS)}, got {network!r}") from None + + +class _Deriver: + """One account's derivation with every loop invariant parsed once: the wrapper's x-coordinate, + the domain field, the view-key field and the recipient's bits. A recovery scan of thousands of + counters then costs one Poseidon hash and one BHP commit each, nothing else.""" + + def __init__(self, view_key: Any, network: str, program: str, recipient: str | None = None) -> None: + domain = domain_field(network) + self._net = net = encoding.network_module(network) + try: + vk = net.ViewKey.from_string(str(view_key)) + except Exception as exc: + raise ConfigurationError("Invalid Aleo view key for private mint derivation") from exc + self._fixed = [net.Address.from_program_id(program).to_group().to_x_coordinate(), + net.Field.from_string(domain), vk.to_scalar().to_field()] + self._hasher = net.Poseidon8() + self._bits = None + if recipient is not None: + encoding.aleo_address_to_bytes32(recipient) # InvalidRecipientError before any FFI work + self._bits = net.Plaintext.from_string(recipient).to_bits_le() + self._committer = net.BHP256() + + def nonce(self, counter: int) -> str: + if isinstance(counter, bool) or not isinstance(counter, int) or not 0 <= counter <= MAX_COUNTER: + raise ConfigurationError(f"Private mint identity counter must be a u32 (0..{MAX_COUNTER}), got {counter!r}") + return str(self._hasher.hash_to_scalar([*self._fixed, self._net.U32(counter).to_field()])) + + def commitment(self, secret_nonce: str) -> str: + assert self._bits is not None, "recipient required for commitments" + return encoding.field_bytes_le(self._committer.commit(self._bits, self._net.Scalar.from_string(secret_nonce))).hex() + + +def derive_secret_nonce(view_key: Any, counter: int, network: str, program: str = DEFAULT_WRAPPER_PROGRAM) -> str: + """Shield's ``shielded-usdcx-secret-nonce``: ``Poseidon8.hash_to_scalar([wrapper_x, domain, view_key, counter])``. + + *view_key* is a ``ViewKey`` or its ``AViewKey1…`` string; *counter* is the identity's u32. + """ + if isinstance(counter, bool) or not isinstance(counter, int) or not 0 <= counter <= MAX_COUNTER: + raise ConfigurationError(f"Private mint identity counter must be a u32 (0..{MAX_COUNTER}), got {counter!r}") + return _Deriver(view_key, network, program).nonce(counter) + + +def derive_address_commitment(recipient: str, secret_nonce: str, network: str) -> str: + """``BHP256.commit(bits(recipient), secret_nonce)`` as 64 lowercase hex characters, no prefix.""" + return encoding.private_mint_commitment(recipient, secret_nonce, network).hex() + + +def commitment_from_hook_data(hook_data: bytes) -> str: + """The commitment inside a canonical private-mint hook (selector 2, 32 commitment bytes, 32 zero bytes).""" + raw = bytes(hook_data) + if len(raw) != encoding.HOOK_DATA_BYTES: + raise ConfigurationError(f"Private mint hook data must contain {encoding.HOOK_DATA_BYTES} bytes") + if raw[0] != 2 or raw[33:] != bytes(32): + raise ConfigurationError("xReserve hook data is not a canonical private mint commitment") + return raw[1:33].hex() + + +@dataclass(frozen=True) +class PrivateMintIdentity: + """One reserved private-mint identity: the counter it was derived from, the recipient it commits, + the secret scalar (never printed) and the public 32-byte commitment as lowercase hex.""" + counter: int + recipient: str + secret_nonce: str + address_commitment: str + + def __repr__(self) -> str: + return (f"PrivateMintIdentity(counter={self.counter}, recipient={self.recipient!r}, " + f"address_commitment={self.address_commitment!r})") + + def to_dict(self) -> dict[str, Any]: + return {"counter": self.counter, "recipient": self.recipient, "secretNonce": self.secret_nonce, + "addressCommitment": self.address_commitment} + + @classmethod + def from_dict(cls, data: Any) -> "PrivateMintIdentity": + """Validate one stored record the way veil's ``validateIdentity`` does; never repair it.""" + if not isinstance(data, dict): + raise ConfigurationError(_INVALID_RECORD) + counter, recipient = data.get("counter"), data.get("recipient") + nonce, commitment = data.get("secretNonce"), data.get("addressCommitment") + if (isinstance(counter, bool) or not isinstance(counter, int) or not 0 <= counter <= MAX_COUNTER + or not isinstance(recipient, str) or not recipient.startswith("aleo1") + or not isinstance(nonce, str) or not nonce.endswith("scalar") + or not isinstance(commitment, str) or not _COMMITMENT_RE.fullmatch(commitment)): + raise ConfigurationError(_INVALID_RECORD) + return cls(counter=counter, recipient=recipient, secret_nonce=nonce, address_commitment=commitment) + + +_COMMITMENT_RE = re.compile(r"[0-9a-f]{64}") +_INVALID_RECORD = ("Private mint identity store contains an invalid record; restore the identities file " + "from a backup before reserving or completing a private mint") +_EXHAUSTED = "Private mint identity counter space is exhausted" +DEFAULT_SCAN_LIMIT = 10_000 +_FILE_VERSION = 1 + + +# ── stores ──────────────────────────────────────────────────────────────────── + +class PrivateMintIdentityStore(Protocol): + """Persistence that keeps a private-mint counter from being issued twice. + + ``allocate_counter(minimum)`` durably consumes a counter at or above *minimum* and returns it; + it runs before derivation, so a failed derivation or save never recycles the counter. A store + whose backing resource is shared between instances or processes should also expose + ``exclusive()`` (a context manager) — :func:`reserve_identity` holds it around load → allocate + → derive → save when present. + """ + + def load(self) -> list[PrivateMintIdentity]: ... + def save(self, identities: list[PrivateMintIdentity]) -> None: ... + def allocate_counter(self, minimum: int) -> int: ... + + +class MemoryPrivateMintIdentityStore: + """A process-local store for tests and explicit opt-in. It restarts its counter at zero with + the process, so it is never chosen by default: a reused counter links two deposits on chain.""" + + def __init__(self, initial: Iterable[PrivateMintIdentity] = ()) -> None: + self._identities = list(initial) + self._next = 0 + + def load(self) -> list[PrivateMintIdentity]: + return list(self._identities) + + def save(self, identities: list[PrivateMintIdentity]) -> None: + self._identities = list(identities) + + def allocate_counter(self, minimum: int) -> int: + self._next = max(self._next, minimum) + if self._next > MAX_COUNTER: + raise ConfigurationError(_EXHAUSTED) + value = self._next + self._next += 1 + return value + + +class FilePrivateMintIdentityStore: + """Identities in a JSON file, the next counter in ``.counter``, and a ``.lock`` + inode that serializes reservations across instances and processes sharing the path. + + Every write is an atomic 0600 replace. The file holds scalars that open the corresponding + mints: treat it like a view key, and back it up together with its ``.counter``. A malformed + file raises rather than silently restarting the sequence. + """ + + def __init__(self, path: Path | str) -> None: + self.path = Path(path).expanduser() + self.counter_path = self.path.with_name(self.path.name + ".counter") + self.lock_path = self.path.with_name(self.path.name + ".lock") + + def __repr__(self) -> str: + return f"FilePrivateMintIdentityStore({str(self.path)!r})" + + @contextmanager + def exclusive(self) -> Iterator[None]: + self.path.parent.mkdir(parents=True, exist_ok=True) + with counter_lock(self.lock_path): + yield + + def load(self) -> list[PrivateMintIdentity]: + if not self.path.exists(): + return [] + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except (ValueError, UnicodeError) as exc: + raise ConfigurationError(_INVALID_RECORD) from exc + records = data.get("identities") if isinstance(data, dict) else None + if not isinstance(records, list): + raise ConfigurationError(_INVALID_RECORD) + return [PrivateMintIdentity.from_dict(record) for record in records] + + def save(self, identities: list[PrivateMintIdentity]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + text = json.dumps({"version": _FILE_VERSION, "identities": [i.to_dict() for i in identities]}, indent=2) + atomic_write_text(self.path.parent, self.path, text) + + def _read_counter(self) -> int: + if not self.counter_path.exists(): + return 0 + text = self.counter_path.read_text(encoding="utf-8").strip() + if not text.isdigit(): + raise ConfigurationError( + "Private mint identity counter file is unreadable; restore it (and the identities file) " + "from a backup before reserving a private mint") + return int(text) + + def allocate_counter(self, minimum: int) -> int: + value = max(self._read_counter(), minimum) + if value > MAX_COUNTER: + raise ConfigurationError(_EXHAUSTED) + self.path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_text(self.path.parent, self.counter_path, str(value + 1)) + return value + + +# ── reservation and recovery ───────────────────────────────────────────────── + +_process_locks: "weakref.WeakKeyDictionary[Any, threading.Lock]" = weakref.WeakKeyDictionary() +_process_locks_by_id: dict[int, threading.Lock] = {} +_registry_lock = threading.Lock() + + +def _process_lock(store: Any) -> threading.Lock: + with _registry_lock: + try: + return _process_locks.setdefault(store, threading.Lock()) + except TypeError: # not weak-referenceable + return _process_locks_by_id.setdefault(id(store), threading.Lock()) + + +def _exclusive(store: Any) -> ContextManager[None]: + exclusive: Any = getattr(store, "exclusive", None) + return cast(ContextManager[None], exclusive()) if callable(exclusive) else nullcontext() + + +def _next_counter(identities: list[PrivateMintIdentity]) -> int: + minimum = max((identity.counter + 1 for identity in identities), default=0) + if minimum > MAX_COUNTER: + raise ConfigurationError(_EXHAUSTED) + return minimum + + +def reserve_identity(store: PrivateMintIdentityStore, *, view_key: Any, recipient: str, network: str, + program: str = DEFAULT_WRAPPER_PROGRAM) -> PrivateMintIdentity: + """Reserve the next identity for one private deposit: consume a counter, derive, save, return. + + The counter is consumed before derivation, so a failed derivation or save leaves a gap rather + than a reuse. One process-wide lock per store object plus the store's own ``exclusive()`` + (when it has one) close the races within and across processes. + """ + with _process_lock(store), _exclusive(store): + identities = store.load() + counter = store.allocate_counter(_next_counter(identities)) + if isinstance(counter, bool) or not isinstance(counter, int) or counter < _next_counter(identities): + raise ConfigurationError("Private mint identity store returned an invalid counter allocation") + if counter > MAX_COUNTER: + raise ConfigurationError(_EXHAUSTED) + secret_nonce = derive_secret_nonce(view_key, counter, network, program) + identity = PrivateMintIdentity(counter=counter, recipient=recipient, secret_nonce=secret_nonce, + address_commitment=derive_address_commitment(recipient, secret_nonce, network)) + store.save([*identities, identity]) + return identity + + +def peek_secret_nonce(store: PrivateMintIdentityStore, *, view_key: Any, recipient: str, network: str, + program: str = DEFAULT_WRAPPER_PROGRAM) -> str: + """The scalar the next :func:`reserve_identity` would produce, without consuming anything. + + Indicative only (another reservation may land first); quotes use it so their hook data matches + what execute will deposit in the common case. + """ + del recipient # the nonce does not depend on it + with _process_lock(store), _exclusive(store): + identities = store.load() + minimum = _next_counter(identities) + counter = max(minimum, store._read_counter()) if isinstance(store, FilePrivateMintIdentityStore) else minimum + if counter > MAX_COUNTER: + raise ConfigurationError(_EXHAUSTED) + return derive_secret_nonce(view_key, counter, network, program) + + +def find_identity(store: PrivateMintIdentityStore, address_commitment: str) -> PrivateMintIdentity | None: + """The stored identity behind a public commitment, or ``None`` when this store never issued it.""" + for identity in store.load(): + if identity.address_commitment == address_commitment: + return identity + return None + + +def recover_identity(store: PrivateMintIdentityStore, *, view_key: Any, recipient: str, address_commitment: str, + network: str, program: str = DEFAULT_WRAPPER_PROGRAM, + scan_limit: int = DEFAULT_SCAN_LIMIT) -> PrivateMintIdentity: + """The identity behind *address_commitment*: from the store, or re-derived by counting up. + + A stored hit must name *recipient*. Otherwise counters ``0..scan_limit-1`` are derived with the + account's view key and *recipient* until one commits to the same value; the hit is saved back + so the next look-up is immediate. Losing the identities file therefore costs a scan, not the + funds. + """ + stored = find_identity(store, address_commitment) + if stored is not None: + if stored.recipient != recipient: + raise ConfigurationError( + "The stored private mint identity for this deposit names a different recipient than the " + "transfer; refusing to mint under it") + return stored + deriver = _Deriver(view_key, network, program, recipient) + for counter in range(min(scan_limit, MAX_COUNTER + 1)): + secret_nonce = deriver.nonce(counter) + if deriver.commitment(secret_nonce) == address_commitment: + identity = PrivateMintIdentity(counter=counter, recipient=recipient, secret_nonce=secret_nonce, + address_commitment=address_commitment) + with _process_lock(store), _exclusive(store): + current = store.load() + if not any(i.address_commitment == address_commitment for i in current): + store.save([*current, identity]) + return identity + raise ConfigurationError( + f"No private mint identity for this deposit's commitment was found in the store or among the first " + f"{scan_limit} counters derived from the account's view key. A deposit made with an explicit " + "secret_nonce can only be completed with that same secret_nonce; otherwise raise scan_limit or " + "restore the identities file that reserved it") + + +# ── the resolver every verb calls ──────────────────────────────────────────── + +def default_identity_store(checkpoints: Any) -> PrivateMintIdentityStore | None: + """The store a ``Bridge`` binds when none is given: a hidden file inside a ``FileCheckpointStore`` + directory (journal metadata, skipped by its ``*.json`` listing), or none at all.""" + from .checkpoint import FileCheckpointStore + if isinstance(checkpoints, FileCheckpointStore): + return FilePrivateMintIdentityStore(checkpoints.directory / ".private-mint-identities.json") + return None + + +def derivable_account(bridge: Any, recipient: str) -> Any | None: + """The bridge's default account when it can derive for *recipient*: it exposes a view key and + *recipient* is its own address. Derivation is never offered for another recipient — the + identity would not be one that recipient's wallet could ever reproduce.""" + account = getattr(getattr(bridge, "aleo", None), "default_account", None) + if not account or getattr(account, "view_key", None) is None: + return None + if str(getattr(account, "address", "")) != recipient: + return None + return account + + +def can_derive(bridge: Any, recipient: str) -> bool: + """True when a private mint to *recipient* needs no ``secret_nonce`` on this bridge.""" + return getattr(bridge, "private_mint_identities", None) is not None and derivable_account(bridge, recipient) is not None + + +def _why_not(bridge: Any, recipient: str) -> str: + account = getattr(getattr(bridge, "aleo", None), "default_account", None) + if not account or getattr(account, "view_key", None) is None: + return "the Aleo facade has no default account with a view key to derive from" + if str(getattr(account, "address", "")) != recipient: + return "the recipient is not the bridge's own Aleo account, and derived nonces apply only to its own mints" + return "no identity store is bound to keep the derived nonce" + + +def resolve_secret_nonce(bridge: Any, *, recipient: str, program: str, address_commitment: str | None = None, + secret_nonce: str | None = None, placeholder: bool = False) -> str: + """The scalar a private mint uses, from one of three sources in this order. + + 1. An explicit *secret_nonce*: validated and returned; no store is read or written. + 2. Derivation for the bridge's own account with a store bound: a new identity is reserved when + *address_commitment* is ``None`` (execute); otherwise the identity behind that commitment is + looked up and, when the store lacks it, re-derived by counting up (resume / complete). + 3. Neither: ``"0scalar"`` when *placeholder* is set (a quote, where nothing is deposited), else a + ``ConfigurationError`` naming both fixes. + """ + if secret_nonce is not None: + return encoding.validate_scalar(secret_nonce) + network = bridge.network + if address_commitment is not None and address_commitment == derive_address_commitment(recipient, "0scalar", network): + # The deposit was made under the Tier-2 ``0scalar`` default (``eth.deposit_usdc``), the + # value ``private_mint`` itself used to assume. The commitment is already public on chain, + # so answering it costs nothing and keeps those callers working without a store. + return "0scalar" + store = getattr(bridge, "private_mint_identities", None) + account = derivable_account(bridge, recipient) + if store is None or account is None: + if placeholder: + return "0scalar" + raise ConfigurationError( + f"A private mint needs a secret_nonce, and this bridge cannot derive one: {_why_not(bridge, recipient)}. " + "Either pass secret_nonce= (and keep it — complete() needs the same value), or let the bridge derive " + "and keep it for its own account by binding a checkpoint directory (FileCheckpointStore) or " + "private_mint_identities= to Bridge(...)") + if placeholder: + return peek_secret_nonce(store, view_key=account.view_key, recipient=recipient, network=network, program=program) + if address_commitment is None: + return reserve_identity(store, view_key=account.view_key, recipient=recipient, network=network, + program=program).secret_nonce + return recover_identity(store, view_key=account.view_key, recipient=recipient, address_commitment=address_commitment, + network=network, program=program).secret_nonce + + +__all__ = [ + "DEFAULT_SCAN_LIMIT", "DEFAULT_WRAPPER_PROGRAM", "MAX_COUNTER", "can_derive", "default_identity_store", + "derivable_account", "resolve_secret_nonce", + "SHIELDED_USDCX_DOMAIN_MAINNET", "SHIELDED_USDCX_DOMAIN_TESTNET", + "FilePrivateMintIdentityStore", "MemoryPrivateMintIdentityStore", "PrivateMintIdentity", + "PrivateMintIdentityStore", "commitment_from_hook_data", "derive_address_commitment", + "derive_secret_nonce", "domain_field", "find_identity", "peek_secret_nonce", "recover_identity", + "reserve_identity", +] diff --git a/bridge-sdk/python/aleo_bridge/xreserve.py b/bridge-sdk/python/aleo_bridge/xreserve.py index e1237ea..27acbed 100644 --- a/bridge-sdk/python/aleo_bridge/xreserve.py +++ b/bridge-sdk/python/aleo_bridge/xreserve.py @@ -206,10 +206,16 @@ def build(tx_id: str, _outputs: list[str]) -> BurnReceipt: def hook_data(self, mode: str, recipient: str, secret_nonce: str = "0scalar") -> bytes: return enc.xreserve_hook_data(mode, recipient, self._bridge.environment, secret_nonce) - def build_private_mint_inputs(self, route: Route, attestation: Attestation, recipient: str, secret_nonce: str) -> list[str]: - """The five ``private_mint`` literals (brief §3.5) after re-verifying hash and the (recipient, nonce) commitment.""" + def build_private_mint_inputs(self, route: Route, attestation: Attestation, recipient: str, + secret_nonce: str | None) -> list[str]: + """The five ``private_mint`` literals (brief §3.5) after re-verifying hash and the (recipient, nonce) commitment. + + ``secret_nonce=None`` resolves the scalar from the bridge's private mint identity store + (looked up by the attested commitment, or re-derived from the account's view key). + """ self._validated(route, direction="mint") - enc.validate_scalar(secret_nonce) + if secret_nonce is not None: + enc.validate_scalar(secret_nonce) if attestation.status != "complete": raise AttestationError("Private mint requires a completed Circle attestation; it is still pending") payload, signature, digest = bytes(attestation.payload), bytes(attestation.attestation), bytes(attestation.message_hash) @@ -217,14 +223,26 @@ def build_private_mint_inputs(self, route: Route, attestation: Attestation, reci raise AttestationError("Circle attestation has invalid widths (expected 305-byte payload, 65-byte signature, 32-byte hash)") if keccak256(payload) != digest: raise AttestationError("Circle attestation payload has an invalid message hash") + if secret_nonce is None: + from . import private_mint + commitment = private_mint.commitment_from_hook_data(payload[-enc.HOOK_DATA_BYTES:]) + secret_nonce = private_mint.resolve_secret_nonce( + self._bridge, recipient=recipient, program=route.meta_str("wrapperProgram"), + address_commitment=commitment) expected_hook = self.hook_data("private", recipient, secret_nonce) if payload[-enc.HOOK_DATA_BYTES:] != expected_hook: raise AttestationError("Private mint secret nonce and recipient do not match the attested hook data") return [enc.u8_array_literal(payload), enc.u8_array_literal(signature), enc.u8_array_literal(digest), secret_nonce, recipient] - def private_mint(self, attestation: Attestation, recipient: str, *, secret_nonce: str = "0scalar", + def private_mint(self, attestation: Attestation, recipient: str, *, secret_nonce: str | None = None, route: Route | None = None) -> AleoCall[MintReceipt]: - """Finish a private-mode deposit: the only user-signed Aleo step of the inbound flow (``wrapper.private_mint``).""" + """Finish a private-mode deposit: the only user-signed Aleo step of the inbound flow (``wrapper.private_mint``). + + An explicit ``secret_nonce`` is used as given. Without one, the bridge resolves the scalar + for its own account from the identity store that reserved the deposit (re-deriving it from + the view key when the store lacks it); a deposit made with a caller-supplied nonce needs + that nonce back. + """ route = self._validated(route if route is not None else self.inbound_route(), direction="mint") inputs = self.build_private_mint_inputs(route, attestation, recipient, secret_nonce) program = route.meta_str("wrapperProgram") diff --git a/bridge-sdk/tests/fakes/fake_bridge.py b/bridge-sdk/tests/fakes/fake_bridge.py index b50e051..7821587 100644 --- a/bridge-sdk/tests/fakes/fake_bridge.py +++ b/bridge-sdk/tests/fakes/fake_bridge.py @@ -474,6 +474,7 @@ def __init__(self, *, environment="mainnet", ethereum=True, solana=False, checkp self.registry = DEFAULT_REGISTRY self.environment = self.network = environment self.checkpoints = checkpoints + self.private_mint_identities = None # set by tests that exercise derived private mints self.events: list[tuple] = [] # ordered side effects (prove/submit/checkpoint...) self.calls: list[tuple] = [] # module method calls with kwargs self.submitted: list[str] = [] diff --git a/bridge-sdk/tests/fixtures/shield-core-private-mint.json b/bridge-sdk/tests/fixtures/shield-core-private-mint.json new file mode 100644 index 0000000..312cbd9 --- /dev/null +++ b/bridge-sdk/tests/fixtures/shield-core-private-mint.json @@ -0,0 +1,43 @@ +{ + "_source": "ProvableHQ/veil packages/bridge/test/fixtures/shield-core-private-mint.json @ f1ed269 (PR #166); vectors generated from shield-core cc29a60dad12bbd31fea4552ba01f9606dc6f53f", + "source": "https://github.com/ProvableHQ/shield-core/blob/cc29a60dad12bbd31fea4552ba01f9606dc6f53f/src/domains/dapp/fulfillment/derivedInput/algorithms.ts", + "provenance": "Generated by running the pinned upstream deriveShieldedUsdcxScalar and deriveShieldedUsdcxAddressCommitment functions with the Provable web WASM SDK; not generated by Veil utilities. Inputs: shielded_usdcx_wrapper.aleo, AViewKey1sqm952gJj1tmAWySYDQvSv2NmfnyEMvU6a9ZBCuyG7PN, aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n.", + "vectors": [ + { + "network": "mainnet", + "counter": 0, + "nonce": "280870382244309297103161192017346343410079817279370435075002649049225545906scalar", + "commitment": "1087f721b642d373e5f0948375165f7d3d696a7f7cdca4edf5db677d3f7d9e0a" + }, + { + "network": "mainnet", + "counter": 7, + "nonce": "1522818780929816559774244111068172985050974005421241141223019137817699363453scalar", + "commitment": "5728da4fcf9856d88075835febbc6a9d50f5c3ee4de2dc1fd3856e0335df6403" + }, + { + "network": "mainnet", + "counter": 4294967295, + "nonce": "407371780112635949220103294706712341630610335872342040492050944512184375410scalar", + "commitment": "f36239fc8bd04b591dac51873100e95990e796a63bc786c6653763a42071f410" + }, + { + "network": "testnet", + "counter": 0, + "nonce": "802580620763429619237402827448565724897962664608720412305173428661097273724scalar", + "commitment": "a04e80d98e7517c800131a77b7a39ea56529dc789317ded54235b168534b9a0c" + }, + { + "network": "testnet", + "counter": 7, + "nonce": "1363213854347872717981869221882535797318953272936761375135239867324332706579scalar", + "commitment": "2733dde94b111fdf3671307cb27a406c2b55fe64c6cd412c7707609aa1826300" + }, + { + "network": "testnet", + "counter": 4294967295, + "nonce": "902714300750590238855583844722633512433592465104153223599331176686516880189scalar", + "commitment": "cdbe10c820b0776f98ae4ade3e0f7c202a91eececaccdd927ec519ce3e55bc0f" + } + ] +} diff --git a/bridge-sdk/tests/live/cases.py b/bridge-sdk/tests/live/cases.py index 0a9dc9b..8903802 100644 --- a/bridge-sdk/tests/live/cases.py +++ b/bridge-sdk/tests/live/cases.py @@ -305,7 +305,7 @@ def _recover_from_state(bridge: Any, state: LiveState, *, benchmark: LiveBenchma def _drive(bridge: Any, progress: Any, state: LiveState, state_path: Path, *, spec: CaseSpec, secret_nonce: str | None, benchmark: LiveBenchmark, save: Callable[[Any], None], wait_timeout_seconds: float, wait_poll_seconds: float, - log: Callable[[str], None]) -> Any: + log: Callable[[str], None], derived_nonce: bool = False) -> Any: """``wait`` / ``resume`` / ``complete`` until the transfer is done. Never calls ``execute``. A ``failed`` verdict whose receipt says ``dropped`` (the source transaction was replaced or @@ -326,7 +326,7 @@ def _drive(bridge: Any, progress: Any, state: LiveState, state_path: Path, *, sp benchmark.mark("resume-returned") continue if progress.next == "complete": - if not secret_nonce: + if not secret_nonce and not derived_nonce: raise LiveCaseError( "The private mint needs the secret nonce kept beside this case's state file; " "it is missing, so the mint cannot be completed here") @@ -432,7 +432,8 @@ def _evm_transfer_tx(bridge: Any, asset: Asset, recipient: str, amount_atomic: i def run_case(bridge: Any, case: str, route_id: str, *, state_path: Path | str, recipient: str | None = None, amount: str | None = None, execute: bool, benchmark: LiveBenchmark | None = None, wait_timeout_seconds: float = WAIT_TIMEOUT_SECONDS, wait_poll_seconds: float = WAIT_POLL_SECONDS, - stop_after_execute: bool = False, log: Callable[[str], None] = print) -> LiveState: + stop_after_execute: bool = False, derived_nonce: bool = False, + log: Callable[[str], None] = print) -> LiveState: """Run one case over one route, resuming from ``state_path``. See the module docstring for the flow. ``stop_after_execute=True`` returns as soon as the source transaction is on chain and its @@ -460,16 +461,18 @@ def run_case(bridge: Any, case: str, route_id: str, *, state_path: Path | str, r benchmark.mark("plan-prepared") # The private-mint nonce is created only when we are actually going to deposit: a quote-only - # rehearsal leaves no secret file behind. An existing one is always reused. + # rehearsal leaves no secret file behind. An existing one is always reused. A ``derived_nonce`` + # case supplies none at all: the bridge derives it for its own account and keeps it beside the + # journal, which is exactly what the case verifies. secret_nonce = None - if spec.private_mint: + if spec.private_mint and not derived_nonce: secret_nonce = ensure_secret_nonce(state_path) if execute else load_secret_nonce(state_path) - state.secret_nonce_present = secret_nonce is not None + state.secret_nonce_present = secret_nonce is not None progress = None if state.checkpoint is None: quote = bridge.quote(route=route, amount=amount, recipient=recipient, sender=sender, - mint_mode=spec.mint_mode, secret_nonce=secret_nonce or "0scalar") + mint_mode=spec.mint_mode, secret_nonce=secret_nonce) benchmark.mark("quote-returned") print_quote(bridge, quote, case=case, route_id=route_id, log=log) balances = precheck(bridge, quote, case=case, log=log) @@ -532,7 +535,7 @@ def run_case(bridge: Any, case: str, route_id: str, *, state_path: Path | str, r progress = _drive(bridge, progress, state, state_path, spec=spec, secret_nonce=secret_nonce, benchmark=benchmark, save=save, wait_timeout_seconds=wait_timeout_seconds, - wait_poll_seconds=wait_poll_seconds, log=log) + wait_poll_seconds=wait_poll_seconds, log=log, derived_nonce=derived_nonce) benchmark.mark("destination-delivered") receipt = progress.receipt diff --git a/bridge-sdk/tests/live/test_lifecycle_live.py b/bridge-sdk/tests/live/test_lifecycle_live.py index 2008a4d..d211a20 100644 --- a/bridge-sdk/tests/live/test_lifecycle_live.py +++ b/bridge-sdk/tests/live/test_lifecycle_live.py @@ -139,8 +139,8 @@ def testnet_client() -> Callable[[], Any]: # ── the shared body ────────────────────────────────────────────────────────── -def _state_path(case: str, route: Route, environment: str) -> Path: - return live_config.live_state_path(environment, live_cases.state_name(case, route.id)) +def _state_path(case: str, route: Route, environment: str, suffix: str = "") -> Path: + return live_config.live_state_path(environment, live_cases.state_name(case, route.id) + suffix) def resume_command(state_path: Path | str) -> str: @@ -167,7 +167,7 @@ def _record(record_property: Callable[[str, Any], None], case: str, route: Route def _drive_case(make_bridge: Callable[[], Any], case: str, route: Route, *, environment: str, execute: bool, record_property: Callable[[str, Any], None], - amount: str | None = None) -> Any: + amount: str | None = None, derived_nonce: bool = False) -> Any: """Quote (and, when acknowledged, execute) one route, then finish it from disk with a NEW client. The two phases are the point: whatever ``execute`` returned is dropped with the first client, so @@ -177,7 +177,8 @@ def _drive_case(make_bridge: Callable[[], Any], case: str, route: Route, *, envi if not route.active: pytest.skip(f"registry:{route.availability}") - state_path = _state_path(case, route, environment) + # A derived-nonce case keeps its own state file so it never resumes the explicit case's checkpoint. + state_path = _state_path(case, route, environment, suffix="-derived" if derived_nonce else "") benchmark = LiveBenchmark(f"{case}:{live_cases.route_slug(route.id)}", log=print) amount = amount or live_config.case_amount_override(case) recipient = live_config.recipient_override(_destination_family(route)) @@ -191,7 +192,7 @@ def run(bridge: Any, *, stop_after_execute: bool) -> Any: bridge, case, route.id, state_path=state_path, recipient=recipient, amount=amount, execute=execute, benchmark=benchmark, stop_after_execute=stop_after_execute, wait_timeout_seconds=min(live_cases.WAIT_TIMEOUT_SECONDS, remaining()), - wait_poll_seconds=live_cases.WAIT_POLL_SECONDS, log=print) + wait_poll_seconds=live_cases.WAIT_POLL_SECONDS, derived_nonce=derived_nonce, log=print) state = None try: @@ -270,13 +271,13 @@ def _mainnet(case: str, route: Route, make_bridge: Callable[[], Any], def _testnet(case: str, route_id: str, make_bridge: Callable[[], Any], - record_property: Callable[[str, Any], None], amount: str) -> Any: + record_property: Callable[[str, Any], None], amount: str, derived_nonce: bool = False) -> Any: """Gate one testnet route: the funds gate alone (veil's testnet file has no mainnet acknowledgement).""" if not live_config.live_funds_enabled(): pytest.skip(f"set {live_config.FUNDS_VAR}=1 and {live_config.STATE_DIR_VAR} to run funded live cases") route = DEFAULT_REGISTRY.route(route_id) return _drive_case(make_bridge, case, route, environment="testnet", execute=True, - record_property=record_property, amount=amount) + record_property=record_property, amount=amount, derived_nonce=derived_nonce) # ── the five mainnet cases, one test each, over every route they cover ─────── @@ -346,6 +347,15 @@ def test_testnet_evm_xreserve_deposit(testnet_client, record_property): TESTNET_DEPOSIT_AMOUNT) +def test_testnet_evm_xreserve_deposit_derived(testnet_client, record_property): + """Same route and amount as the explicit-nonce case, but no ``secret_nonce`` anywhere: the + bridge derives it for its own account (Shield's algorithm), keeps it in + ``.private-mint-identities.json`` beside the journal, and the NEW client that recovers from + disk completes the mint with it.""" + _testnet("evm-xreserve", TESTNET_DEPOSIT_ROUTE, testnet_client, record_property, + TESTNET_DEPOSIT_AMOUNT, derived_nonce=True) + + def test_testnet_aleo_xreserve_return(testnet_client, record_property): """Beyond veil: the RETURN leg, aleo-testnet USDCx → Sepolia USDC, 2.000001 private burn. diff --git a/bridge-sdk/tests/test_agent.py b/bridge-sdk/tests/test_agent.py index 1cdfdad..0b3b406 100644 --- a/bridge-sdk/tests/test_agent.py +++ b/bridge-sdk/tests/test_agent.py @@ -86,13 +86,15 @@ def test_tool_surface_and_schemas(): assert "private key" not in json.dumps(tools).lower() -def test_private_mint_descriptions_state_the_nonce_requirement(): +def test_private_mint_descriptions_state_the_nonce_is_derived_or_supplied(): tools = {t["name"]: t for t in bridge_tools()} - for name in ("bridge_quote", "bridge_execute", "bridge_complete"): + for name in ("bridge_quote", "bridge_execute", "bridge_resume", "bridge_complete"): assert "secret_nonce" in tools[name]["input_schema"]["properties"] - for name in ("bridge_execute", "bridge_complete"): + assert "secret_nonce" not in tools[name]["input_schema"].get("required", []) + for name in ("bridge_execute", "bridge_resume", "bridge_complete"): description = tools[name]["input_schema"]["properties"]["secret_nonce"]["description"].lower() - assert "required" in description and "0scalar" not in description + assert "deriv" in description and "0scalar" not in description + assert "there is no default" not in tools["bridge_quote"]["description"] # ── _serialize ──────────────────────────────────────────────────────────────── @@ -352,10 +354,12 @@ def test_unknown_tool(): def test_private_mint_never_defaults_the_secret_nonce(): b = FakeBridge() private = {**QUOTE_ARGS, "mint_mode": "private"} - for tool in ("bridge_quote", "bridge_execute"): - out = dispatch_tool(b, tool, dict(private)) - assert "secret_nonce" in out["error"] and out["how_to_fix"] - assert "confirmation_required" not in out and "quote" not in out + # a quote prices the transfer regardless: nothing is deposited under its placeholder hook + assert dispatch_tool(b, "bridge_quote", dict(private))["kind"] == "evm-xreserve" + b.calls.clear() + out = dispatch_tool(b, "bridge_execute", dict(private)) + assert "secret_nonce" in out["error"] and "private_mint_identities" in out["error"] and out["how_to_fix"] + assert "confirmation_required" not in out and "quote" not in out out = dispatch_tool(b, "bridge_execute", {**private, "confirm": True}) assert "secret_nonce" in out["error"] assert b.calls == [] and b.events == [] # nothing quoted, nothing moved @@ -368,6 +372,23 @@ def test_private_mint_never_defaults_the_secret_nonce(): assert "secret_nonce" in out["error"] and out["how_to_fix"] and b2.events == [] +def test_execute_and_complete_derive_for_the_bridge_account_with_a_store(): + """With an identity store bound and the recipient being the bridge's own account, the tools + need no secret_nonce — and the derived scalar never appears in a result.""" + import aleo + from aleo_bridge import private_mint as pm + b = FakeBridge() + account = aleo.mainnet.Account.from_private_key(aleo.mainnet.PrivateKey.random()) + b.aleo.default_account = account + b.private_mint_identities = pm.MemoryPrivateMintIdentityStore() + args = {**QUOTE_ARGS, "mint_mode": "private", "recipient": str(account.address)} + out = dispatch_tool(b, "bridge_execute", {**args, "confirm": True}) + assert "error" not in out + [identity] = b.private_mint_identities.load() + text = json.dumps(out) + assert "secret_nonce" not in text and identity.secret_nonce not in text + + def test_no_write_tool_ever_echoes_the_secret_nonce(): """Every write tool, with and without confirm: the nonce is not in the JSON result.""" results = [] @@ -476,6 +497,7 @@ def test_resume_refuses_a_missing_private_nonce_before_any_rpc(): out = dispatch_tool(b, "bridge_resume", {"checkpoint": cp, "confirm": True}) assert out["error_type"] == "ConfigurationError" and "secret_nonce" in out["error"] + assert "private_mint_identities" in out["error"] # the other fix: let the bridge derive assert out.get("next") != "recover" and "how_to_fix" in out # recover()'s read-only history scan ran; nothing that re-quotes or sends the deposit did assert not any(name in ("eth.deposit_usdc", "eth.quote_deposit_usdc") for name, *_ in b.calls) diff --git a/bridge-sdk/tests/test_client.py b/bridge-sdk/tests/test_client.py index 2da943f..11190de 100644 --- a/bridge-sdk/tests/test_client.py +++ b/bridge-sdk/tests/test_client.py @@ -266,3 +266,14 @@ def test_routes_filters_the_registry_for_this_environment(fake_aleo): assert bridge.routes() == REG.routes(environment="mainnet") assert bridge.routes(bridge_protocol="xreserve", include_unavailable=True) == \ REG.routes(bridge_protocol="xreserve", include_unavailable=True, environment="mainnet") + + +def test_bridge_binds_a_private_mint_store_beside_a_file_journal(tmp_path, fake_aleo): + from aleo_bridge.checkpoint import FileCheckpointStore + from aleo_bridge.private_mint import FilePrivateMintIdentityStore, MemoryPrivateMintIdentityStore + bound = Bridge(fake_aleo, checkpoints=FileCheckpointStore(tmp_path)).private_mint_identities + assert isinstance(bound, FilePrivateMintIdentityStore) + assert bound.path == tmp_path / ".private-mint-identities.json" + assert Bridge(fake_aleo).private_mint_identities is None + explicit = MemoryPrivateMintIdentityStore() + assert Bridge(fake_aleo, private_mint_identities=explicit).private_mint_identities is explicit diff --git a/bridge-sdk/tests/test_execute.py b/bridge-sdk/tests/test_execute.py index ed80182..412bb59 100644 --- a/bridge-sdk/tests/test_execute.py +++ b/bridge-sdk/tests/test_execute.py @@ -181,8 +181,9 @@ def test_a_private_mint_without_a_secret_nonce_is_refused_before_any_rpc(): """``secret_nonce`` is not a plan field and the SDK never stores it: a private deposit that silently fell back to "0scalar" would mint to a commitment the caller cannot ever reproduce.""" b = FakeBridge() - with pytest.raises(ConfigurationError, match="secret_nonce"): + with pytest.raises(ConfigurationError, match="secret_nonce") as info: execute(b, _usdc_plan(b, mint_mode="private")) + assert "private_mint_identities" in str(info.value) # the other fix: let the bridge derive assert b.calls == [] execute(b, _usdc_plan(b)) # public mint: the default is fine assert b.calls[0][1]["secret_nonce"] == "0scalar" diff --git a/bridge-sdk/tests/test_private_mint_derivation.py b/bridge-sdk/tests/test_private_mint_derivation.py new file mode 100644 index 0000000..e4c6436 --- /dev/null +++ b/bridge-sdk/tests/test_private_mint_derivation.py @@ -0,0 +1,73 @@ +"""Shield-compatible private mint derivation (veil PR #166, shield-core cc29a60). + +The six vectors in ``tests/fixtures/shield-core-private-mint.json`` were produced by Shield's own +``deriveShieldedUsdcxScalar`` / ``deriveShieldedUsdcxAddressCommitment``; the bridge must reproduce +them byte for byte so an identity minted here is one Shield could reproduce. +""" +import json +from pathlib import Path + +import pytest + +from aleo_bridge import private_mint as pm +from aleo_bridge.encoding import xreserve_hook_data +from aleo_bridge.errors import ConfigurationError + +FIXTURE = json.loads((Path(__file__).parent / "fixtures" / "shield-core-private-mint.json").read_text()) +VIEW_KEY = "AViewKey1sqm952gJj1tmAWySYDQvSv2NmfnyEMvU6a9ZBCuyG7PN" +RECIPIENT = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" + + +@pytest.mark.parametrize("vector", FIXTURE["vectors"], ids=lambda v: f"{v['network']}-{v['counter']}") +def test_shield_vectors_reproduce(vector): + nonce = pm.derive_secret_nonce(VIEW_KEY, vector["counter"], vector["network"]) + assert nonce == vector["nonce"] + commitment = pm.derive_address_commitment(RECIPIENT, nonce, vector["network"]) + assert commitment == vector["commitment"] + hook = xreserve_hook_data("private", RECIPIENT, vector["network"], nonce) + assert hook == b"\x02" + bytes.fromhex(commitment) + b"\x00" * 32 + assert pm.commitment_from_hook_data(hook) == commitment + + +def test_domain_fields_are_little_endian_ascii_labels(): + for network, expected in (("mainnet", pm.SHIELDED_USDCX_DOMAIN_MAINNET), + ("testnet", pm.SHIELDED_USDCX_DOMAIN_TESTNET)): + n = int.from_bytes(f"shielded_usdcx_{network}".encode(), "little") + assert pm.domain_field(network) == f"{n}field" == expected + assert pm.SHIELDED_USDCX_DOMAIN_MAINNET != pm.SHIELDED_USDCX_DOMAIN_TESTNET + + +def test_view_key_object_and_string_derive_the_same_nonce(): + import aleo + vk = aleo.mainnet.ViewKey.from_string(VIEW_KEY) + assert pm.derive_secret_nonce(vk, 7, "mainnet") == pm.derive_secret_nonce(VIEW_KEY, 7, "mainnet") + + +@pytest.mark.parametrize("counter", [-1, 0x1_0000_0000, 1.5, True]) +def test_counter_must_be_u32(counter): + with pytest.raises(ConfigurationError, match="u32"): + pm.derive_secret_nonce(VIEW_KEY, counter, "mainnet") + + +def test_unknown_network_and_bad_view_key_are_configuration_errors(): + with pytest.raises(ConfigurationError): + pm.derive_secret_nonce(VIEW_KEY, 0, "devnet") + with pytest.raises(ConfigurationError, match="view key"): + pm.derive_secret_nonce("AViewKey1nope", 0, "mainnet") + + +@pytest.mark.parametrize("hook", [ + b"\x02" + b"\x11" * 32 + b"\x01" + b"\x00" * 31, # reserved tail is not zero + b"\x00" * 65, # public hook, not a private commitment + b"\x02" + b"\x11" * 32, # wrong width +]) +def test_commitment_from_hook_data_rejects_non_private_or_malformed_hooks(hook): + with pytest.raises(ConfigurationError): + pm.commitment_from_hook_data(hook) + + +def test_identity_repr_never_shows_the_scalar(): + identity = pm.PrivateMintIdentity(counter=3, recipient=RECIPIENT, secret_nonce="7scalar", + address_commitment="ab" * 32) + text = repr(identity) + assert "7scalar" not in text and "ab" * 32 in text and "3" in text diff --git a/bridge-sdk/tests/test_private_mint_lifecycle.py b/bridge-sdk/tests/test_private_mint_lifecycle.py new file mode 100644 index 0000000..7033536 --- /dev/null +++ b/bridge-sdk/tests/test_private_mint_lifecycle.py @@ -0,0 +1,183 @@ +"""The lifecycle verbs derive, keep and recover a private mint nonce for the bridge's own account. + +``execute`` reserves one identity and deposits with its scalar; ``resume`` and ``complete`` find the +scalar again from the checkpointed hook / the attested payload, or re-derive it by counting up when +the store lost it. An explicit ``secret_nonce=`` bypasses the store; nothing derivable means a +refusal before any RPC — except for a quote, which prices the transfer regardless.""" +import aleo +import pytest + +from aleo_bridge import private_mint as pm +from aleo_bridge.encoding import xreserve_deposit_payload, xreserve_hook_data, xreserve_message_hash +from aleo_bridge.errors import ConfigurationError, NotResumableError +from aleo_bridge.lifecycle import complete, execute, prepare, quote, resume, to_progress +from aleo_bridge.types import Attestation, Receipt, Status +from tests.fakes.fake_bridge import EVM_ADDRESS, FakeBridge + +ACCOUNT = aleo.mainnet.Account.from_private_key(aleo.mainnet.PrivateKey.random()) +ME = str(ACCOUNT.address) +OTHER = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +APPROVAL = "0x" + "11" * 32 +QUOTE = dict(source_chain="ethereum", source_asset="usdc", destination_chain="aleo", destination_asset="usdcx", + amount="2", recipient=ME, sender=EVM_ADDRESS, mint_mode="private") + + +def _bridge(): + b = FakeBridge() + b.aleo.default_account = ACCOUNT + b.private_mint_identities = pm.MemoryPrivateMintIdentityStore() + return b + + +def _plan(b, recipient=ME): + return prepare(b.registry, **{**QUOTE, "recipient": recipient}) + + +def _reserve(b, recipient=ME): + return pm.reserve_identity(b.private_mint_identities, view_key=ACCOUNT.view_key, recipient=recipient, + network="mainnet") + + +# ── execute ────────────────────────────────────────────────────────────────── + +def test_execute_reserves_one_identity_and_deposits_with_its_scalar(): + b = _bridge() + execute(b, _plan(b)) + [identity] = b.private_mint_identities.load() + assert b.calls[0] == ("eth.deposit_usdc", {"plan": _plan(b), "secret_nonce": identity.secret_nonce}) + assert identity.recipient == ME and identity.secret_nonce != "0scalar" + + +def test_execute_with_an_explicit_nonce_reserves_nothing(): + b = _bridge() + execute(b, _plan(b), secret_nonce="7scalar") + assert b.private_mint_identities.load() == [] and b.calls[0][1]["secret_nonce"] == "7scalar" + + +def test_execute_without_store_or_nonce_is_refused_before_any_rpc(): + b = _bridge() + b.private_mint_identities = None + with pytest.raises(ConfigurationError, match="secret_nonce"): + execute(b, _plan(b)) + assert b.calls == [] + + +# ── quote ──────────────────────────────────────────────────────────────────── + +def test_quote_prices_a_private_transfer_on_a_read_only_bridge(): + b = FakeBridge() # no store, no view key + q = quote(b, **QUOTE) + assert q.kind == "evm-xreserve" and b.calls[0][1]["secret_nonce"] == "0scalar" + + +def test_quote_peeks_without_consuming_a_counter(): + b = _bridge() + quote(b, **QUOTE) + assert b.calls[0][1]["secret_nonce"] == pm.derive_secret_nonce(ACCOUNT.view_key, 0, "mainnet") + assert b.private_mint_identities.load() == [] + + +# ── resume ─────────────────────────────────────────────────────────────────── + +def _resumable(b, nonce): + plan = _plan(b) + hook = xreserve_hook_data("private", ME, "mainnet", nonce) + b.eth.hook_data = hook + receipt = Receipt(id=APPROVAL, protocol="xreserve", status=Status.SOURCE_SUBMISSION_PENDING, + protocol_state={"routeId": plan.route_id, "approvalTxIds": [APPROVAL], + "sourceSender": EVM_ADDRESS, "hookData": "0x" + hook.hex()}) + b.eth.recover_result = receipt + return plan, receipt + + +def test_resume_resolves_the_scalar_from_the_checkpointed_hook(): + b = _bridge() + identity = _reserve(b) + plan, receipt = _resumable(b, identity.secret_nonce) + resume(b, to_progress(plan, receipt)) + assert b.calls[-1][1]["secret_nonce"] == identity.secret_nonce + + +def test_resume_recovers_by_scanning_when_the_store_was_lost(): + b = _bridge() + nonce = pm.derive_secret_nonce(ACCOUNT.view_key, 3, "mainnet") + plan, receipt = _resumable(b, nonce) + resume(b, to_progress(plan, receipt)) + assert b.calls[-1][1]["secret_nonce"] == nonce and b.private_mint_identities.load()[0].counter == 3 + + +def test_resume_refuses_a_malformed_private_hook_before_any_rpc(): + b = _bridge() + plan, receipt = _resumable(b, "7scalar") + bad = "0x02" + "11" * 32 + "01" + "00" * 31 + with pytest.raises((ConfigurationError, NotResumableError)): + resume(b, to_progress(plan, receipt.replace(protocol_state={**receipt.protocol_state, "hookData": bad}))) + assert b.calls == [] + + +# ── complete ───────────────────────────────────────────────────────────────── + +def _ready(b, nonce, recipient=ME): + plan = _plan(b, recipient) + hook = xreserve_hook_data("private", recipient, "mainnet", nonce) + payload = xreserve_deposit_payload(amount=2_000_000, remote_domain=10_002, remote_token=b"\x11" * 32, + remote_recipient=b"\x22" * 32, + local_token="0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", + depositor=EVM_ADDRESS, max_fee=100_000, nonce=b"\x00" * 32, hook_data=hook) + message_hash = "0x" + xreserve_message_hash(payload).hex() + receipt = Receipt(id=message_hash, protocol="xreserve", status=Status.DESTINATION_ACTION_REQUIRED, + source_tx_id="0x" + "22" * 32, + next_action={"kind": "xreserve-private-mint", "chainId": "aleo"}, + protocol_state={"routeId": plan.route_id, "mintMode": "private", "intendedRecipient": recipient, + "payload": "0x" + payload.hex(), "messageHash": message_hash, + "attestation": "0x" + "33" * 65}) + return plan, receipt + + +def test_complete_resolves_the_scalar_from_the_attested_hook(): + b = _bridge() + identity = _reserve(b) + b.xreserve.expected_secret_nonce = identity.secret_nonce + plan, receipt = _ready(b, identity.secret_nonce) + out = complete(b, to_progress(plan, receipt)) + assert out.receipt.status is Status.DESTINATION_CONFIRMING + assert b.calls[-1][1]["secret_nonce"] == identity.secret_nonce + + +def test_complete_refuses_when_a_stored_identity_names_another_recipient(): + """A stored record whose commitment matches the attested hook but whose recipient is not the + plan's (tampering, or a hand-edited file) must stop before proving.""" + b = _bridge() + nonce = pm.derive_secret_nonce(ACCOUNT.view_key, 0, "mainnet") + plan, receipt = _ready(b, nonce, recipient=ME) # the deposit commits ME + b.private_mint_identities.save([pm.PrivateMintIdentity( + counter=0, recipient=OTHER, secret_nonce=nonce, + address_commitment=pm.derive_address_commitment(ME, nonce, "mainnet"))]) + with pytest.raises(ConfigurationError, match="recipient"): + complete(b, to_progress(plan, receipt)) + assert b.events == [] + + +def test_tier2_private_mint_resolves_when_no_nonce_is_given(): + from aleo_bridge.xreserve import XReserveModule + b = _bridge() + identity = _reserve(b) + plan, receipt = _ready(b, identity.secret_nonce) + route = next(r for r in b.registry.routes(bridge_protocol="xreserve", environment="mainnet") if r.id == plan.route_id) + att = Attestation(payload=bytes.fromhex(receipt.protocol_state["payload"][2:]), + message_hash=bytes.fromhex(receipt.id[2:]), attestation=b"\x33" * 65, status="complete") + inputs = XReserveModule(b).build_private_mint_inputs(route, att, ME, None) + assert inputs[3] == identity.secret_nonce and inputs[4] == ME + + +def test_tier2_private_mint_without_a_nonce_still_opens_a_zero_scalar_deposit(): + """Before derivation existed ``private_mint(att, recipient)`` defaulted to ``0scalar``; a bare bridge + that deposited under that default must still be able to mint without passing anything.""" + from aleo_bridge.xreserve import XReserveModule + b = FakeBridge() # no store, no view key + plan, receipt = _ready(b, "0scalar") + route = next(r for r in b.registry.routes(bridge_protocol="xreserve", environment="mainnet") if r.id == plan.route_id) + att = Attestation(payload=bytes.fromhex(receipt.protocol_state["payload"][2:]), + message_hash=bytes.fromhex(receipt.id[2:]), attestation=b"\x33" * 65, status="complete") + inputs = XReserveModule(b).build_private_mint_inputs(route, att, ME, None) + assert inputs[3] == "0scalar" diff --git a/bridge-sdk/tests/test_private_mint_resolver.py b/bridge-sdk/tests/test_private_mint_resolver.py new file mode 100644 index 0000000..27346a7 --- /dev/null +++ b/bridge-sdk/tests/test_private_mint_resolver.py @@ -0,0 +1,89 @@ +"""``resolve_secret_nonce``: the one place every verb turns ``secret_nonce=None`` into a scalar. + +An explicit nonce wins and touches no store. Otherwise the bridge derives for its own account — +reserving at execute, looking up (or re-deriving) at resume/complete — and refuses, naming both +fixes, when it cannot.""" +import aleo +import pytest + +from aleo_bridge import private_mint as pm +from aleo_bridge.errors import ConfigurationError +from tests.fakes.fake_bridge import FakeBridge + +ACCOUNT = aleo.mainnet.Account.from_private_key(aleo.mainnet.PrivateKey.random()) +ME = str(ACCOUNT.address) +OTHER = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +PROGRAM = pm.DEFAULT_WRAPPER_PROGRAM + + +def _bridge(store=True, account=True): + b = FakeBridge() + if account: + b.aleo.default_account = ACCOUNT + b.private_mint_identities = pm.MemoryPrivateMintIdentityStore() if store else None + return b + + +def test_explicit_nonce_wins_and_touches_no_store(): + b = _bridge() + assert pm.resolve_secret_nonce(b, recipient=ME, program=PROGRAM, secret_nonce="7scalar") == "7scalar" + assert b.private_mint_identities.load() == [] + + +def test_execute_reserves_and_later_phases_find_the_same_scalar(): + b = _bridge() + nonce = pm.resolve_secret_nonce(b, recipient=ME, program=PROGRAM) + commitment = pm.derive_address_commitment(ME, nonce, "mainnet") + assert pm.resolve_secret_nonce(b, recipient=ME, program=PROGRAM, address_commitment=commitment) == nonce + assert len(b.private_mint_identities.load()) == 1 + + +def test_recovery_scans_when_the_store_is_empty(): + b = _bridge() + nonce = pm.derive_secret_nonce(ACCOUNT.view_key, 5, "mainnet") + commitment = pm.derive_address_commitment(ME, nonce, "mainnet") + assert pm.resolve_secret_nonce(b, recipient=ME, program=PROGRAM, address_commitment=commitment) == nonce + + +@pytest.mark.parametrize("store,account,recipient", [(False, True, ME), (True, False, ME), (True, True, OTHER)]) +def test_underivable_cases_fail_naming_both_fixes(store, account, recipient): + b = _bridge(store=store, account=account) + with pytest.raises(ConfigurationError, match="secret_nonce") as info: + pm.resolve_secret_nonce(b, recipient=recipient, program=PROGRAM) + assert "private_mint_identities" in str(info.value) + assert pm.can_derive(b, recipient) is False + + +def test_quote_placeholder_only_when_nothing_can_derive(): + assert pm.resolve_secret_nonce(_bridge(store=False), recipient=ME, program=PROGRAM, placeholder=True) == "0scalar" + b = _bridge() + expected = pm.derive_secret_nonce(ACCOUNT.view_key, 0, "mainnet") + assert pm.resolve_secret_nonce(b, recipient=ME, program=PROGRAM, placeholder=True) == expected + assert b.private_mint_identities.load() == [] + + +def test_stored_identity_for_another_recipient_is_refused(): + b = _bridge() + identity = pm.reserve_identity(b.private_mint_identities, view_key=ACCOUNT.view_key, recipient=OTHER, + network="mainnet") + with pytest.raises(ConfigurationError, match="recipient"): + pm.resolve_secret_nonce(b, recipient=ME, program=PROGRAM, address_commitment=identity.address_commitment) + + +def test_default_store_rides_with_a_file_checkpoint_store(tmp_path): + from aleo_bridge.checkpoint import FileCheckpointStore + store = pm.default_identity_store(FileCheckpointStore(tmp_path)) + assert isinstance(store, pm.FilePrivateMintIdentityStore) + assert store.path == tmp_path / ".private-mint-identities.json" + assert pm.default_identity_store(None) is None and pm.default_identity_store(object()) is None + + +def test_lookup_recognises_a_deposit_made_under_the_zero_scalar_default(): + """Tier-2 compatibility: ``eth.deposit_usdc`` still defaults to ``0scalar``; a later look-up for that + commitment answers ``0scalar`` at once — with no store, no account, and without scanning.""" + zero = pm.derive_address_commitment(ME, "0scalar", "mainnet") + assert pm.resolve_secret_nonce(_bridge(store=False, account=False), recipient=ME, program=PROGRAM, + address_commitment=zero) == "0scalar" + b = _bridge() + assert pm.resolve_secret_nonce(b, recipient=ME, program=PROGRAM, address_commitment=zero) == "0scalar" + assert b.private_mint_identities.load() == [] diff --git a/bridge-sdk/tests/test_private_mint_store.py b/bridge-sdk/tests/test_private_mint_store.py new file mode 100644 index 0000000..d1bac8c --- /dev/null +++ b/bridge-sdk/tests/test_private_mint_store.py @@ -0,0 +1,140 @@ +"""The private mint identity store: counters never recycle, files are 0600 and atomic, a second +instance (or process) on the same path continues the sequence, and a lost file is recovered by +re-deriving counters from zero.""" +import json +import stat +import threading + +import pytest + +from aleo_bridge import private_mint as pm +from aleo_bridge.errors import ConfigurationError + +VIEW_KEY = "AViewKey1sqm952gJj1tmAWySYDQvSv2NmfnyEMvU6a9ZBCuyG7PN" +RECIPIENT = "aleo1kypwp5m7qtk9mwazgcpg0tq8aal23mnrvwfvug65qgcg9xvsrqgspyjm6n" +KW = dict(view_key=VIEW_KEY, recipient=RECIPIENT, network="mainnet") + + +def _file_store(tmp_path): + return pm.FilePrivateMintIdentityStore(tmp_path / ".private-mint-identities.json") + + +def test_reservation_is_monotonic_and_saved(tmp_path): + store = _file_store(tmp_path) + first, second = pm.reserve_identity(store, **KW), pm.reserve_identity(store, **KW) + assert (first.counter, second.counter) == (0, 1) + assert first.secret_nonce == pm.derive_secret_nonce(VIEW_KEY, 0, "mainnet") + assert [i.address_commitment for i in store.load()] == [first.address_commitment, second.address_commitment] + assert pm.find_identity(store, second.address_commitment) == second + assert pm.find_identity(store, "00" * 32) is None + + +def test_file_store_mode_and_companions(tmp_path): + store = _file_store(tmp_path) + pm.reserve_identity(store, **KW) + assert stat.S_IMODE(store.path.stat().st_mode) == 0o600 + assert stat.S_IMODE(store.counter_path.stat().st_mode) == 0o600 + assert store.counter_path.read_text().strip() == "1" + data = json.loads(store.path.read_text()) + assert data["identities"][0]["secretNonce"].endswith("scalar") + assert not any(p.name.endswith(".tmp") for p in tmp_path.iterdir()) + + +def test_counter_survives_losing_the_identities_file(tmp_path): + store = _file_store(tmp_path) + pm.reserve_identity(store, **KW) + pm.reserve_identity(store, **KW) + store.path.unlink() + assert pm.reserve_identity(store, **KW).counter == 2 + + +def test_counter_is_consumed_before_a_failed_derivation(tmp_path): + store = _file_store(tmp_path) + with pytest.raises(ConfigurationError): + pm.reserve_identity(store, view_key="AViewKey1nope", recipient=RECIPIENT, network="mainnet") + assert pm.reserve_identity(store, **KW).counter == 1 + + +def test_a_second_instance_on_the_same_path_continues_the_sequence(tmp_path): + a, b = _file_store(tmp_path), _file_store(tmp_path) + pm.reserve_identity(a, **KW) + assert pm.reserve_identity(b, **KW).counter == 1 + assert len(a.load()) == 2 + + +def test_concurrent_reservations_get_distinct_counters(tmp_path): + store = _file_store(tmp_path) + out: list[pm.PrivateMintIdentity] = [] + threads = [threading.Thread(target=lambda: out.append(pm.reserve_identity(store, **KW))) for _ in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + assert sorted(i.counter for i in out) == list(range(8)) and len(store.load()) == 8 + + +def test_peek_does_not_consume(tmp_path): + store = _file_store(tmp_path) + assert pm.peek_secret_nonce(store, **KW) == pm.derive_secret_nonce(VIEW_KEY, 0, "mainnet") + assert pm.reserve_identity(store, **KW).counter == 0 + + +def test_recover_rederives_after_the_file_is_lost(tmp_path): + store = _file_store(tmp_path) + lost = pm.reserve_identity(store, **KW) + pm.reserve_identity(store, **KW) + store.path.unlink() + store.counter_path.unlink() + found = pm.recover_identity(store, address_commitment=lost.address_commitment, **KW) + assert found == lost and pm.find_identity(store, lost.address_commitment) == lost + + +def test_recover_stops_at_the_scan_limit_with_an_actionable_error(tmp_path): + store = _file_store(tmp_path) + with pytest.raises(ConfigurationError, match=r"3 counters.*secret_nonce"): + pm.recover_identity(store, address_commitment="ab" * 32, scan_limit=3, **KW) + + +def test_recover_rejects_a_stored_identity_for_another_recipient(tmp_path): + store = _file_store(tmp_path) + other = "aleo132x69f77mz7cx2f4s5wykktj73tj8cy7smdvxrgwja9qcmyuysxst42d9m" + identity = pm.reserve_identity(store, view_key=VIEW_KEY, recipient=other, network="mainnet") + with pytest.raises(ConfigurationError, match="recipient"): + pm.recover_identity(store, address_commitment=identity.address_commitment, **KW) + + +@pytest.mark.parametrize("corrupt", ["not json", '{"identities": [{"counter": -1}]}']) +def test_malformed_identities_file_raises_instead_of_resetting(tmp_path, corrupt): + store = _file_store(tmp_path) + store.path.write_text(corrupt) + with pytest.raises(ConfigurationError, match="restore"): + pm.reserve_identity(store, **KW) + + +def test_malformed_counter_file_raises(tmp_path): + store = _file_store(tmp_path) + store.counter_path.write_text("x") + with pytest.raises(ConfigurationError, match="restore"): + pm.reserve_identity(store, **KW) + + +def test_counter_space_exhaustion(tmp_path): + store = _file_store(tmp_path) + store.counter_path.write_text(str(pm.MAX_COUNTER + 1)) + with pytest.raises(ConfigurationError, match="exhausted"): + pm.reserve_identity(store, **KW) + + +def test_memory_store_is_explicit_only(): + store = pm.MemoryPrivateMintIdentityStore() + assert pm.reserve_identity(store, **KW).counter == 0 and pm.reserve_identity(store, **KW).counter == 1 + + +def test_recovery_scan_is_fast_enough_to_use_the_default_limit(tmp_path): + """Hoisted invariants: 2,000 counters in well under the time one would take re-parsing everything.""" + import time + store = _file_store(tmp_path) + started = time.perf_counter() + with pytest.raises(ConfigurationError, match="2000 counters"): + pm.recover_identity(store, address_commitment="ab" * 32, scan_limit=2000, **KW) + assert time.perf_counter() - started < 5.0 diff --git a/bridge-sdk/tests/test_resume_complete.py b/bridge-sdk/tests/test_resume_complete.py index a816b43..ad2d996 100644 --- a/bridge-sdk/tests/test_resume_complete.py +++ b/bridge-sdk/tests/test_resume_complete.py @@ -228,8 +228,9 @@ def test_resume_of_a_private_mint_without_its_secret_nonce_is_refused_before_any b = FakeBridge() plan, receipt = _xreserve_progress(b, mint_mode="private") b.eth.recover_result = receipt - with pytest.raises(ConfigurationError, match="secret_nonce"): + with pytest.raises(ConfigurationError, match="secret_nonce") as info: resume(b, to_progress(plan, receipt)) + assert "private_mint_identities" in str(info.value) # the other fix: let the bridge derive assert b.calls == [] # a public mint has nothing to commit to: the default is fine b2 = FakeBridge() diff --git a/bridge-sdk/tests/test_sol_bridge.py b/bridge-sdk/tests/test_sol_bridge.py index 743b28b..ffbfd12 100644 --- a/bridge-sdk/tests/test_sol_bridge.py +++ b/bridge-sdk/tests/test_sol_bridge.py @@ -82,7 +82,8 @@ def test_from_env_builds_the_solana_connection(monkeypatch): key = b58encode(bytes(Keypair())) seen = {} - def capture_init(self, aleo, *, ethereum=None, solana=None, environment=None, registry=None, checkpoints=None, evm=None): + def capture_init(self, aleo, *, ethereum=None, solana=None, environment=None, registry=None, checkpoints=None, + evm=None, private_mint_identities=None): seen["solana"] = solana monkeypatch.setattr(Bridge, "__init__", capture_init)