diff --git a/.changes/20260718_cardano_wasm_demo_wallets.yml b/.changes/20260718_cardano_wasm_demo_wallets.yml new file mode 100644 index 0000000000..87012fb25f --- /dev/null +++ b/.changes/20260718_cardano_wasm_demo_wallets.yml @@ -0,0 +1,9 @@ +project: cardano-wasm + +pr: 1270 + +kind: + - feature + +description: | + Demo: multi-wallet management (generate/restore, aliases, network switch with address re-derivation). diff --git a/cardano-wasm/demo/src/Format.elm b/cardano-wasm/demo/src/Format.elm new file mode 100644 index 0000000000..35ad75d08c --- /dev/null +++ b/cardano-wasm/demo/src/Format.elm @@ -0,0 +1,123 @@ +module Format exposing (ada, adaToLovelace, amountError, lovelaceToAda, orDefault, shorten) + +{-| String formatting and parsing: ada ↔ lovelace, abbreviation, small text helpers. +-} + + +{-| Parse a user-typed ADA amount ("1.5" → 1500000 lovelace). +Exact decimal parsing — the digits are scaled as integers, so amounts never +pick up float rounding. At most 6 decimals, no sign, no exponent. +-} +adaToLovelace : String -> Maybe Int +adaToLovelace s = + case String.split "." (String.trim s) of + [ whole ] -> + Maybe.map ((*) 1000000) (digits whole) + + [ whole, frac ] -> + if frac == "" || String.length frac > 6 then + Nothing + + else + Maybe.map2 (\w f -> w * 1000000 + f * 10 ^ (6 - String.length frac)) + (digits whole) + (digits frac) + + _ -> + Nothing + + +{-| String.toInt restricted to plain digit runs (rejects signs and exponents). +-} +digits : String -> Maybe Int +digits str = + if str /= "" && String.all Char.isDigit str then + String.toInt str + + else + Nothing + + +{-| True when a typed amount is non-empty but not a valid positive number (e.g. "1,5"). +-} +amountError : String -> Bool +amountError s = + if String.trim s == "" then + False + + else + case adaToLovelace s of + Just n -> + n <= 0 + + Nothing -> + True + + +lovelaceToAda : Int -> String +lovelaceToAda l = + let + sign = + if l < 0 then + "-" + + else + "" + + a = + abs l + + whole = + a // 1000000 + + frac = + String.padLeft 6 '0' (String.fromInt (modBy 1000000 a)) |> stripTrailingZeros + in + sign + ++ String.fromInt whole + ++ (if frac == "" then + "" + + else + "." ++ frac + ) + + +ada : Int -> String +ada l = + lovelaceToAda l ++ " ₳" + + +stripTrailingZeros : String -> String +stripTrailingZeros s = + String.foldr + (\c ( acc, trimming ) -> + if trimming && c == '0' then + ( acc, True ) + + else + ( String.cons c acc, False ) + ) + ( "", True ) + s + |> Tuple.first + + +{-| Abbreviate long identifiers (addresses, keys, hashes) for display. +-} +shorten : String -> String +shorten s = + if String.length s > 22 then + String.left 12 s ++ "…" ++ String.right 6 s + + else + s + + +orDefault : String -> String -> String +orDefault d s = + if String.trim s == "" then + d + + else + s diff --git a/cardano-wasm/demo/src/Main.elm b/cardano-wasm/demo/src/Main.elm index a2122176cf..b3b9716567 100644 --- a/cardano-wasm/demo/src/Main.elm +++ b/cardano-wasm/demo/src/Main.elm @@ -1,24 +1,42 @@ module Main exposing (main) -{-| Placeholder page: proves the demo build pipeline and that the cardano-wasm -engine loads in the browser (web/ports.js only starts this application after -`initialise()` has succeeded). The wallet application itself follows. +{-| Entry point — wires The Elm Architecture together. The interesting code lives in: + + - Types — every data type, the Model and the Msg + - State — initial state, derived queries, small updaters + - Update — the controller (one branch per Msg) + - View — the whole UI + - Wasm — the cardano-wasm boundary (port commands + decoders) + - Net / Format — static tables and pure utilities + - Ports — the raw port declarations (JS side: web/ports.js) + -} -import Html exposing (Html, div, h3, p, text) -import Html.Attributes exposing (class, style) +import Browser +import Ports +import State exposing (init) +import Types exposing (..) +import Update exposing (update) +import View exposing (view) +import Wasm -main : Html msg -main = - div [ class "grid" ] - [ div [ class "col" ] [] - , div [ class "col" ] - [ div [ class "card", style "margin-top" "40px" ] - [ h3 [] [ text "cardano-wasm wallet demo" ] - , p [] [ text "✓ the cardano-wasm engine loaded successfully in your browser." ] - , p [ class "muted small" ] [ text "The wallet application will appear here as it is built up in subsequent changes." ] - ] - ] - , div [ class "col" ] [] +{-| Each incoming port is decoded by Wasm and dispatched as a Msg. +-} +subscriptions : Model -> Sub Msg +subscriptions _ = + Sub.batch + [ Ports.wasmWalletGenerated (Wasm.decodeResult Wasm.genDecoder >> GotGeneratedWallet) + , Ports.wasmWalletRestored (Wasm.decodeResult Wasm.genDecoder >> GotRestoredWallet) + , Ports.wasmAddressesDerived (Wasm.decodeResult Wasm.addrsDecoder >> GotDerivedAddresses) ] + + +main : Program () Model Msg +main = + Browser.element + { init = init + , update = update + , view = view + , subscriptions = subscriptions + } diff --git a/cardano-wasm/demo/src/Net.elm b/cardano-wasm/demo/src/Net.elm new file mode 100644 index 0000000000..94cf0a376d --- /dev/null +++ b/cardano-wasm/demo/src/Net.elm @@ -0,0 +1,108 @@ +module Net exposing + ( blockfrostBase + , cliFlag + , explorerTx + , faucetUrl + , netMagic + , netName + , netTag + ) + +{-| Static tables for the three networks. Pure data — no logic lives here. +-} + +import Types exposing (..) + + + +-- NETWORKS + + +netName : Network -> String +netName n = + case n of + Mainnet -> + "Mainnet" + + Preprod -> + "Preprod" + + Preview -> + "Preview" + + +{-| Lowercase identifier used on the JS side of the ports. +-} +netTag : Network -> String +netTag n = + case n of + Mainnet -> + "mainnet" + + Preprod -> + "preprod" + + Preview -> + "preview" + + +netMagic : Network -> String +netMagic n = + case n of + Mainnet -> + "764824073" + + Preprod -> + "1" + + Preview -> + "2" + + +blockfrostBase : Network -> String +blockfrostBase n = + case n of + Mainnet -> + "https://cardano-mainnet.blockfrost.io/api/v0" + + Preprod -> + "https://cardano-preprod.blockfrost.io/api/v0" + + Preview -> + "https://cardano-preview.blockfrost.io/api/v0" + + +explorerTx : Network -> String +explorerTx n = + case n of + Mainnet -> + "https://cardanoscan.io/" + + Preprod -> + "https://preprod.cardanoscan.io/" + + Preview -> + "https://preview.cardanoscan.io/" + + +faucetUrl : Network -> Maybe String +faucetUrl n = + case n of + Mainnet -> + Nothing + + _ -> + Just "https://docs.cardano.org/cardano-testnets/tools/faucet/" + + +cliFlag : Network -> String +cliFlag n = + case n of + Mainnet -> + "--mainnet" + + Preprod -> + "--testnet-magic 1" + + Preview -> + "--testnet-magic 2" diff --git a/cardano-wasm/demo/src/Ports.elm b/cardano-wasm/demo/src/Ports.elm new file mode 100644 index 0000000000..2966b3393e --- /dev/null +++ b/cardano-wasm/demo/src/Ports.elm @@ -0,0 +1,38 @@ +port module Ports exposing (..) + +{-| The raw port declarations — the only holes in the wall between Elm and +JavaScript. The JS side lives in web/ports.js. Payloads are untyped JSON; +Wasm.elm encodes the requests and decodes the replies. +-} + +import Json.Decode as D +import Json.Encode as E + + + +-- PORTS (out → cardano-wasm / clipboard) + + +port wasmGenerateWallet : E.Value -> Cmd msg + + +port wasmRestoreWallet : E.Value -> Cmd msg + + +port wasmDeriveAddresses : E.Value -> Cmd msg + + +port clipboardWrite : String -> Cmd msg + + + +-- PORTS (in ← cardano-wasm) + + +port wasmWalletGenerated : (D.Value -> msg) -> Sub msg + + +port wasmWalletRestored : (D.Value -> msg) -> Sub msg + + +port wasmAddressesDerived : (D.Value -> msg) -> Sub msg diff --git a/cardano-wasm/demo/src/State.elm b/cardano-wasm/demo/src/State.elm new file mode 100644 index 0000000000..8a32af40f7 --- /dev/null +++ b/cardano-wasm/demo/src/State.elm @@ -0,0 +1,147 @@ +module State exposing + ( addWallet + , aliasOf + , emptyRestoreForm + , getWallet + , init + , log + , mapWallet + , setRestorePay + , setRestoreStake + , toastNow + , toggleRestore + ) + +{-| Everything about the Model: the initial state, derived queries (what the view +and update read), and the small pure updaters. No commands except the toast timer. +-} + +import Process +import Task +import Types exposing (..) + + + +-- INIT + + +init : () -> ( Model, Cmd Msg ) +init _ = + ( { network = Preview + , wallets = [] + , nextWid = 1 + , modal = NoModal + , restore = emptyRestoreForm + , console = + [ LogLine LogInfo "cardano-wasm loaded · post-link module ready" ] + , toast = Nothing + , toastSeq = 0 + } + , Cmd.none + ) + + +emptyRestoreForm : RestoreForm +emptyRestoreForm = + { open = False, paymentSkey = "", stakeSkey = "" } + + + +-- WALLETS + + +getWallet : WalletId -> Model -> Maybe Wallet +getWallet wid model = + List.filter (\w -> w.id == wid) model.wallets |> List.head + + +mapWallet : WalletId -> (Wallet -> Wallet) -> Model -> Model +mapWallet wid f model = + { model + | wallets = + List.map + (\w -> + if w.id == wid then + f w + + else + w + ) + model.wallets + } + + +aliasOf : WalletId -> Model -> String +aliasOf wid model = + getWallet wid model |> Maybe.map .alias |> Maybe.withDefault "?" + + +avatarColors : List String +avatarColors = + [ "#3b73ff", "#33d17a", "#f6b73c", "#ff6b6b", "#a06bff", "#19cdd7", "#ff9ed6", "#5ee89c" ] + + +addWallet : GenPayload -> Model -> Model +addWallet p model = + let + color = + List.drop (modBy (List.length avatarColors) (model.nextWid - 1)) avatarColors + |> List.head + |> Maybe.withDefault "#3b73ff" + + w = + { id = model.nextWid + , alias = "Wallet " ++ String.fromInt model.nextWid + , address = p.address + , keys = p.keys + , expanded = True + , color = color + } + in + { model | wallets = model.wallets ++ [ w ], nextWid = model.nextWid + 1 } + + + +-- CONSOLE & TOAST + + +log : LogLevel -> String -> Model -> Model +log level text model = + let + entries = + model.console ++ [ LogLine level text ] + in + -- keep the last 200 lines only + { model | console = List.drop (List.length entries - 200) entries } + + +{-| Show a toast and schedule its dismissal; the sequence number ignores stale timers. +-} +toastNow : String -> Model -> ( Model, Cmd Msg ) +toastNow text model = + let + seq = + model.toastSeq + 1 + in + ( { model | toast = Just text, toastSeq = seq } + , Process.sleep 1900 |> Task.perform (\_ -> ClearToast seq) + ) + + + +-- SMALL FORM UPDATERS + + +toggleRestore : RestoreForm -> RestoreForm +toggleRestore r = + { r | open = not r.open } + + +setRestorePay : String -> RestoreForm -> RestoreForm +setRestorePay s r = + { r | paymentSkey = s } + + +setRestoreStake : String -> RestoreForm -> RestoreForm +setRestoreStake s r = + { r | stakeSkey = s } diff --git a/cardano-wasm/demo/src/Types.elm b/cardano-wasm/demo/src/Types.elm new file mode 100644 index 0000000000..0dc88c732e --- /dev/null +++ b/cardano-wasm/demo/src/Types.elm @@ -0,0 +1,93 @@ +module Types exposing (..) + +{-| Every data type in the application: the Model (all state in one record) +and the Msg (everything that can happen). +-} + + +type Network + = Mainnet + | Preprod + | Preview + + +type alias WalletId = + Int + + +type alias Keys = + { paymentVKey : String + , paymentSKey : String + , stakeVKey : String + , stakeSKey : String + , paymentKeyHash : String + , stakeKeyHash : String + } + + +type alias Wallet = + { id : WalletId + , alias : String + , address : String + , keys : Keys + , expanded : Bool + , color : String + } + + +type Modal + = NoModal + | ForgetDialog WalletId + + +type alias RestoreForm = + { open : Bool, paymentSkey : String, stakeSkey : String } + + +type LogLevel + = LogInfo + | LogOk + | LogWarn + | LogCmd -- echo of the cardano-wasm call being made + + +type alias LogLine = + { level : LogLevel, text : String } + + +type alias GenPayload = + { address : String, keys : Keys } + + +type alias Model = + { network : Network + , wallets : List Wallet + , nextWid : Int + , modal : Modal + , restore : RestoreForm + , console : List LogLine + , toast : Maybe String + , toastSeq : Int + } + + +type Msg + = SelectNetwork Network + | ClickNewWallet + | GotGeneratedWallet (Result String GenPayload) + | ClickRestoreToggle + | UpdateRestorePay String + | UpdateRestoreStake String + | SubmitRestore + | CancelRestore + | GotRestoredWallet (Result String GenPayload) + | GotDerivedAddresses (Result String (List ( WalletId, String ))) + | ToggleWalletExpanded WalletId + | EditAlias WalletId String + | RequestForget WalletId + | ConfirmForget WalletId + | CancelForget + | Copy String + | ClearConsole + | ClearToast Int + | NoOp diff --git a/cardano-wasm/demo/src/Update.elm b/cardano-wasm/demo/src/Update.elm new file mode 100644 index 0000000000..77e2d890ea --- /dev/null +++ b/cardano-wasm/demo/src/Update.elm @@ -0,0 +1,129 @@ +module Update exposing (update) + +{-| The controller: every Msg in one `update`. Pure state changes call State +helpers; effects go through Wasm (ports). +-} + +import Net exposing (netName) +import Ports exposing (clipboardWrite) +import State exposing (..) +import Types exposing (..) +import Wasm + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + NoOp -> + ( model, Cmd.none ) + + -- ── network ──────────────────────────────────────────────────────────── + SelectNetwork n -> + -- Wallet keys survive a network switch; addresses are re-derived below + -- (the bech32 encoding is network-specific, the keys are not). + ( { model | network = n } + |> log LogInfo ("switched to " ++ netName n) + , if List.isEmpty model.wallets then + Cmd.none + + else + Wasm.deriveAddresses n model.wallets + ) + + GotDerivedAddresses (Ok pairs) -> + ( { model + | wallets = + List.map + (\w -> + case List.filter (\( i, _ ) -> i == w.id) pairs |> List.head of + Just ( _, addr ) -> + { w | address = addr } + + Nothing -> + w + ) + model.wallets + } + , Cmd.none + ) + + GotDerivedAddresses (Err e) -> + ( log LogWarn ("derive addresses failed: " ++ e) model, Cmd.none ) + + -- ── wallets: generate / restore / edit / forget ──────────────────────── + ClickNewWallet -> + ( log LogCmd "CardanoApi.wallet.generateStakeWallet()" model + , Wasm.generateWallet model.network + ) + + GotGeneratedWallet (Ok p) -> + ( addWallet p model |> log LogOk "generated wallet", Cmd.none ) + + GotGeneratedWallet (Err e) -> + ( log LogWarn ("generate failed: " ++ e) model, Cmd.none ) + + ClickRestoreToggle -> + ( { model | restore = toggleRestore model.restore }, Cmd.none ) + + UpdateRestorePay s -> + ( { model | restore = setRestorePay s model.restore }, Cmd.none ) + + UpdateRestoreStake s -> + ( { model | restore = setRestoreStake s model.restore }, Cmd.none ) + + CancelRestore -> + ( { model | restore = emptyRestoreForm }, Cmd.none ) + + SubmitRestore -> + ( log LogCmd "CardanoApi.wallet.restoreStakeWalletFromSigningKeyBech32(...)" model + , Wasm.restoreWallet model.network model.restore + ) + + GotRestoredWallet (Ok p) -> + ( addWallet p { model | restore = emptyRestoreForm } |> log LogOk "restored wallet" + , Cmd.none + ) + + GotRestoredWallet (Err e) -> + ( log LogWarn ("restore failed: " ++ e) model, Cmd.none ) + + ToggleWalletExpanded wid -> + ( mapWallet wid (\w -> { w | expanded = not w.expanded }) model, Cmd.none ) + + EditAlias wid s -> + ( mapWallet wid (\w -> { w | alias = s }) model, Cmd.none ) + + RequestForget wid -> + ( { model | modal = ForgetDialog wid }, Cmd.none ) + + CancelForget -> + ( { model | modal = NoModal }, Cmd.none ) + + ConfirmForget wid -> + ( { model + | wallets = List.filter (\w -> w.id /= wid) model.wallets + , modal = NoModal + } + |> log LogWarn "forgot wallet" + , Cmd.none + ) + + -- ── misc ─────────────────────────────────────────────────────────────── + Copy t -> + let + ( m, c ) = + toastNow "Copied" model + in + ( m, Cmd.batch [ c, clipboardWrite t ] ) + + ClearConsole -> + ( { model | console = [] }, Cmd.none ) + + ClearToast n -> + ( if n == model.toastSeq then + { model | toast = Nothing } + + else + model + , Cmd.none + ) diff --git a/cardano-wasm/demo/src/View.elm b/cardano-wasm/demo/src/View.elm new file mode 100644 index 0000000000..0ecf5d1922 --- /dev/null +++ b/cardano-wasm/demo/src/View.elm @@ -0,0 +1,227 @@ +module View exposing (view) + +{-| The UI: wallets column · (builder placeholder) · console column, the forget +dialog and the toast. Pure Model → Html. +-} + +import Format exposing (shorten) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick, onInput, stopPropagationOn) +import Json.Decode as D +import Net exposing (faucetUrl, netMagic, netName) +import State exposing (..) +import Types exposing (..) + + +view : Model -> Html Msg +view model = + div [] + [ div [ class "mock" ] [ text "◆ DEMO — keys, balances, fees and transactions are real cardano-wasm calls on the selected network. Use TESTNETS only." ] + , viewTopbar model + , div [ class "grid" ] + [ div [ class "col" ] [ viewWalletsCard model ] + , div [ class "col" ] + [ div [ class "card" ] + [ h3 [] [ text "Transaction builder" ] + , div [ class "empty" ] [ text "coming in a later change" ] + ] + ] + , div [ class "col" ] [ viewConsole model ] + ] + , viewModal model + , viewToast model + ] + + +viewTopbar : Model -> Html Msg +viewTopbar model = + div [ class "topbar" ] + [ div [ class "brand" ] [ span [ class "b" ] [ text "◆" ], text " cardano-wasm ", span [ class "muted" ] [ text "demo · multi-wallet" ] ] + , div [ class "nettabs" ] + (List.map + (\n -> + button [ classList [ ( "on", model.network == n ) ], onClick (SelectNetwork n) ] [ text (netName n) ] + ) + [ Mainnet, Preprod, Preview ] + ) + , span [ class "magic" ] [ text ("magic " ++ netMagic model.network) ] + ] + + +viewWalletsCard : Model -> Html Msg +viewWalletsCard model = + div [ class "card" ] + [ h3 [] + [ text "Wallets" + , span [ class "hrow" ] + [ button [ class "btn xs", onClick ClickNewWallet ] [ text "+ New" ] + , button [ class "btn ghost xs", onClick ClickRestoreToggle ] [ text "Restore" ] + ] + ] + , if model.restore.open then + div [ class "restore" ] + [ input [ placeholder "payment signing key (bech32)", value model.restore.paymentSkey, onInput UpdateRestorePay ] [] + , input [ placeholder "stake signing key (bech32)", value model.restore.stakeSkey, onInput UpdateRestoreStake, style "margin-top" "6px" ] [] + , div [ class "hrow", style "margin-top" "6px" ] + [ button [ class "btn sm", style "flex" "1", onClick SubmitRestore ] [ text "Restore wallet" ] + , button [ class "btn ghost sm", onClick CancelRestore ] [ text "Cancel" ] + ] + ] + + else + text "" + , if List.isEmpty model.wallets then + div [ class "empty" ] [ text "No wallets yet — click + New" ] + + else + div [] (List.map (viewWallet model) model.wallets) + , case faucetUrl model.network of + Just url -> + if List.isEmpty model.wallets then + text "" + + else + a [ href url, target "_blank", rel "noopener noreferrer", class "faucet btn ghost sm block" ] [ text "🚰 Faucet" ] + + Nothing -> + text "" + ] + + +viewWallet : Model -> Wallet -> Html Msg +viewWallet model w = + div [ classList [ ( "wallet", True ), ( "open", w.expanded ) ] ] + [ div [ class "whead", onClick (ToggleWalletExpanded w.id) ] + [ span [ class "wchev" ] [ text "▶" ] + , span [ class "wav", style "background" w.color ] [ text (String.left 1 w.alias |> String.toUpper) ] + , span [ class "winfo" ] + [ input [ class "walias", value w.alias, stopClick, onInput (EditAlias w.id) ] [] + , span [ class "waddr mono" ] [ text (shorten w.address) ] + ] + ] + , if w.expanded then + div [ class "wbody" ] + [ div [ class "kv" ] + [ span [ class "k" ] [ text "Address" ] + , span [ class "v mono" ] + [ text (shorten w.address) + , text " " + , button [ class "btn ghost xs", onClick (Copy w.address) ] [ text "copy" ] + ] + ] + , details [] + [ summary [] [ text "keys & hashes (signing keys are your backup)" ] + , kv "pay vkey" (shorten w.keys.paymentVKey) + , kvSecret "pay skey" w.keys.paymentSKey + , kv "stake vkey" (shorten w.keys.stakeVKey) + , kvSecret "stake skey" w.keys.stakeSKey + , kv "pay keyhash" (shorten w.keys.paymentKeyHash) + , kv "stake keyhash" (shorten w.keys.stakeKeyHash) + ] + , div [ class "hrow", style "margin-top" "8px" ] + [ button [ class "btn xs danger", onClick (RequestForget w.id) ] [ text "🗑 forget" ] ] + ] + + else + text "" + ] + + +viewConsole : Model -> Html Msg +viewConsole model = + div [ class "card" ] + [ h3 [] [ text "Console", button [ class "btn ghost xs", onClick ClearConsole ] [ text "clear" ] ] + + -- newest rendered first + CSS column-reverse = chronological order with the + -- scroll position pinned to the latest line (no scroll commands needed) + , div [ class "console" ] + (List.map + (\l -> + div [ class ("ln " ++ logClass l.level) ] [ text (logPrefix l.level ++ " " ++ l.text) ] + ) + (List.reverse model.console) + ) + ] + + +logClass : LogLevel -> String +logClass l = + case l of + LogInfo -> + "info" + + LogOk -> + "ok" + + LogWarn -> + "warn" + + LogCmd -> + "cmd" + + +logPrefix : LogLevel -> String +logPrefix l = + case l of + LogInfo -> + "→" + + LogOk -> + "✓" + + LogWarn -> + "!" + + LogCmd -> + "$" + + +viewModal : Model -> Html Msg +viewModal model = + case model.modal of + NoModal -> + text "" + + ForgetDialog wid -> + div [ class "modal-bg" ] + [ div [ class "modal sm" ] + [ div [ class "mh" ] [ h3 [] [ text ("Forget " ++ aliasOf wid model ++ "?") ] ] + , div [ class "mb" ] + [ p [] [ text "Keys are not stored — be sure the signing keys are saved." ] + , div [ class "hrow" ] + [ button [ class "btn danger", onClick (ConfirmForget wid) ] [ text "Forget" ] + , button [ class "btn ghost", onClick CancelForget ] [ text "Cancel" ] + ] + ] + ] + ] + + +viewToast : Model -> Html Msg +viewToast model = + case model.toast of + Just t -> + div [ class "toast on" ] [ text t ] + + Nothing -> + div [ class "toast" ] [] + + + +-- view helpers + + +kv : String -> String -> Html Msg +kv k v = + div [ class "kv" ] [ span [ class "k" ] [ text k ], span [ class "v" ] [ text v ] ] + + +kvSecret : String -> String -> Html Msg +kvSecret k v = + div [ class "kv" ] [ span [ class "k" ] [ text k ], span [ class "v mono secret" ] [ text v ] ] + + +stopClick : Attribute Msg +stopClick = + stopPropagationOn "click" (D.succeed ( NoOp, True )) diff --git a/cardano-wasm/demo/src/Wasm.elm b/cardano-wasm/demo/src/Wasm.elm new file mode 100644 index 0000000000..780c2dfcf7 --- /dev/null +++ b/cardano-wasm/demo/src/Wasm.elm @@ -0,0 +1,106 @@ +module Wasm exposing + ( addrsDecoder + , decodeResult + , deriveAddresses + , genDecoder + , generateWallet + , restoreWallet + ) + +{-| The cardano-wasm boundary. Commands encode a request and send it out a port; +results come back on the matching incoming port and are decoded here (see the +subscriptions in Main). The Cardano processing itself (key handling, address +encoding) happens in web/ports.js through the cardano-wasm wrapper. +-} + +import Json.Decode as D +import Json.Encode as E +import Net exposing (netTag) +import Ports +import Types exposing (..) + + + +-- COMMANDS (out → cardano-wasm) + + +{-| Generate a fresh stake-enabled wallet on the given network. +-} +generateWallet : Network -> Cmd msg +generateWallet net = + Ports.wasmGenerateWallet (E.object [ ( "network", E.string (netTag net) ) ]) + + +{-| Restore a wallet from its two bech32 signing keys (the backup artifact). +-} +restoreWallet : Network -> RestoreForm -> Cmd msg +restoreWallet net form = + Ports.wasmRestoreWallet + (E.object + [ ( "network", E.string (netTag net) ) + , ( "paymentSkey", E.string form.paymentSkey ) + , ( "stakeSkey", E.string form.stakeSkey ) + ] + ) + + +{-| Re-encode every wallet's address for a new network (keys are network-agnostic, +the bech32 address is not). +-} +deriveAddresses : Network -> List Wallet -> Cmd msg +deriveAddresses net wallets = + Ports.wasmDeriveAddresses + (E.object + [ ( "network", E.string (netTag net) ) + , ( "wallets" + , E.list + (\w -> + E.object + [ ( "id", E.int w.id ) + , ( "paymentSkey", E.string w.keys.paymentSKey ) + , ( "stakeSkey", E.string w.keys.stakeSKey ) + ] + ) + wallets + ) + ] + ) + + + +-- DECODERS (in ← cardano-wasm) + + +{-| Every incoming port payload is either the expected object or { error }. +-} +decodeResult : D.Decoder a -> D.Value -> Result String a +decodeResult dec v = + case D.decodeValue (D.field "error" D.string) v of + Ok e -> + Err e + + Err _ -> + D.decodeValue dec v |> Result.mapError D.errorToString + + +genDecoder : D.Decoder GenPayload +genDecoder = + D.map2 GenPayload + (D.field "address" D.string) + (D.field "keys" keysDecoder) + + +keysDecoder : D.Decoder Keys +keysDecoder = + D.map6 Keys + (D.field "paymentVKey" D.string) + (D.field "paymentSKey" D.string) + (D.field "stakeVKey" D.string) + (D.field "stakeSKey" D.string) + (D.field "paymentKeyHash" D.string) + (D.field "stakeKeyHash" D.string) + + +addrsDecoder : D.Decoder (List ( WalletId, String )) +addrsDecoder = + D.list (D.map2 Tuple.pair (D.field "id" D.int) (D.field "address" D.string)) diff --git a/cardano-wasm/demo/web/index.html b/cardano-wasm/demo/web/index.html index d61bd3cb41..0fb754b762 100644 --- a/cardano-wasm/demo/web/index.html +++ b/cardano-wasm/demo/web/index.html @@ -117,7 +117,7 @@ .toast{position:fixed;bottom:18px;right:18px;background:#1b2440;border:1px solid var(--line);color:#fff;padding:10px 15px;border-radius:10px;font-size:13px;transform:translateY(80px);opacity:0;transition:.3s;z-index:60} .toast.on{transform:none;opacity:1} - .faucet{text-decoration:none} + .faucet{display:block;text-decoration:none;text-align:center} diff --git a/cardano-wasm/demo/web/ports.js b/cardano-wasm/demo/web/ports.js index 6cdb2a1c4a..645b2f0f55 100644 --- a/cardano-wasm/demo/web/ports.js +++ b/cardano-wasm/demo/web/ports.js @@ -1,11 +1,64 @@ -// Boot the cardano-wasm engine, then start the Elm application. -// The Elm app is only started once `initialise()` has succeeded, so the page -// rendering at all is proof that the wasm engine loaded. +// Port glue: Elm ⇄ cardano-wasm. The wrapper does the Cardano work here — +// key generation and restoration, address encoding. import initialise from "./cardano-api.js"; +const magic = { mainnet: undefined, preprod: 1, preview: 2 }; + +async function walletToJson(w) { + return { + address: await w.getAddressBech32(), + keys: { + paymentVKey: await w.getBech32ForPaymentVerificationKey(), + paymentSKey: await w.getBech32ForPaymentSigningKey(), + stakeVKey: await w.getBech32ForStakeVerificationKey(), + stakeSKey: await w.getBech32ForStakeSigningKey(), + paymentKeyHash: await w.getBase16ForPaymentVerificationKeyHash(), + stakeKeyHash: await w.getBase16ForStakeVerificationKeyHash(), + }, + }; +} + +async function restoreWallet(api, network, paymentSkey, stakeSkey) { + return network === "mainnet" + ? api.wallet.mainnet.restoreStakeWalletFromSigningKeyBech32(paymentSkey, stakeSkey) + : api.wallet.testnet.restoreStakeWalletFromSigningKeyBech32(magic[network], paymentSkey, stakeSkey); +} + +function wire(app, api) { + app.ports.wasmGenerateWallet.subscribe(async ({ network }) => { + try { + const w = network === "mainnet" + ? await api.wallet.mainnet.generateStakeWallet() + : await api.wallet.testnet.generateStakeWallet(magic[network]); + app.ports.wasmWalletGenerated.send(await walletToJson(w)); + } catch (e) { app.ports.wasmWalletGenerated.send({ error: String(e) }); } + }); + + app.ports.wasmRestoreWallet.subscribe(async ({ network, paymentSkey, stakeSkey }) => { + try { + const w = await restoreWallet(api, network, paymentSkey, stakeSkey); + app.ports.wasmWalletRestored.send(await walletToJson(w)); + } catch (e) { app.ports.wasmWalletRestored.send({ error: String(e) }); } + }); + + app.ports.wasmDeriveAddresses.subscribe(async ({ network, wallets }) => { + try { + const out = []; + for (const wk of wallets) { + const w = await restoreWallet(api, network, wk.paymentSkey, wk.stakeSkey); + out.push({ id: wk.id, address: await w.getAddressBech32() }); + } + app.ports.wasmAddressesDerived.send(out); + } catch (e) { app.ports.wasmAddressesDerived.send({ error: String(e) }); } + }); + + app.ports.clipboardWrite.subscribe((t) => { if (navigator.clipboard) navigator.clipboard.writeText(t).catch(() => {}); }); +} + async function boot() { - await initialise(); - window.Elm.Main.init({ node: document.getElementById("app") }); + const api = await initialise(); + const app = window.Elm.Main.init({ node: document.getElementById("app") }); + wire(app, api); } boot().catch((e) => {