diff --git a/README.md b/README.md index 61f32d6a..234778fe 100644 --- a/README.md +++ b/README.md @@ -478,6 +478,7 @@ COMMON OPTIONS (all commands): OPTIONS (validator-join): --amount Amount to stake (in wei or with 'gen' suffix) --operator
Operator address (defaults to signer) + --wallet 'keystore' (default) or 'browser' (sign in MetaMask) OPTIONS (delegator-join): --validator
Validator address to delegate to @@ -497,6 +498,14 @@ EXAMPLES: # Join as validator with 42000 GEN genlayer staking validator-join --amount 42000gen + # Sign the validator-join with a browser wallet (MetaMask) instead of a keystore. + # The CLI serves a page on 127.0.0.1 and opens it; connect + confirm in the wallet. + # Remote/SSH: forward the printed port first (ssh -L :127.0.0.1: ...). + genlayer staking validator-join --amount 42000gen --wallet browser --network testnet-bradbury + + # The wizard can also use a browser wallet as the owner (operator keystore is still generated locally): + genlayer staking wizard --wallet browser + # Join as delegator with 42 GEN genlayer staking delegator-join --validator 0x... --amount 42gen diff --git a/docs/api-references/_meta.json b/docs/api-references/_meta.json index bf2c81cb..3bffb8a8 100644 --- a/docs/api-references/_meta.json +++ b/docs/api-references/_meta.json @@ -10,5 +10,6 @@ "estimate-fees": "estimate-fees", "finalize": "finalize", "finalize-batch": "finalize-batch", - "vesting": "vesting" + "vesting": "vesting", + "wallet": "wallet" } \ No newline at end of file diff --git a/docs/api-references/contracts/deploy.mdx b/docs/api-references/contracts/deploy.mdx index ca96ac0b..1fc24809 100644 --- a/docs/api-references/contracts/deploy.mdx +++ b/docs/api-references/contracts/deploy.mdx @@ -21,4 +21,5 @@ Deploy intelligent contracts | | --fee-value <wei> | Fee deposit value to send with the transaction | No | | | | --valid-until <unixTimestamp> | Unix timestamp after which the transaction is invalid | No | | | | --args <args...> | Contract arguments. Supported types: | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/contracts/write.mdx b/docs/api-references/contracts/write.mdx index b4760e37..a6542d4a 100644 --- a/docs/api-references/contracts/write.mdx +++ b/docs/api-references/contracts/write.mdx @@ -25,4 +25,5 @@ Sends a transaction to a contract method that modifies the state | | --fee-value <wei> | Fee deposit value to send with the transaction | No | | | | --valid-until <unixTimestamp> | Unix timestamp after which the transaction is invalid | No | | | | --args <args...> | Contract arguments. Supported types: | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/finalize-batch.mdx b/docs/api-references/finalize-batch.mdx index afc1f6f9..37fa3895 100644 --- a/docs/api-references/finalize-batch.mdx +++ b/docs/api-references/finalize-batch.mdx @@ -17,4 +17,5 @@ Finalize a batch of idle transactions in a single call (public call) | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | | | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/finalize.mdx b/docs/api-references/finalize.mdx index b977d82d..74ad96ea 100644 --- a/docs/api-references/finalize.mdx +++ b/docs/api-references/finalize.mdx @@ -17,4 +17,5 @@ Finalize a transaction that is ready to be finalized (public call) | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | | | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/index.mdx b/docs/api-references/index.mdx index dc97f6cb..47162dbd 100644 --- a/docs/api-references/index.mdx +++ b/docs/api-references/index.mdx @@ -33,6 +33,7 @@ Version: `0.39.1` - `genlayer finalize-batch` — Finalize a batch of idle transactions in a single call (public call) - `genlayer staking` — Staking operations for validators and delegators - `genlayer vesting` — Vesting operations for beneficiaries +- `genlayer wallet` — Manage the persistent browser-wallet (MetaMask) signing session --- diff --git a/docs/api-references/staking/staking/delegator-claim.mdx b/docs/api-references/staking/staking/delegator-claim.mdx index 25c71120..9f8ea959 100644 --- a/docs/api-references/staking/staking/delegator-claim.mdx +++ b/docs/api-references/staking/staking/delegator-claim.mdx @@ -23,4 +23,5 @@ Claim delegator withdrawals after unbonding period | | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/delegator-exit.mdx b/docs/api-references/staking/staking/delegator-exit.mdx index b4f8864d..31e233ec 100644 --- a/docs/api-references/staking/staking/delegator-exit.mdx +++ b/docs/api-references/staking/staking/delegator-exit.mdx @@ -23,4 +23,5 @@ Exit as a delegator by withdrawing shares from a validator | | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/delegator-join.mdx b/docs/api-references/staking/staking/delegator-join.mdx index d213113a..2efe155c 100644 --- a/docs/api-references/staking/staking/delegator-join.mdx +++ b/docs/api-references/staking/staking/delegator-join.mdx @@ -23,4 +23,5 @@ Join as a delegator by staking with a validator | | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/prime-all.mdx b/docs/api-references/staking/staking/prime-all.mdx index be8d1a7c..96a929ef 100644 --- a/docs/api-references/staking/staking/prime-all.mdx +++ b/docs/api-references/staking/staking/prime-all.mdx @@ -17,4 +17,5 @@ Prime all validators that need priming | | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/set-identity.mdx b/docs/api-references/staking/staking/set-identity.mdx index 85046475..66fea2cd 100644 --- a/docs/api-references/staking/staking/set-identity.mdx +++ b/docs/api-references/staking/staking/set-identity.mdx @@ -30,4 +30,5 @@ Set validator identity metadata (moniker, website, socials, etc.) | | --password <password> | Password to unlock account (skips interactive prompt) | No | | | | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/set-operator.mdx b/docs/api-references/staking/staking/set-operator.mdx index 0d08a104..bde668f7 100644 --- a/docs/api-references/staking/staking/set-operator.mdx +++ b/docs/api-references/staking/staking/set-operator.mdx @@ -23,4 +23,5 @@ Change the operator address for a validator wallet | | --password <password> | Password to unlock account (skips interactive prompt) | No | | | | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/validator-claim.mdx b/docs/api-references/staking/staking/validator-claim.mdx index 3085a3af..64ca0a66 100644 --- a/docs/api-references/staking/staking/validator-claim.mdx +++ b/docs/api-references/staking/staking/validator-claim.mdx @@ -21,4 +21,5 @@ Claim validator withdrawals after unbonding period | | --password <password> | Password to unlock account (skips interactive prompt) | No | | | | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/validator-deposit.mdx b/docs/api-references/staking/staking/validator-deposit.mdx index daf32fa0..029c7071 100644 --- a/docs/api-references/staking/staking/validator-deposit.mdx +++ b/docs/api-references/staking/staking/validator-deposit.mdx @@ -22,4 +22,5 @@ Make an additional deposit to a validator wallet | | --password <password> | Password to unlock account (skips interactive prompt) | No | | | | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/validator-exit.mdx b/docs/api-references/staking/staking/validator-exit.mdx index c5489f03..383aa2f3 100644 --- a/docs/api-references/staking/staking/validator-exit.mdx +++ b/docs/api-references/staking/staking/validator-exit.mdx @@ -22,4 +22,5 @@ Exit as a validator by withdrawing shares | | --password <password> | Password to unlock account (skips interactive prompt) | No | | | | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/validator-join.mdx b/docs/api-references/staking/staking/validator-join.mdx index e2d56331..09a65a35 100644 --- a/docs/api-references/staking/staking/validator-join.mdx +++ b/docs/api-references/staking/staking/validator-join.mdx @@ -19,4 +19,5 @@ Join as a validator by staking tokens | | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/validator-prime.mdx b/docs/api-references/staking/staking/validator-prime.mdx index 769d53f4..0ced6924 100644 --- a/docs/api-references/staking/staking/validator-prime.mdx +++ b/docs/api-references/staking/staking/validator-prime.mdx @@ -22,4 +22,5 @@ Prime a validator to prepare their stake record for the next epoch | | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/wizard.mdx b/docs/api-references/staking/staking/wizard.mdx index 60733ceb..1923cc78 100644 --- a/docs/api-references/staking/staking/wizard.mdx +++ b/docs/api-references/staking/staking/wizard.mdx @@ -17,4 +17,5 @@ Interactive wizard to become a validator | | --skip-identity | Skip identity setup step | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/transactions/appeal.mdx b/docs/api-references/transactions/appeal.mdx index 606c70c1..271a6a93 100644 --- a/docs/api-references/transactions/appeal.mdx +++ b/docs/api-references/transactions/appeal.mdx @@ -18,4 +18,5 @@ Appeal a transaction by its hash | --- | --- | --- | :---: | --- | | | --bond <amount> | Appeal bond amount (e.g. 500gen, 0.5gen). Auto-calculated if omitted | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/claim.mdx b/docs/api-references/vesting/claim.mdx index 43d64d47..0d068d80 100644 --- a/docs/api-references/vesting/claim.mdx +++ b/docs/api-references/vesting/claim.mdx @@ -24,4 +24,5 @@ Claim vesting delegation withdrawals after unbonding period | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | | | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/delegate.mdx b/docs/api-references/vesting/delegate.mdx index c0523790..3b984e6f 100644 --- a/docs/api-references/vesting/delegate.mdx +++ b/docs/api-references/vesting/delegate.mdx @@ -25,4 +25,5 @@ Delegate vesting-held tokens to a validator | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | | | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/undelegate.mdx b/docs/api-references/vesting/undelegate.mdx index 3ab6c783..c1c602c7 100644 --- a/docs/api-references/vesting/undelegate.mdx +++ b/docs/api-references/vesting/undelegate.mdx @@ -24,4 +24,5 @@ Undelegate all vesting delegation shares from a validator | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | | | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/claim.mdx b/docs/api-references/vesting/validator/claim.mdx index cf112d09..8b359a80 100644 --- a/docs/api-references/vesting/validator/claim.mdx +++ b/docs/api-references/vesting/validator/claim.mdx @@ -16,7 +16,7 @@ Claim vesting validator withdrawals after unbonding period | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | -| | --wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | +| | --validator-wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | | | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | | | --account <name> | Account to use | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | @@ -24,4 +24,5 @@ Claim vesting validator withdrawals after unbonding period | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | | | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/create.mdx b/docs/api-references/vesting/validator/create.mdx index f159851b..f6b28c43 100644 --- a/docs/api-references/vesting/validator/create.mdx +++ b/docs/api-references/vesting/validator/create.mdx @@ -25,4 +25,5 @@ Create a vesting-backed validator | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | | | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/deposit.mdx b/docs/api-references/vesting/validator/deposit.mdx index 13ead169..fcf8099b 100644 --- a/docs/api-references/vesting/validator/deposit.mdx +++ b/docs/api-references/vesting/validator/deposit.mdx @@ -17,7 +17,7 @@ Deposit more vesting-held tokens to a validator wallet | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | | | --amount <amount> | Amount to deposit (in wei or with 'eth'/'gen' suffix) | No | | -| | --wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | +| | --validator-wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | | | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | | | --account <name> | Account to use | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | @@ -25,4 +25,5 @@ Deposit more vesting-held tokens to a validator wallet | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | | | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/exit.mdx b/docs/api-references/vesting/validator/exit.mdx index 02a3541c..a44f12cf 100644 --- a/docs/api-references/vesting/validator/exit.mdx +++ b/docs/api-references/vesting/validator/exit.mdx @@ -17,7 +17,7 @@ Exit vesting validator self-stake by withdrawing shares | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | | | --shares <shares> | Number of shares to withdraw | No | | -| | --wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | +| | --validator-wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | | | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | | | --account <name> | Account to use | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | @@ -25,4 +25,5 @@ Exit vesting validator self-stake by withdrawing shares | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | | | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/join.mdx b/docs/api-references/vesting/validator/join.mdx index e2bd862b..35a4a9f1 100644 --- a/docs/api-references/vesting/validator/join.mdx +++ b/docs/api-references/vesting/validator/join.mdx @@ -25,4 +25,5 @@ Create a vesting-backed validator | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | | | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/operator-transfer/cancel.mdx b/docs/api-references/vesting/validator/operator-transfer/cancel.mdx index 13f88d5f..504afe5d 100644 --- a/docs/api-references/vesting/validator/operator-transfer/cancel.mdx +++ b/docs/api-references/vesting/validator/operator-transfer/cancel.mdx @@ -16,7 +16,7 @@ Cancel a vesting validator operator transfer | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | -| | --wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | +| | --validator-wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | | | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | | | --account <name> | Account to use | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | @@ -24,4 +24,5 @@ Cancel a vesting validator operator transfer | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | | | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/operator-transfer/complete.mdx b/docs/api-references/vesting/validator/operator-transfer/complete.mdx index 47dda0c7..20af8759 100644 --- a/docs/api-references/vesting/validator/operator-transfer/complete.mdx +++ b/docs/api-references/vesting/validator/operator-transfer/complete.mdx @@ -16,7 +16,7 @@ Complete a vesting validator operator transfer | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | -| | --wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | +| | --validator-wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | | | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | | | --account <name> | Account to use | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | @@ -24,4 +24,5 @@ Complete a vesting validator operator transfer | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | | | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/operator-transfer/initiate.mdx b/docs/api-references/vesting/validator/operator-transfer/initiate.mdx index e3596106..473d51fc 100644 --- a/docs/api-references/vesting/validator/operator-transfer/initiate.mdx +++ b/docs/api-references/vesting/validator/operator-transfer/initiate.mdx @@ -18,7 +18,7 @@ Initiate a vesting validator operator transfer | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | | | --new-operator <address> | New operator address (deprecated, use positional arg) | No | | -| | --wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | +| | --validator-wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | | | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | | | --account <name> | Account to use | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | @@ -26,4 +26,5 @@ Initiate a vesting validator operator transfer | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | | | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/set-identity.mdx b/docs/api-references/vesting/validator/set-identity.mdx index 463f37b8..2379836d 100644 --- a/docs/api-references/vesting/validator/set-identity.mdx +++ b/docs/api-references/vesting/validator/set-identity.mdx @@ -25,7 +25,7 @@ Set vesting validator identity metadata | | --telegram <handle> | Telegram handle | No | | | | --github <handle> | GitHub handle | No | | | | --extra-cid <cid> | Extra data as IPFS CID or hex bytes (0x...) | No | | -| | --wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | +| | --validator-wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | | | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | | | --account <name> | Account to use | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | @@ -33,4 +33,5 @@ Set vesting validator identity metadata | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | | | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/withdraw.mdx b/docs/api-references/vesting/withdraw.mdx index 294c87a0..a065322e 100644 --- a/docs/api-references/vesting/withdraw.mdx +++ b/docs/api-references/vesting/withdraw.mdx @@ -20,4 +20,5 @@ Withdraw vested tokens to the beneficiary | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | | | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/wallet.mdx b/docs/api-references/wallet.mdx new file mode 100644 index 00000000..6a6eeb4d --- /dev/null +++ b/docs/api-references/wallet.mdx @@ -0,0 +1,25 @@ +--- +title: wallet +--- + +Manage the persistent browser-wallet (MetaMask) signing session + +### Usage + +`$ genlayer wallet [options] [command]` + +### Arguments + +- `[command]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| -h | --help | display help for command | No | | + +### Subcommands + +- `genlayer connect` — Start a persistent browser-wallet session (connect once, +- `genlayer status` — Show the current browser-wallet session (address, network, +- `genlayer disconnect` — End the active browser-wallet session diff --git a/docs/api-references/wallet/connect.mdx b/docs/api-references/wallet/connect.mdx new file mode 100644 index 00000000..a5da010f --- /dev/null +++ b/docs/api-references/wallet/connect.mdx @@ -0,0 +1,17 @@ +--- +title: wallet connect +--- + +Start a persistent browser-wallet session (connect once, reuse across commands) + +### Usage + +`$ genlayer wallet connect [options]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --network <network> | Network alias to connect on (defaults to config network) | No | | +| | --rpc <rpc> | Override the RPC URL | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/wallet/disconnect.mdx b/docs/api-references/wallet/disconnect.mdx new file mode 100644 index 00000000..8c2554ec --- /dev/null +++ b/docs/api-references/wallet/disconnect.mdx @@ -0,0 +1,15 @@ +--- +title: wallet disconnect +--- + +End the active browser-wallet session + +### Usage + +`$ genlayer wallet disconnect [options]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/wallet/status.mdx b/docs/api-references/wallet/status.mdx new file mode 100644 index 00000000..00bdea34 --- /dev/null +++ b/docs/api-references/wallet/status.mdx @@ -0,0 +1,15 @@ +--- +title: wallet status +--- + +Show the current browser-wallet session (address, network, heartbeat, queue) + +### Usage + +`$ genlayer wallet status [options]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| -h | --help | display help for command | No | | diff --git a/src/commands/account/index.ts b/src/commands/account/index.ts index 88580d46..219471ac 100644 --- a/src/commands/account/index.ts +++ b/src/commands/account/index.ts @@ -32,6 +32,7 @@ export function initializeAccountCommands(program: Command) { .command("show") .description("Show account details (address, balance)") .option("--rpc ", "RPC URL for the network") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--account ", "Account to show") .action(async (options: ShowAccountOptions) => { const showAction = new ShowAccountAction(); diff --git a/src/commands/account/show.ts b/src/commands/account/show.ts index ee74506f..2c539f6b 100644 --- a/src/commands/account/show.ts +++ b/src/commands/account/show.ts @@ -6,6 +6,7 @@ import {readFileSync, existsSync} from "fs"; export interface ShowAccountOptions { rpc?: string; + network?: string; account?: string; } @@ -14,7 +15,11 @@ export class ShowAccountAction extends BaseAction { super(); } - private getNetwork(): GenLayerChain { + private getNetwork(networkOption?: string): GenLayerChain { + // Priority: --network option > global config network > localnet default. + if (networkOption) { + return resolveNetwork(networkOption, this.getCustomNetworks()); + } return resolveNetwork(this.getConfig().network, this.getCustomNetworks()); } @@ -30,7 +35,9 @@ export class ShowAccountAction extends BaseAction { const keystorePath = this.getKeystorePath(accountName); if (!existsSync(keystorePath)) { - this.failSpinner(`Account '${accountName}' not found. Run 'genlayer account create --name ${accountName}' first.`); + this.failSpinner( + `Account '${accountName}' not found. Run 'genlayer account create --name ${accountName}' first.`, + ); return; } @@ -43,7 +50,11 @@ export class ShowAccountAction extends BaseAction { const rawAddr = keystoreData.address; const address = (rawAddr.startsWith("0x") ? rawAddr : `0x${rawAddr}`) as Address; - const network = this.getNetwork(); + const network = this.getNetwork(options?.network); + // Label with the ACTIVE network alias (what the user set via `network set` + // or --network), not chain.name: a custom network inherits its base + // chain's name ("Genlayer Localnet"), which would mislabel the account. + const networkAlias = options?.network || this.getConfig().network || "localnet"; const client = createClient({ chain: network, @@ -61,7 +72,8 @@ export class ShowAccountAction extends BaseAction { name: accountName, address, balance: `${formattedBalance} GEN`, - network: network.name || "localnet", + network: networkAlias, + chainId: network.id, status: isUnlocked ? "unlocked" : "locked", active: isActive, }; diff --git a/src/commands/balances/BalancesAction.ts b/src/commands/balances/BalancesAction.ts new file mode 100644 index 00000000..8777a222 --- /dev/null +++ b/src/commands/balances/BalancesAction.ts @@ -0,0 +1,276 @@ +import {VestingAction, VestingConfig} from "../vesting/VestingAction"; +import {resolveNetwork} from "../../lib/actions/BaseAction"; +import type {Address} from "genlayer-js/types"; +import type {VestingClient} from "../vesting/vestingTypes"; +import {vestingAvailableToStake} from "../../lib/vesting/availableToStake"; +import {readDescriptor, descriptorPath, isPidAlive} from "../../lib/wallet/sessionDescriptor"; +import {WalletSessionClient} from "../../lib/wallet/sessionClient"; +import {formatEther} from "viem"; +import Table from "cli-table3"; +import chalk from "chalk"; + +export interface BalancesOptions extends VestingConfig { + beneficiary?: string; +} + +/** Per-vesting-contract holdings, all amounts kept as raw wei bigints. */ +interface VestingBalanceSummary { + vesting: Address; + name: string; + totalRaw: bigint; + vestedRaw: bigint; + /** Unvested (still locked by schedule). */ + lockedRaw: bigint; + withdrawableRaw: bigint; + totalWithdrawnRaw: bigint; + /** Revoked contracts can no longer stake, so their available-to-stake is 0. */ + revoked: boolean; + /** Self-stake committed principal (cost basis, summed over its validator wallets). */ + selfStakeRaw: bigint; + /** Delegated committed principal (cost basis, summed over the validator set). */ + delegatedRaw: bigint; + /** Committed principal (self-stake + delegated); informational only. */ + committedRaw: bigint; + /** AUTHORITATIVE on-chain figure: the contract's live native balance (0 if revoked). */ + availableToStakeRaw: bigint; +} + +interface BalancesSummary { + network: string; + chainId: number; + address: Address; + walletBalanceRaw: bigint; + vestings: VestingBalanceSummary[]; +} + +/** + * Read-only "what do I hold" view: wallet GEN + per-vesting totals, committed + * principal, and the authoritative available-to-stake (the contract's live + * on-chain balance). Never signs, never writes, and never unlocks the keystore + * — a read only needs the address. + */ +export class BalancesAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: BalancesOptions): Promise { + this.startSpinner("Fetching balances..."); + + try { + const client = await this.getReadOnlyVestingClient(options); + const address = await this.resolveAddress(options); + + // Label with the ACTIVE network alias + real chainId, mirroring `account + // show`. chain.name is the BASE chain's name for custom networks, so it + // would mislabel — use the alias the user set and network.id instead. + const networkKey = options.network || this.getConfig().network; + const networkAlias = networkKey || "localnet"; + const chain = resolveNetwork(networkKey, this.getCustomNetworks()); + + this.setSpinnerText(`Fetching wallet balance for ${address}...`); + const walletBalanceRaw = await client.getBalance({address}); + + this.setSpinnerText(`Looking up vesting contracts for ${address}...`); + const vestingAddresses = await client.getBeneficiaryVestings( + address, + this.getFactoryLookupOptions(options), + ); + + const vestings: VestingBalanceSummary[] = []; + if (vestingAddresses.length > 0) { + // The active validator set is global; fetch it once and reuse across + // every vesting. Committed-delegation lookup is O(#vestings × #validators). + this.setSpinnerText("Enumerating validator set..."); + const activeValidators = await client.getActiveValidators(); + + for (let i = 0; i < vestingAddresses.length; i++) { + this.setSpinnerText( + `Computing balances for vesting ${i + 1}/${vestingAddresses.length} ` + + `(scanning ${activeValidators.length} validator${activeValidators.length === 1 ? "" : "s"})...`, + ); + vestings.push(await this.computeVestingSummary(client, vestingAddresses[i], activeValidators)); + } + } + + this.stopSpinner(); + + this.renderSummary({ + network: networkAlias, + chainId: chain.id, + address, + walletBalanceRaw, + vestings, + }); + } catch (error: any) { + this.failSpinner("Failed to fetch balances", error.message || error); + } + } + + /** + * Resolve the address to inspect without ever unlocking a keystore. Precedence + * mirrors resolveWalletMode so "who am I" follows the same connect-once rule as + * "how do I sign": + * 1. --beneficiary — explicit address override (pure read, no wallet). + * 2. --account — explicit keystore selection wins over a session. + * 3. a live browser-wallet session's connected address — when a session is up + * (resolveWalletMode → "browser") that IS your active identity. + * 4. the active account's keystore address (file read only, no password). + * 5. last resort: a live session even if the mode wasn't "browser". + */ + private async resolveAddress(options: BalancesOptions): Promise
{ + if (options.beneficiary) { + return options.beneficiary as Address; + } + if (options.account) { + return await this.getSignerAddress(); + } + + if (this.resolveWalletMode() === "browser") { + const sessionAddress = await this.liveSessionAddress(); + if (sessionAddress) { + return sessionAddress; + } + } + + try { + return await this.getSignerAddress(); + } catch (error) { + const sessionAddress = await this.liveSessionAddress(); + if (sessionAddress) { + return sessionAddress; + } + throw new Error( + "No address to inspect. Pass --beneficiary
, select an account, or connect a wallet.", + ); + } + } + + /** + * The connected address of a live browser-wallet session, or null. Read-only: + * pings an already-running daemon and reads its live state (as `wallet status` + * does) — never starts a daemon or opens a tab. The descriptor's own `address` + * field is null until connect and not reliably rewritten, so we query state. + */ + private async liveSessionAddress(): Promise
{ + try { + const descriptor = readDescriptor(descriptorPath(this)); + if (!descriptor) { + return null; + } + const client = new WalletSessionClient(descriptor); + if (!(isPidAlive(descriptor.pid) && (await client.ping()))) { + return null; + } + const state = await client.state().catch(() => null); + return state?.connected && state.address ? (state.address as Address) : null; + } catch { + return null; + } + } + + private async computeVestingSummary( + client: VestingClient, + vesting: Address, + activeValidators: Address[], + ): Promise { + const state = await client.getVestingState(vesting); + + // Self-stake committed principal: sum the cost-basis deposits across this + // vesting's own validator wallets (see vesting validatorList). + const wallets = await client.getValidatorWallets(vesting); + let selfStakeRaw = 0n; + for (const wallet of wallets) { + const deposited = await client.validatorDeposited(vesting, wallet); + selfStakeRaw += typeof deposited === "bigint" ? deposited : this.parseAmount(String(deposited)); + } + + // Delegated committed principal: sum the cost-basis the vesting deposited + // delegating to each active validator (see execute() for the one-shot set). + // This on-chain principal getter is consistent with the balance identity + // used below (balance = deposits − withdrawals + rewards − losses). + let delegatedRaw = 0n; + for (const validator of activeValidators) { + delegatedRaw += await client.vestingDepositedPerValidator(vesting, validator); + } + + const committedRaw = selfStakeRaw + delegatedRaw; + + // AUTHORITATIVE available-to-stake: the contract's live native balance (0 + // once revoked). Vesting.sol enforces every stake path against + // address(this).balance, so this — not vested/withdrawn/committed math — is + // the real cap; it already nets withdrawals + committed principal and + // includes still-locked tokens. The committed figures above are purely + // informational now. + const availableToStakeRaw = await vestingAvailableToStake(client, vesting, state.revoked); + + return { + vesting, + name: state.name || "", + totalRaw: state.totalAmountRaw, + vestedRaw: state.vestedAmountRaw, + lockedRaw: state.unvestedAmountRaw, + withdrawableRaw: state.withdrawableAmountRaw, + totalWithdrawnRaw: state.totalWithdrawnRaw, + revoked: state.revoked, + selfStakeRaw, + delegatedRaw, + committedRaw, + availableToStakeRaw, + }; + } + + private renderSummary(summary: BalancesSummary): void { + const fmt = (raw: bigint) => `${formatEther(raw)} GEN`; + + console.log(""); + console.log(chalk.bold("GenLayer balances")); + console.log(chalk.gray(`Network: ${summary.network} (chainId ${summary.chainId})`)); + console.log(chalk.gray(`Address: ${summary.address}`)); + console.log(""); + console.log(`${chalk.cyan("Wallet")}: ${fmt(summary.walletBalanceRaw)}`); + console.log(""); + + if (summary.vestings.length === 0) { + console.log(chalk.yellow(`No vesting contracts found for ${summary.address}`)); + console.log(""); + return; + } + + summary.vestings.forEach(v => { + const table = new Table({ + head: [chalk.cyan("Metric"), chalk.cyan("Amount")], + style: {head: [], border: []}, + wordWrap: true, + }); + const availableValue = v.revoked + ? `${fmt(v.availableToStakeRaw)} ${chalk.gray("(revoked — staking disabled)")}` + : fmt(v.availableToStakeRaw); + table.push( + ["Total", fmt(v.totalRaw)], + ["Vested", fmt(v.vestedRaw)], + ["Locked (unvested)", fmt(v.lockedRaw)], + ["Withdrawable", fmt(v.withdrawableRaw)], + ["Total withdrawn", fmt(v.totalWithdrawnRaw)], + ["Committed principal (staked)", fmt(v.committedRaw)], + [chalk.gray(" self-stake"), chalk.gray(fmt(v.selfStakeRaw))], + [chalk.gray(" delegated"), chalk.gray(fmt(v.delegatedRaw))], + [chalk.bold("Available to stake"), chalk.bold(availableValue)], + ); + console.log(chalk.bold(`Vesting ${v.vesting}`) + (v.name ? ` ${chalk.gray(v.name)}` : "")); + console.log(table.toString()); + console.log(""); + }); + + console.log( + chalk.gray("Available to stake is the vesting contract's live on-chain balance (0 once revoked)."), + ); + console.log( + chalk.gray("Committed principal is the staked cost basis (self-stake + delegated), shown for reference."), + ); + console.log( + chalk.gray(`Total: ${summary.vestings.length} vesting contract${summary.vestings.length === 1 ? "" : "s"}`), + ); + console.log(""); + } +} diff --git a/src/commands/balances/index.ts b/src/commands/balances/index.ts new file mode 100644 index 00000000..3577d0d5 --- /dev/null +++ b/src/commands/balances/index.ts @@ -0,0 +1,18 @@ +import {Command} from "commander"; +import {BalancesAction, BalancesOptions} from "./BalancesAction"; + +export function initializeBalancesCommands(program: Command) { + program + .command("balances") + .description("Show wallet + vesting balances and committed stake (read-only)") + .option("--beneficiary
", "Address to inspect (defaults to the active account, no unlock)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") + .option("--rpc ", "RPC URL for the network") + .option("--account ", "Account whose address to use (no unlock)") + .action(async (options: BalancesOptions) => { + const action = new BalancesAction(); + await action.execute(options); + }); + + return program; +} diff --git a/src/commands/contracts/deploy.ts b/src/commands/contracts/deploy.ts index 845831c6..d3eb1bcc 100644 --- a/src/commands/contracts/deploy.ts +++ b/src/commands/contracts/deploy.ts @@ -11,10 +11,12 @@ export interface DeployOptions extends ContractFeeCliOptions { contract?: string; args?: any[]; rpc?: string; + wallet?: "keystore" | "browser"; } export interface DeployScriptsOptions { rpc?: string; + wallet?: "keystore" | "browser"; } export class DeployAction extends BaseAction { @@ -73,6 +75,7 @@ export class DeployAction extends BaseAction { } async deployScripts(options?: DeployScriptsOptions) { + if (this.isBrowserWallet({wallet: options?.wallet})) this.walletModeOverride = "browser"; this.startSpinner("Searching for deploy scripts..."); if (!fs.existsSync(this.deployDir)) { this.failSpinner("No deploy folder found."); @@ -98,24 +101,33 @@ export class DeployAction extends BaseAction { this.setSpinnerText(`Found ${files.length} deploy scripts. Executing...`); - for (const file of files) { - const filePath = path.resolve(this.deployDir, file); - this.setSpinnerText(`Executing script: ${filePath}`); - try { - if (file.endsWith(".ts")) { - await this.executeTsScript(filePath, options?.rpc); - } else { - await this.executeJsScript(filePath, undefined, options?.rpc); + try { + for (const file of files) { + const filePath = path.resolve(this.deployDir, file); + this.setSpinnerText(`Executing script: ${filePath}`); + try { + if (file.endsWith(".ts")) { + await this.executeTsScript(filePath, options?.rpc); + } else { + await this.executeJsScript(filePath, undefined, options?.rpc); + } + } catch (error) { + this.failSpinner(`Error executing script: ${filePath}`, error); } - } catch (error) { - this.failSpinner(`Error executing script: ${filePath}`, error); } + } finally { + // One browser session drives every script in the folder; close it here. + await this.closeBrowserSession(); } } async deploy(options: DeployOptions): Promise { + if (this.isBrowserWallet({wallet: options.wallet})) this.walletModeOverride = "browser"; try { const client = await this.getClient(options.rpc); + this.browserSession?.setNextLabel( + options.contract ? `Deploy ${path.basename(options.contract)}` : "Deploy contract", + ); this.startSpinner("Setting up the deployment environment..."); await client.initializeConsensusSmartContract(); @@ -175,6 +187,8 @@ export class DeployAction extends BaseAction { }); } catch (error) { this.failSpinner("Error deploying contract", error); + } finally { + await this.closeBrowserSession(); } } } diff --git a/src/commands/contracts/index.ts b/src/commands/contracts/index.ts index c18b5df2..f4909eb3 100644 --- a/src/commands/contracts/index.ts +++ b/src/commands/contracts/index.ts @@ -6,6 +6,7 @@ import {WriteAction, WriteOptions} from "./write"; import {SchemaAction, SchemaOptions} from "./schema"; import {CodeAction, CodeOptions} from "./code"; import {EstimateFeesAction, EstimateFeesOptions} from "./estimateFees"; +import {addWalletModeOption} from "../../lib/wallet/walletOption"; const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/; const ADDR_PREFIX_RE = /^addr#([0-9a-fA-F]{40})$/; @@ -107,19 +108,20 @@ const FEE_PROFILE_HELP = [ ].join("\n"); export function initializeContractsCommands(program: Command) { - program - .command("deploy") - .description("Deploy intelligent contracts") - .option("--contract ", "Path to the smart contract to deploy") - .option("--rpc ", "RPC URL for the network") - .option("--fees ", FEES_HELP) - .option("--fee-profile ", FEE_PROFILE_HELP) - .option("--fee-preset ", "Fee profile appeal posture: low, standard, or high") - .option("--appeal-rounds ", "Override fee profile appeal rounds") - .option("--fee-value ", "Fee deposit value to send with the transaction") - .option("--valid-until ", "Unix timestamp after which the transaction is invalid") - .option("--args ", ARGS_HELP, parseArg, []) - .action(async (options: DeployOptions) => { + addWalletModeOption( + program + .command("deploy") + .description("Deploy intelligent contracts") + .option("--contract ", "Path to the smart contract to deploy") + .option("--rpc ", "RPC URL for the network") + .option("--fees ", FEES_HELP) + .option("--fee-profile ", FEE_PROFILE_HELP) + .option("--fee-preset ", "Fee profile appeal posture: low, standard, or high") + .option("--appeal-rounds ", "Override fee profile appeal rounds") + .option("--fee-value ", "Fee deposit value to send with the transaction") + .option("--valid-until ", "Unix timestamp after which the transaction is invalid") + .option("--args ", ARGS_HELP, parseArg, []), + ).action(async (options: DeployOptions) => { const deployer = new DeployAction(); if (options.contract) { await deployer.deploy(options); @@ -139,18 +141,19 @@ export function initializeContractsCommands(program: Command) { await callAction.call({contractAddress, method, ...options}); }); - program - .command("write ") - .description("Sends a transaction to a contract method that modifies the state") - .option("--rpc ", "RPC URL for the network") - .option("--fees ", FEES_HELP) - .option("--fee-profile ", FEE_PROFILE_HELP) - .option("--fee-preset ", "Fee profile appeal posture: low, standard, or high") - .option("--appeal-rounds ", "Override fee profile appeal rounds") - .option("--fee-value ", "Fee deposit value to send with the transaction") - .option("--valid-until ", "Unix timestamp after which the transaction is invalid") - .option("--args ", ARGS_HELP, parseArg, []) - .action(async (contractAddress: string, method: string, options: WriteOptions) => { + addWalletModeOption( + program + .command("write ") + .description("Sends a transaction to a contract method that modifies the state") + .option("--rpc ", "RPC URL for the network") + .option("--fees ", FEES_HELP) + .option("--fee-profile ", FEE_PROFILE_HELP) + .option("--fee-preset ", "Fee profile appeal posture: low, standard, or high") + .option("--appeal-rounds ", "Override fee profile appeal rounds") + .option("--fee-value ", "Fee deposit value to send with the transaction") + .option("--valid-until ", "Unix timestamp after which the transaction is invalid") + .option("--args ", ARGS_HELP, parseArg, []), + ).action(async (contractAddress: string, method: string, options: WriteOptions) => { const writeAction = new WriteAction(); await writeAction.write({contractAddress, method, ...options}); }); diff --git a/src/commands/contracts/write.ts b/src/commands/contracts/write.ts index 42622b8a..882e3c3b 100644 --- a/src/commands/contracts/write.ts +++ b/src/commands/contracts/write.ts @@ -8,6 +8,7 @@ import {assertSuccessfulExecution, transactionConsensusStatus} from "./execution export interface WriteOptions extends ContractFeeCliOptions { args: any[]; rpc?: string; + wallet?: "keystore" | "browser"; } export class WriteAction extends BaseAction { @@ -20,6 +21,7 @@ export class WriteAction extends BaseAction { method, args, rpc, + wallet, fees, feeProfile, feePreset, @@ -30,8 +32,10 @@ export class WriteAction extends BaseAction { contractAddress: string; method: string; }): Promise { + if (this.isBrowserWallet({wallet})) this.walletModeOverride = "browser"; const client = await this.getClient(rpc); await client.initializeConsensusSmartContract(); + this.browserSession?.setNextLabel(`${method} on ${contractAddress}`); this.startSpinner(`Calling write method ${method} on contract at ${contractAddress}...`); try { @@ -78,6 +82,8 @@ export class WriteAction extends BaseAction { }); } catch (error) { this.failSpinner("Error during write operation", error); + } finally { + await this.closeBrowserSession(); } } } diff --git a/src/commands/staking/StakingAction.ts b/src/commands/staking/StakingAction.ts index ab78e8c9..40644785 100644 --- a/src/commands/staking/StakingAction.ts +++ b/src/commands/staking/StakingAction.ts @@ -3,21 +3,19 @@ import {createClient, createAccount, formatStakingAmount, parseStakingAmount, ab import type {GenLayerClient, GenLayerChain, Address} from "genlayer-js/types"; import {readFileSync, existsSync} from "fs"; import {ethers, ZeroAddress} from "ethers"; -import {createPublicClient, createWalletClient, http, type PublicClient, type WalletClient, type Chain, type Account, type HttpTransportConfig} from "viem"; +import { + createPublicClient, + createWalletClient, + http, + type PublicClient, + type WalletClient, + type Chain, + type Account, + type TransactionReceipt, +} from "viem"; import {privateKeyToAccount} from "viem/accounts"; - -// GenLayer RPC rejects JSON-RPC requests with id=0 (treats 0 as missing). -// Viem starts its id counter at 0, so we ensure non-zero ids. -const glHttpConfig: HttpTransportConfig = { - async fetchFn(url, init) { - if (init?.body) { - const body = JSON.parse(init.body as string); - if (body.id === 0) body.id = 1; - init = {...init, body: JSON.stringify(body)}; - } - return fetch(url, init); - }, -}; +import {glHttpConfig, type BrowserSession} from "../../lib/wallet/browserSend"; +import {resolveBrowserWalletSession} from "../../lib/wallet/sessionResolver"; // Extended ABI for tree traversal (not in SDK) const STAKING_TREE_ABI = [ @@ -39,8 +37,12 @@ export interface StakingConfig { network?: string; account?: string; password?: string; + wallet?: "keystore" | "browser"; } +/** Staking-scoped session: the shared BrowserSession plus a resolved staking address. */ +export type BrowserWalletSession = BrowserSession & {stakingAddress: string}; + export class StakingAction extends BaseAction { private _stakingClient: GenLayerClient | null = null; private _passwordOverride: string | undefined; @@ -58,6 +60,63 @@ export class StakingAction extends BaseAction { return resolveNetwork(this.getConfig().network, this.getCustomNetworks()); } + /** + * Validate flag combinations for browser-wallet mode. Delegates value/password + * checks to the shared BaseAction.assertWalletFlags, then applies the + * staking-specific `--account` wording (wizard: "the browser wallet is the + * owner"). Preserves the #367 call-site/test messages. + */ + protected assertBrowserWalletFlags(config: StakingConfig, context: "validator-join" | "wizard"): void { + // Shared checks: invalid --wallet value + --password conflict. + this.assertWalletFlags(config, {accountFlagExists: false, context}); + if (!this.isBrowserWallet(config)) return; + if (config.account !== undefined) { + if (context === "validator-join") { + throw new Error("--account selects a keystore; not applicable with --wallet browser"); + } + throw new Error("--account cannot be used with --wallet browser (the browser wallet is the owner)"); + } + } + + /** + * Build a staking browser-wallet signing session: resolves the staking + * address, then delegates to the shared bridge session. Never touches + * keystore/keychain/password code paths. + */ + protected async getBrowserWalletSession( + config: StakingConfig, + context: "validator-join" | "wizard", + ): Promise { + this.assertBrowserWalletFlags(config, context); + + const chain = this.getNetwork(config); + const rpcUrl = config.rpc || chain.rpcUrls.default.http[0]; + const stakingAddress = config.stakingAddress || chain.stakingContract?.address; + + if (!stakingAddress) { + throw new Error( + "Staking contract address not configured. Pass --staking-address or use a network with one.", + ); + } + + // The wizard is a self-contained interactive flow: keep its own single-use + // bridge (own-bridge) rather than leaving a surprise daemon behind. + // validator-join reuses / auto-starts the persistent session like other writes. + const session = await resolveBrowserWalletSession({ + chain, + rpcUrl, + networkAlias: config.network ?? this.getConfig().network, + configManager: this, + fallback: context === "wizard" ? "own-bridge" : "auto-start", + log: (msg: string) => this.log(msg), + logInfo: (msg: string) => this.logInfo(msg), + logWarning: (msg: string) => this.logWarning(msg), + }); + this.browserSession = session; + + return {...session, stakingAddress}; + } + protected async getStakingClient(config: StakingConfig): Promise> { if (!this._stakingClient) { // Set account override if provided @@ -133,7 +192,9 @@ export class StakingAction extends BaseAction { const keystorePath = this.getKeystorePath(accountName); if (!existsSync(keystorePath)) { - throw new Error(`Account '${accountName}' not found. Run 'genlayer account create --name ${accountName}' first.`); + throw new Error( + `Account '${accountName}' not found. Run 'genlayer account create --name ${accountName}' first.`, + ); } const keystoreJson = readFileSync(keystorePath, "utf-8"); @@ -148,7 +209,7 @@ export class StakingAction extends BaseAction { // Verify cached key matches keystore address - safety check const tempAccount = createAccount(cachedKey as `0x${string}`); const cachedAddress = tempAccount.address.toLowerCase(); - const keystoreAddress = `0x${keystoreData.address.toLowerCase().replace(/^0x/, '')}`; + const keystoreAddress = `0x${keystoreData.address.toLowerCase().replace(/^0x/, "")}`; if (cachedAddress !== keystoreAddress) { // Cached key doesn't match keystore - invalidate it @@ -273,12 +334,12 @@ export class StakingAction extends BaseAction { validators.push(addr as Address); - const info = await publicClient.readContract({ + const info = (await publicClient.readContract({ address: stakingAddress as `0x${string}`, abi: abi.STAKING_ABI, functionName: "validatorView", args: [addr as `0x${string}`], - }) as {left: string; right: string}; + })) as {left: string; right: string}; if (info.left !== ZeroAddress) { stack.push(info.left); diff --git a/src/commands/staking/delegatorClaim.ts b/src/commands/staking/delegatorClaim.ts index 6f1b2b56..7a78b0f2 100644 --- a/src/commands/staking/delegatorClaim.ts +++ b/src/commands/staking/delegatorClaim.ts @@ -1,5 +1,7 @@ import {StakingAction, StakingConfig} from "./StakingAction"; import type {Address} from "genlayer-js/types"; +import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface DelegatorClaimOptions extends StakingConfig { validator: string; @@ -12,6 +14,10 @@ export class DelegatorClaimAction extends StakingAction { } async execute(options: DelegatorClaimOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Claiming delegator withdrawals..."); try { @@ -38,4 +44,42 @@ export class DelegatorClaimAction extends StakingAction { this.failSpinner("Failed to claim", error.message || error); } } + + private async executeWithBrowserWallet(options: DelegatorClaimOptions): Promise { + let session; + try { + session = await this.getBrowserWalletSession(options, "validator-join"); + } catch (error: any) { + this.failSpinner("Failed to claim", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + try { + const delegatorAddress = options.delegator || session.signerAddress; + const {to, data} = buildTx(abi.STAKING_ABI as any, session.stakingAddress, "delegatorClaim", [ + delegatorAddress as Address, + options.validator as Address, + ]); + + this.log(` From (browser wallet): ${session.signerAddress}`); + const receipt = await session.sendTransaction({ + to, + data, + label: `Claim delegator withdrawals`, + }); + + this.succeedSpinner("Claim successful!", { + transactionHash: receipt.transactionHash, + delegator: delegatorAddress, + validator: options.validator, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to claim", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/staking/delegatorExit.ts b/src/commands/staking/delegatorExit.ts index 66e32996..300a69d1 100644 --- a/src/commands/staking/delegatorExit.ts +++ b/src/commands/staking/delegatorExit.ts @@ -1,5 +1,7 @@ import {StakingAction, StakingConfig} from "./StakingAction"; import type {Address} from "genlayer-js/types"; +import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface DelegatorExitOptions extends StakingConfig { validator: string; @@ -12,6 +14,10 @@ export class DelegatorExitAction extends StakingAction { } async execute(options: DelegatorExitOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Initiating delegator exit..."); try { @@ -53,4 +59,58 @@ export class DelegatorExitAction extends StakingAction { this.failSpinner("Failed to exit", error.message || error); } } + + private async executeWithBrowserWallet(options: DelegatorExitOptions): Promise { + let session; + try { + session = await this.getBrowserWalletSession(options, "validator-join"); + } catch (error: any) { + this.failSpinner("Failed to exit", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + try { + let shares: bigint; + try { + shares = BigInt(options.shares); + if (shares <= 0n) throw new Error("must be positive"); + } catch { + this.failSpinner(`Invalid shares value: "${options.shares}". Must be a positive whole number.`); + return; + } + + const {to, data} = buildTx(abi.STAKING_ABI as any, session.stakingAddress, "delegatorExit", [ + options.validator as Address, + shares, + ]); + + this.log(` From (browser wallet): ${session.signerAddress}`); + const receipt = await session.sendTransaction({ + to, + data, + label: `Exit ${shares} shares from validator`, + }); + + // Check epoch to determine note + const readClient = await this.getReadOnlyStakingClient(options); + const epochInfo = await readClient.getEpochInfo(); + const isEpochZero = epochInfo.currentEpoch === 0n; + + this.succeedSpinner("Exit initiated successfully!", { + transactionHash: receipt.transactionHash, + validator: options.validator, + sharesWithdrawn: shares.toString(), + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + note: isEpochZero + ? "Epoch 0: Withdrawal claimable immediately" + : "Withdrawal will be claimable after the unbonding period", + }); + } catch (error: any) { + this.failSpinner("Failed to exit", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/staking/delegatorJoin.ts b/src/commands/staking/delegatorJoin.ts index c9649bcf..3820b838 100644 --- a/src/commands/staking/delegatorJoin.ts +++ b/src/commands/staking/delegatorJoin.ts @@ -1,6 +1,8 @@ import {StakingAction, StakingConfig} from "./StakingAction"; import type {Address} from "genlayer-js/types"; +import {abi} from "genlayer-js"; import chalk from "chalk"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface DelegatorJoinOptions extends StakingConfig { validator: string; @@ -13,6 +15,10 @@ export class DelegatorJoinAction extends StakingAction { } async execute(options: DelegatorJoinOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Joining as delegator..."); try { @@ -41,4 +47,44 @@ export class DelegatorJoinAction extends StakingAction { this.failSpinner("Failed to join as delegator", error.message || error); } } + + private async executeWithBrowserWallet(options: DelegatorJoinOptions): Promise { + let session; + try { + session = await this.getBrowserWalletSession(options, "validator-join"); + } catch (error: any) { + this.failSpinner("Failed to join as delegator", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + try { + const amount = this.parseAmount(options.amount); + const {to, data} = buildTx(abi.STAKING_ABI as any, session.stakingAddress, "delegatorJoin", [ + options.validator as Address, + ]); + + this.log(` From (browser wallet): ${session.signerAddress}`); + const receipt = await session.sendTransaction({ + to, + data, + value: amount, + label: `Delegate ${this.formatAmount(amount)} to validator`, + }); + + this.succeedSpinner("Successfully joined as delegator!", { + transactionHash: receipt.transactionHash, + validator: options.validator, + amount: this.formatAmount(amount), + delegator: session.signerAddress, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + }); + console.log(chalk.dim(`\nTo view your delegation: genlayer staking delegation-info --validator ${options.validator}`)); + } catch (error: any) { + this.failSpinner("Failed to join as delegator", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/staking/index.ts b/src/commands/staking/index.ts index b5b097eb..699d8ca1 100644 --- a/src/commands/staking/index.ts +++ b/src/commands/staking/index.ts @@ -1,5 +1,6 @@ import {Command} from "commander"; import type {StakingConfig} from "./StakingAction"; +import {addWalletModeOption} from "../../lib/wallet/walletOption"; import {ValidatorJoinAction, ValidatorJoinOptions} from "./validatorJoin"; import {ValidatorDepositAction, ValidatorDepositOptions} from "./validatorDeposit"; import {ValidatorExitAction, ValidatorExitOptions} from "./validatorExit"; @@ -19,45 +20,51 @@ export function initializeStakingCommands(program: Command) { const staking = program.command("staking").description("Staking operations for validators and delegators"); // Wizard command (main entry point for new validators) - staking - .command("wizard") - .description("Interactive wizard to become a validator") - .option("--account ", "Account to use (skip selection)") - .option("--network ", "Network to use (skip selection)") - .option("--skip-identity", "Skip identity setup step") - .option("--rpc ", "RPC URL for the network") - .option("--staking-address
", "Staking contract address (overrides chain config)") - .action(async (options: WizardOptions) => { - const wizard = new ValidatorWizardAction(); - await wizard.execute(options); - }); + addWalletModeOption( + staking + .command("wizard") + .description("Interactive wizard to become a validator") + .option("--account ", "Account to use (skip selection)") + .option("--network ", "Network to use (skip selection)") + .option("--skip-identity", "Skip identity setup step") + .option("--rpc ", "RPC URL for the network") + .option("--staking-address
", "Staking contract address (overrides chain config)"), + ).action(async (options: WizardOptions) => { + const wizard = new ValidatorWizardAction(); + await wizard.execute(options); + }); // Validator commands - staking - .command("validator-join") - .description("Join as a validator by staking tokens") - .requiredOption("--amount ", "Amount to stake (in wei or with 'eth'/'gen' suffix, e.g., '42000gen')") - .option("--operator
", "Operator address (defaults to signer)") - .option("--account ", "Account to use") - .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "built-in or custom network alias (see: genlayer network list)") - .option("--rpc ", "RPC URL for the network") - .option("--staking-address
", "Staking contract address (overrides chain config)") - .action(async (options: ValidatorJoinOptions) => { - const action = new ValidatorJoinAction(); - await action.execute(options); - }); + addWalletModeOption( + staking + .command("validator-join") + .description("Join as a validator by staking tokens") + .requiredOption( + "--amount ", + "Amount to stake (in wei or with 'eth'/'gen' suffix, e.g., '42000gen')", + ) + .option("--operator
", "Operator address (defaults to signer)") + .option("--account ", "Account to use") + .option("--password ", "Password to unlock account (skips interactive prompt)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") + .option("--rpc ", "RPC URL for the network") + .option("--staking-address
", "Staking contract address (overrides chain config)"), + ).action(async (options: ValidatorJoinOptions) => { + const action = new ValidatorJoinAction(); + await action.execute(options); + }); - staking - .command("validator-deposit [validator]") - .description("Make an additional deposit to a validator wallet") - .option("--validator
", "Validator wallet contract address (deprecated, use positional arg)") - .requiredOption("--amount ", "Amount to deposit (in wei or with 'eth'/'gen' suffix)") - .option("--account ", "Account to use (must be validator owner)") - .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "built-in or custom network alias (see: genlayer network list)") - .option("--rpc ", "RPC URL for the network") - .action(async (validatorArg: string | undefined, options: ValidatorDepositOptions) => { + addWalletModeOption( + staking + .command("validator-deposit [validator]") + .description("Make an additional deposit to a validator wallet") + .option("--validator
", "Validator wallet contract address (deprecated, use positional arg)") + .requiredOption("--amount ", "Amount to deposit (in wei or with 'eth'/'gen' suffix)") + .option("--account ", "Account to use (must be validator owner)") + .option("--password ", "Password to unlock account (skips interactive prompt)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") + .option("--rpc ", "RPC URL for the network"), + ).action(async (validatorArg: string | undefined, options: ValidatorDepositOptions) => { const validator = validatorArg || options.validator; if (!validator) { console.error("Error: validator address is required"); @@ -67,16 +74,17 @@ export function initializeStakingCommands(program: Command) { await action.execute({...options, validator}); }); - staking - .command("validator-exit [validator]") - .description("Exit as a validator by withdrawing shares") - .option("--validator
", "Validator wallet contract address (deprecated, use positional arg)") - .requiredOption("--shares ", "Number of shares to withdraw") - .option("--account ", "Account to use (must be validator owner)") - .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "built-in or custom network alias (see: genlayer network list)") - .option("--rpc ", "RPC URL for the network") - .action(async (validatorArg: string | undefined, options: ValidatorExitOptions) => { + addWalletModeOption( + staking + .command("validator-exit [validator]") + .description("Exit as a validator by withdrawing shares") + .option("--validator
", "Validator wallet contract address (deprecated, use positional arg)") + .requiredOption("--shares ", "Number of shares to withdraw") + .option("--account ", "Account to use (must be validator owner)") + .option("--password ", "Password to unlock account (skips interactive prompt)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") + .option("--rpc ", "RPC URL for the network"), + ).action(async (validatorArg: string | undefined, options: ValidatorExitOptions) => { const validator = validatorArg || options.validator; if (!validator) { console.error("Error: validator address is required"); @@ -86,15 +94,16 @@ export function initializeStakingCommands(program: Command) { await action.execute({...options, validator}); }); - staking - .command("validator-claim [validator]") - .description("Claim validator withdrawals after unbonding period") - .option("--validator
", "Validator wallet contract address (deprecated, use positional arg)") - .option("--account ", "Account to use") - .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "built-in or custom network alias (see: genlayer network list)") - .option("--rpc ", "RPC URL for the network") - .action(async (validatorArg: string | undefined, options: ValidatorClaimOptions) => { + addWalletModeOption( + staking + .command("validator-claim [validator]") + .description("Claim validator withdrawals after unbonding period") + .option("--validator
", "Validator wallet contract address (deprecated, use positional arg)") + .option("--account ", "Account to use") + .option("--password ", "Password to unlock account (skips interactive prompt)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") + .option("--rpc ", "RPC URL for the network"), + ).action(async (validatorArg: string | undefined, options: ValidatorClaimOptions) => { const validator = validatorArg || options.validator; if (!validator) { console.error("Error: validator address is required"); @@ -104,16 +113,17 @@ export function initializeStakingCommands(program: Command) { await action.execute({...options, validator}); }); - staking - .command("validator-prime [validator]") - .description("Prime a validator to prepare their stake record for the next epoch") - .option("--validator
", "Validator address to prime (deprecated, use positional arg)") - .option("--account ", "Account to use") - .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "built-in or custom network alias (see: genlayer network list)") - .option("--rpc ", "RPC URL for the network") - .option("--staking-address
", "Staking contract address (overrides chain config)") - .action(async (validatorArg: string | undefined, options: ValidatorPrimeOptions) => { + addWalletModeOption( + staking + .command("validator-prime [validator]") + .description("Prime a validator to prepare their stake record for the next epoch") + .option("--validator
", "Validator address to prime (deprecated, use positional arg)") + .option("--account ", "Account to use") + .option("--password ", "Password to unlock account (skips interactive prompt)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") + .option("--rpc ", "RPC URL for the network") + .option("--staking-address
", "Staking contract address (overrides chain config)"), + ).action(async (validatorArg: string | undefined, options: ValidatorPrimeOptions) => { const validator = validatorArg || options.validator; if (!validator) { console.error("Error: validator address is required"); @@ -123,57 +133,66 @@ export function initializeStakingCommands(program: Command) { await action.execute({...options, validator}); }); - staking - .command("prime-all") - .description("Prime all validators that need priming") - .option("--account ", "Account to use (pays gas)") - .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "built-in or custom network alias (see: genlayer network list)") - .option("--rpc ", "RPC URL for the network") - .option("--staking-address
", "Staking contract address (overrides chain config)") - .action(async (options: StakingConfig) => { + addWalletModeOption( + staking + .command("prime-all") + .description("Prime all validators that need priming") + .option("--account ", "Account to use (pays gas)") + .option("--password ", "Password to unlock account (skips interactive prompt)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") + .option("--rpc ", "RPC URL for the network") + .option("--staking-address
", "Staking contract address (overrides chain config)"), + ).action(async (options: StakingConfig) => { const action = new ValidatorPrimeAction(); await action.primeAll(options); }); - staking - .command("set-operator [validator] [operator]") - .description("Change the operator address for a validator wallet") - .option("--validator
", "Validator wallet address (deprecated, use positional arg)") - .option("--operator
", "New operator address (deprecated, use positional arg)") - .option("--account ", "Account to use (must be validator owner)") - .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "built-in or custom network alias (see: genlayer network list)") - .option("--rpc ", "RPC URL for the network") - .action(async (validatorArg: string | undefined, operatorArg: string | undefined, options: SetOperatorOptions) => { - const validator = validatorArg || options.validator; - const operator = operatorArg || options.operator; - if (!validator || !operator) { - console.error("Error: validator and operator addresses are required"); - process.exit(1); - } - const action = new SetOperatorAction(); - await action.execute({...options, validator, operator}); - }); + addWalletModeOption( + staking + .command("set-operator [validator] [operator]") + .description("Change the operator address for a validator wallet") + .option("--validator
", "Validator wallet address (deprecated, use positional arg)") + .option("--operator
", "New operator address (deprecated, use positional arg)") + .option("--account ", "Account to use (must be validator owner)") + .option("--password ", "Password to unlock account (skips interactive prompt)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") + .option("--rpc ", "RPC URL for the network"), + ).action( + async ( + validatorArg: string | undefined, + operatorArg: string | undefined, + options: SetOperatorOptions, + ) => { + const validator = validatorArg || options.validator; + const operator = operatorArg || options.operator; + if (!validator || !operator) { + console.error("Error: validator and operator addresses are required"); + process.exit(1); + } + const action = new SetOperatorAction(); + await action.execute({...options, validator, operator}); + }, + ); - staking - .command("set-identity [validator]") - .description("Set validator identity metadata (moniker, website, socials, etc.)") - .option("--validator
", "Validator wallet address (deprecated, use positional arg)") - .requiredOption("--moniker ", "Validator display name") - .option("--logo-uri ", "Logo URI") - .option("--website ", "Website URL") - .option("--description ", "Description") - .option("--email ", "Contact email") - .option("--twitter ", "Twitter handle") - .option("--telegram ", "Telegram handle") - .option("--github ", "GitHub handle") - .option("--extra-cid ", "Extra data as IPFS CID or hex bytes (0x...)") - .option("--account ", "Account to use (must be validator operator)") - .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "built-in or custom network alias (see: genlayer network list)") - .option("--rpc ", "RPC URL for the network") - .action(async (validatorArg: string | undefined, options: SetIdentityOptions) => { + addWalletModeOption( + staking + .command("set-identity [validator]") + .description("Set validator identity metadata (moniker, website, socials, etc.)") + .option("--validator
", "Validator wallet address (deprecated, use positional arg)") + .requiredOption("--moniker ", "Validator display name") + .option("--logo-uri ", "Logo URI") + .option("--website ", "Website URL") + .option("--description ", "Description") + .option("--email ", "Contact email") + .option("--twitter ", "Twitter handle") + .option("--telegram ", "Telegram handle") + .option("--github ", "GitHub handle") + .option("--extra-cid ", "Extra data as IPFS CID or hex bytes (0x...)") + .option("--account ", "Account to use (must be validator operator)") + .option("--password ", "Password to unlock account (skips interactive prompt)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") + .option("--rpc ", "RPC URL for the network"), + ).action(async (validatorArg: string | undefined, options: SetIdentityOptions) => { const validator = validatorArg || options.validator; if (!validator) { console.error("Error: validator address is required"); @@ -184,17 +203,18 @@ export function initializeStakingCommands(program: Command) { }); // Delegator commands - staking - .command("delegator-join [validator]") - .description("Join as a delegator by staking with a validator") - .option("--validator
", "Validator address to delegate to (deprecated, use positional arg)") - .requiredOption("--amount ", "Amount to stake (in wei or with 'eth'/'gen' suffix)") - .option("--account ", "Account to use") - .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "built-in or custom network alias (see: genlayer network list)") - .option("--rpc ", "RPC URL for the network") - .option("--staking-address
", "Staking contract address (overrides chain config)") - .action(async (validatorArg: string | undefined, options: DelegatorJoinOptions) => { + addWalletModeOption( + staking + .command("delegator-join [validator]") + .description("Join as a delegator by staking with a validator") + .option("--validator
", "Validator address to delegate to (deprecated, use positional arg)") + .requiredOption("--amount ", "Amount to stake (in wei or with 'eth'/'gen' suffix)") + .option("--account ", "Account to use") + .option("--password ", "Password to unlock account (skips interactive prompt)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") + .option("--rpc ", "RPC URL for the network") + .option("--staking-address
", "Staking contract address (overrides chain config)"), + ).action(async (validatorArg: string | undefined, options: DelegatorJoinOptions) => { const validator = validatorArg || options.validator; if (!validator) { console.error("Error: validator address is required"); @@ -204,17 +224,18 @@ export function initializeStakingCommands(program: Command) { await action.execute({...options, validator}); }); - staking - .command("delegator-exit [validator]") - .description("Exit as a delegator by withdrawing shares from a validator") - .option("--validator
", "Validator address to exit from (deprecated, use positional arg)") - .requiredOption("--shares ", "Number of shares to withdraw") - .option("--account ", "Account to use") - .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "built-in or custom network alias (see: genlayer network list)") - .option("--rpc ", "RPC URL for the network") - .option("--staking-address
", "Staking contract address (overrides chain config)") - .action(async (validatorArg: string | undefined, options: DelegatorExitOptions) => { + addWalletModeOption( + staking + .command("delegator-exit [validator]") + .description("Exit as a delegator by withdrawing shares from a validator") + .option("--validator
", "Validator address to exit from (deprecated, use positional arg)") + .requiredOption("--shares ", "Number of shares to withdraw") + .option("--account ", "Account to use") + .option("--password ", "Password to unlock account (skips interactive prompt)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") + .option("--rpc ", "RPC URL for the network") + .option("--staking-address
", "Staking contract address (overrides chain config)"), + ).action(async (validatorArg: string | undefined, options: DelegatorExitOptions) => { const validator = validatorArg || options.validator; if (!validator) { console.error("Error: validator address is required"); @@ -224,17 +245,18 @@ export function initializeStakingCommands(program: Command) { await action.execute({...options, validator}); }); - staking - .command("delegator-claim [validator]") - .description("Claim delegator withdrawals after unbonding period") - .option("--validator
", "Validator address (deprecated, use positional arg)") - .option("--delegator
", "Delegator address (defaults to signer)") - .option("--account ", "Account to use") - .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "built-in or custom network alias (see: genlayer network list)") - .option("--rpc ", "RPC URL for the network") - .option("--staking-address
", "Staking contract address (overrides chain config)") - .action(async (validatorArg: string | undefined, options: DelegatorClaimOptions) => { + addWalletModeOption( + staking + .command("delegator-claim [validator]") + .description("Claim delegator withdrawals after unbonding period") + .option("--validator
", "Validator address (deprecated, use positional arg)") + .option("--delegator
", "Delegator address (defaults to signer)") + .option("--account ", "Account to use") + .option("--password ", "Password to unlock account (skips interactive prompt)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") + .option("--rpc ", "RPC URL for the network") + .option("--staking-address
", "Staking contract address (overrides chain config)"), + ).action(async (validatorArg: string | undefined, options: DelegatorClaimOptions) => { const validator = validatorArg || options.validator; if (!validator) { console.error("Error: validator address is required"); @@ -330,7 +352,10 @@ export function initializeStakingCommands(program: Command) { .option("--all", "Include banned validators") .option("--json", "Output machine-readable JSON") .option("--sort-by ", "Sort validators by stake or uptime (default: stake)", "stake") - .option("--explorer-url ", "Explorer backend or explorer base URL for optional performance enrichment") + .option( + "--explorer-url ", + "Explorer backend or explorer base URL for optional performance enrichment", + ) .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") diff --git a/src/commands/staking/setIdentity.ts b/src/commands/staking/setIdentity.ts index eb20acdb..ee5f1075 100644 --- a/src/commands/staking/setIdentity.ts +++ b/src/commands/staking/setIdentity.ts @@ -2,6 +2,7 @@ import {StakingAction, StakingConfig} from "./StakingAction"; import type {Address} from "genlayer-js/types"; import {abi} from "genlayer-js"; import {toHex} from "viem"; +import {buildSetIdentityTx} from "../../lib/wallet/txBuilders"; export interface SetIdentityOptions extends StakingConfig { validator: string; @@ -22,6 +23,10 @@ export class SetIdentityAction extends StakingAction { } async execute(options: SetIdentityOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Setting validator identity..."); try { @@ -75,4 +80,61 @@ export class SetIdentityAction extends StakingAction { this.failSpinner("Failed to set identity", error.message || error); } } + + private async executeWithBrowserWallet(options: SetIdentityOptions): Promise { + let session; + try { + session = await this.getBrowserWalletSession(options, "validator-join"); + } catch (error: any) { + this.failSpinner("Failed to set identity", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + try { + const validatorWallet = options.validator as Address; + const {to, data} = buildSetIdentityTx(validatorWallet, { + moniker: options.moniker, + logoUri: options.logoUri, + website: options.website, + description: options.description, + email: options.email, + twitter: options.twitter, + telegram: options.telegram, + github: options.github, + extraCid: options.extraCid, + }); + + this.log(` From (browser wallet): ${session.signerAddress}`); + const receipt = await session.sendTransaction({ + to, + data, + label: `Set identity (${options.moniker})`, + }); + + const output: Record = { + transactionHash: receipt.transactionHash, + validator: validatorWallet, + moniker: options.moniker, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + }; + + // Add optional fields that were set + if (options.logoUri) output.logoUri = options.logoUri; + if (options.website) output.website = options.website; + if (options.description) output.description = options.description; + if (options.email) output.email = options.email; + if (options.twitter) output.twitter = options.twitter; + if (options.telegram) output.telegram = options.telegram; + if (options.github) output.github = options.github; + if (options.extraCid) output.extraCid = options.extraCid; + + this.succeedSpinner("Validator identity set!", output); + } catch (error: any) { + this.failSpinner("Failed to set identity", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/staking/setOperator.ts b/src/commands/staking/setOperator.ts index 446cef56..5775a76c 100644 --- a/src/commands/staking/setOperator.ts +++ b/src/commands/staking/setOperator.ts @@ -1,6 +1,7 @@ import {StakingAction, StakingConfig} from "./StakingAction"; import type {Address} from "genlayer-js/types"; import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface SetOperatorOptions extends StakingConfig { validator: string; @@ -13,6 +14,10 @@ export class SetOperatorAction extends StakingAction { } async execute(options: SetOperatorOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Setting operator..."); try { @@ -43,4 +48,41 @@ export class SetOperatorAction extends StakingAction { this.failSpinner("Failed to set operator", error.message || error); } } + + private async executeWithBrowserWallet(options: SetOperatorOptions): Promise { + let session; + try { + session = await this.getBrowserWalletSession(options, "validator-join"); + } catch (error: any) { + this.failSpinner("Failed to set operator", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + try { + const validatorWallet = options.validator as Address; + const {to, data} = buildTx(abi.VALIDATOR_WALLET_ABI as any, validatorWallet, "setOperator", [ + options.operator as Address, + ]); + + this.log(` From (browser wallet): ${session.signerAddress}`); + const receipt = await session.sendTransaction({ + to, + data, + label: `Set operator to ${options.operator}`, + }); + + this.succeedSpinner("Operator updated!", { + transactionHash: receipt.transactionHash, + validator: validatorWallet, + newOperator: options.operator, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to set operator", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/staking/validatorClaim.ts b/src/commands/staking/validatorClaim.ts index 319ffbbd..e3c8a5ac 100644 --- a/src/commands/staking/validatorClaim.ts +++ b/src/commands/staking/validatorClaim.ts @@ -1,6 +1,7 @@ import {StakingAction, StakingConfig} from "./StakingAction"; import type {Address} from "genlayer-js/types"; import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface ValidatorClaimOptions extends StakingConfig { validator: string; @@ -12,6 +13,10 @@ export class ValidatorClaimAction extends StakingAction { } async execute(options: ValidatorClaimOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Claiming validator withdrawals..."); try { @@ -40,4 +45,38 @@ export class ValidatorClaimAction extends StakingAction { this.failSpinner("Failed to claim", error.message || error); } } + + private async executeWithBrowserWallet(options: ValidatorClaimOptions): Promise { + let session; + try { + session = await this.getBrowserWalletSession(options, "validator-join"); + } catch (error: any) { + this.failSpinner("Failed to claim", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + try { + const validatorWallet = options.validator as Address; + const {to, data} = buildTx(abi.VALIDATOR_WALLET_ABI as any, validatorWallet, "validatorClaim"); + + this.log(` From (browser wallet): ${session.signerAddress}`); + const receipt = await session.sendTransaction({ + to, + data, + label: `Claim validator withdrawals`, + }); + + this.succeedSpinner("Claim successful!", { + transactionHash: receipt.transactionHash, + validator: validatorWallet, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to claim", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/staking/validatorDeposit.ts b/src/commands/staking/validatorDeposit.ts index e295fdf6..6a71fd12 100644 --- a/src/commands/staking/validatorDeposit.ts +++ b/src/commands/staking/validatorDeposit.ts @@ -1,6 +1,7 @@ import {StakingAction, StakingConfig} from "./StakingAction"; import type {Address} from "genlayer-js/types"; import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface ValidatorDepositOptions extends StakingConfig { amount: string; @@ -13,6 +14,10 @@ export class ValidatorDepositAction extends StakingAction { } async execute(options: ValidatorDepositOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Making validator deposit..."); try { @@ -45,4 +50,41 @@ export class ValidatorDepositAction extends StakingAction { this.failSpinner("Failed to make deposit", error.message || error); } } + + private async executeWithBrowserWallet(options: ValidatorDepositOptions): Promise { + let session; + try { + session = await this.getBrowserWalletSession(options, "validator-join"); + } catch (error: any) { + this.failSpinner("Failed to make deposit", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + try { + const amount = this.parseAmount(options.amount); + const validatorWallet = options.validator as Address; + const {to, data} = buildTx(abi.VALIDATOR_WALLET_ABI as any, validatorWallet, "validatorDeposit"); + + this.log(` From (browser wallet): ${session.signerAddress}`); + const receipt = await session.sendTransaction({ + to, + data, + value: amount, + label: `Deposit ${this.formatAmount(amount)} to validator`, + }); + + this.succeedSpinner("Deposit successful!", { + transactionHash: receipt.transactionHash, + validator: validatorWallet, + amount: this.formatAmount(amount), + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to make deposit", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/staking/validatorExit.ts b/src/commands/staking/validatorExit.ts index 5a038c5e..6829cbdd 100644 --- a/src/commands/staking/validatorExit.ts +++ b/src/commands/staking/validatorExit.ts @@ -1,6 +1,7 @@ import {StakingAction, StakingConfig} from "./StakingAction"; import type {Address} from "genlayer-js/types"; import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface ValidatorExitOptions extends StakingConfig { validator: string; @@ -13,6 +14,10 @@ export class ValidatorExitAction extends StakingAction { } async execute(options: ValidatorExitOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Initiating validator exit..."); try { @@ -60,4 +65,56 @@ export class ValidatorExitAction extends StakingAction { this.failSpinner("Failed to exit", error.message || error); } } + + private async executeWithBrowserWallet(options: ValidatorExitOptions): Promise { + let session; + try { + session = await this.getBrowserWalletSession(options, "validator-join"); + } catch (error: any) { + this.failSpinner("Failed to exit", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + try { + let shares: bigint; + try { + shares = BigInt(options.shares); + if (shares <= 0n) throw new Error("must be positive"); + } catch { + this.failSpinner(`Invalid shares value: "${options.shares}". Must be a positive whole number.`); + return; + } + + const validatorWallet = options.validator as Address; + const {to, data} = buildTx(abi.VALIDATOR_WALLET_ABI as any, validatorWallet, "validatorExit", [shares]); + + this.log(` From (browser wallet): ${session.signerAddress}`); + const receipt = await session.sendTransaction({ + to, + data, + label: `Exit validator (${shares} shares)`, + }); + + // Check epoch to determine note + const readClient = await this.getReadOnlyStakingClient(options); + const epochInfo = await readClient.getEpochInfo(); + const isEpochZero = epochInfo.currentEpoch === 0n; + + this.succeedSpinner("Exit initiated successfully!", { + transactionHash: receipt.transactionHash, + validator: validatorWallet, + sharesWithdrawn: shares.toString(), + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + note: isEpochZero + ? "Epoch 0: Withdrawal claimable immediately" + : "Withdrawal will be claimable after the unbonding period", + }); + } catch (error: any) { + this.failSpinner("Failed to exit", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/staking/validatorJoin.ts b/src/commands/staking/validatorJoin.ts index 8185fd91..d41281be 100644 --- a/src/commands/staking/validatorJoin.ts +++ b/src/commands/staking/validatorJoin.ts @@ -1,5 +1,6 @@ import {StakingAction, StakingConfig} from "./StakingAction"; import type {Address} from "genlayer-js/types"; +import {buildValidatorJoinTx, extractValidatorWallet} from "../../lib/wallet/stakingTx"; export interface ValidatorJoinOptions extends StakingConfig { amount: string; @@ -12,6 +13,10 @@ export class ValidatorJoinAction extends StakingAction { } async execute(options: ValidatorJoinOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Creating a new validator..."); try { @@ -44,4 +49,49 @@ export class ValidatorJoinAction extends StakingAction { this.failSpinner("Failed to create validator", error.message || error); } } + + private async executeWithBrowserWallet(options: ValidatorJoinOptions): Promise { + let session; + try { + session = await this.getBrowserWalletSession(options, "validator-join"); + } catch (error: any) { + this.failSpinner("Failed to create validator", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + try { + const amount = this.parseAmount(options.amount); + const {to, data} = buildValidatorJoinTx(session.stakingAddress, options.operator); + + this.log(` From (browser wallet): ${session.signerAddress}`); + if (options.operator) { + this.log(` Operator: ${options.operator}`); + } + + const receipt = await session.sendTransaction({ + to, + data, + value: amount, + label: `Join as validator (${this.formatAmount(amount)})`, + }); + + const validatorWallet = extractValidatorWallet(receipt); + + this.succeedSpinner("Validator created successfully!", { + transactionHash: receipt.transactionHash, + validatorWallet, + amount: this.formatAmount(amount), + operator: options.operator ?? session.signerAddress, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to create validator", error.message || error); + } finally { + // session.close() is a no-op for a remote (daemon) session and a full + // close for an own bridge — so a shared daemon survives the command. + await session.close(); + } + } } diff --git a/src/commands/staking/validatorPrime.ts b/src/commands/staking/validatorPrime.ts index bf7d4294..b0f12cf0 100644 --- a/src/commands/staking/validatorPrime.ts +++ b/src/commands/staking/validatorPrime.ts @@ -1,6 +1,8 @@ import {StakingAction, StakingConfig} from "./StakingAction"; import type {Address} from "genlayer-js/types"; +import {abi} from "genlayer-js"; import chalk from "chalk"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface ValidatorPrimeOptions extends StakingConfig { validator: string; @@ -12,6 +14,10 @@ export class ValidatorPrimeAction extends StakingAction { } async execute(options: ValidatorPrimeOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Priming validator..."); try { @@ -34,7 +40,46 @@ export class ValidatorPrimeAction extends StakingAction { } } + private async executeWithBrowserWallet(options: ValidatorPrimeOptions): Promise { + let session; + try { + session = await this.getBrowserWalletSession(options, "validator-join"); + } catch (error: any) { + this.failSpinner("Failed to prime validator", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + try { + const {to, data} = buildTx(abi.STAKING_ABI as any, session.stakingAddress, "validatorPrime", [ + options.validator as Address, + ]); + + this.log(` From (browser wallet): ${session.signerAddress}`); + const receipt = await session.sendTransaction({ + to, + data, + label: `Prime ${options.validator}`, + }); + + this.succeedSpinner("Validator primed for next epoch!", { + transactionHash: receipt.transactionHash, + validator: options.validator, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to prime validator", error.message || error); + } finally { + await session.close(); + } + } + async primeAll(options: StakingConfig): Promise { + if (this.isBrowserWallet(options)) { + return this.primeAllWithBrowserWallet(options); + } + this.startSpinner("Fetching validators..."); try { @@ -70,4 +115,47 @@ export class ValidatorPrimeAction extends StakingAction { this.failSpinner("Failed to prime validators", error.message || error); } } + + private async primeAllWithBrowserWallet(options: StakingConfig): Promise { + let session; + try { + session = await this.getBrowserWalletSession(options, "validator-join"); + } catch (error: any) { + this.failSpinner("Failed to prime validators", error.message || error); + return; + } + + try { + this.startSpinner("Fetching validators..."); + const allValidators = await this.getAllValidatorsFromTree(options); + + this.stopSpinner(); + console.log(`\nPriming ${allValidators.length} validators:\n`); + + let succeeded = 0; + let skipped = 0; + + for (const addr of allValidators) { + process.stdout.write(` ${addr} ... `); + + try { + const {to, data} = buildTx(abi.STAKING_ABI as any, session.stakingAddress, "validatorPrime", [addr]); + const receipt = await session.sendTransaction({to, data, label: `Prime ${addr}`}); + console.log(chalk.green(`primed ${receipt.transactionHash}`)); + succeeded++; + } catch (error: any) { + const msg = error.message || String(error); + const shortErr = msg.length > 60 ? msg.slice(0, 57) + "..." : msg; + console.log(chalk.gray(`skipped: ${shortErr}`)); + skipped++; + } + } + + console.log(`\n${chalk.green(`${succeeded} primed`)}, ${chalk.gray(`${skipped} skipped`)}\n`); + } catch (error: any) { + this.failSpinner("Failed to prime validators", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/staking/wizard.ts b/src/commands/staking/wizard.ts index d7137b8a..91d970da 100644 --- a/src/commands/staking/wizard.ts +++ b/src/commands/staking/wizard.ts @@ -1,13 +1,19 @@ -import {StakingAction, StakingConfig, BUILT_IN_NETWORKS} from "./StakingAction"; +import {StakingAction, StakingConfig, BUILT_IN_NETWORKS, type BrowserWalletSession} from "./StakingAction"; import {resolveNetwork} from "../../lib/actions/BaseAction"; import {CreateAccountAction} from "../account/create"; import {ExportAccountAction} from "../account/export"; import inquirer from "inquirer"; import type {Address} from "genlayer-js/types"; import {formatEther, parseEther} from "viem"; -import {createClient} from "genlayer-js"; +import {createClient, abi} from "genlayer-js"; import {readFileSync, existsSync} from "fs"; import path from "path"; +import {buildValidatorJoinTx, buildSetIdentityTx, extractValidatorWallet} from "../../lib/wallet/stakingTx"; +import {buildTx} from "../../lib/wallet/txBuilders"; +import type {VestingClient, VestingValidatorJoinResult} from "../vesting/vestingTypes"; +import {vestingAvailableToStake} from "../../lib/vesting/availableToStake"; + +const BROWSER_WALLET_CHOICE = "__browser_wallet__"; export interface WizardOptions extends StakingConfig { skipIdentity?: boolean; @@ -17,6 +23,10 @@ interface WizardState { accountName: string; accountAddress: string; networkAlias: string; + /** Where the self-stake comes from. "wallet" (default) keeps the original flow. */ + stakeSource: "wallet" | "vesting"; + /** Chosen vesting contract address when stakeSource === "vesting". */ + vestingContract?: string; balance: bigint; minStake: bigint; operatorAddress?: string; @@ -24,6 +34,7 @@ interface WizardState { operatorKeystorePath?: string; stakeAmount: string; validatorWalletAddress?: string; // the validator contract address returned from validatorJoin + ownerIsBrowserWallet?: boolean; identity?: { moniker: string; logoUri?: string; @@ -52,6 +63,9 @@ export class ValidatorWizardAction extends StakingAction { console.log(" GenLayer Validator Setup Wizard"); console.log("========================================\n"); + // Validate flag combinations up-front (throws on --account/--password + browser). + this.assertBrowserWalletFlags(options, "wizard"); + const state: Partial = {}; try { @@ -61,7 +75,11 @@ export class ValidatorWizardAction extends StakingAction { // Step 2: Network Selection await this.stepNetworkSelection(state, options); - // Step 3: Balance Check + // Step 2b: Funding source (wallet vs vesting contract). Default "wallet" + // keeps the original flow untouched. + await this.stepStakeSource(state, options); + + // Step 3: Balance Check (lazily starts the browser session if owner is browser wallet) await this.stepBalanceCheck(state, options); // Step 4: Operator Setup @@ -86,13 +104,57 @@ export class ValidatorWizardAction extends StakingAction { return; } this.failSpinner("Wizard failed", error.message || error); + } finally { + const session = this._wizardSession; + this._wizardSession = null; + this.browserSession = null; + // session.close() is a no-op for a remote (daemon) session and a full + // close for an own bridge — so a shared daemon survives the wizard. + if (session) await session.close(); } } + /** + * Lazily start the browser-wallet bridge for the owner. Deferred until after + * network selection (step 2) so the connect prompt carries the right chain. + * Idempotent — reuses the same page session across steps 3/6/7. + */ + private async ensureBrowserSession( + state: Partial, + options: WizardOptions, + ): Promise { + if (this._wizardSession) return this._wizardSession; + + // getBrowserWalletSession sets this.browserSession (base field) internally. + this._wizardSession = await this.getBrowserWalletSession( + {...options, network: state.networkAlias}, + "wizard", + ); + state.accountAddress = this._wizardSession.signerAddress; + if (!state.accountName) state.accountName = "browser wallet"; + return this._wizardSession; + } + + /** Staking-scoped session cache (carries stakingAddress on top of the base session). */ + private _wizardSession: BrowserWalletSession | null = null; + private async stepAccountSetup(state: Partial, options: WizardOptions): Promise { console.log("Step 1: Account Setup"); console.log("---------------------\n"); + // Browser-wallet owner. Auto-selected when the effective wallet mode is + // browser: explicit --wallet browser, walletMode=browser config, OR a live + // wallet session (connect-once) — consistent with every other command. The + // actual bridge start is deferred until after network selection (step 2) so + // the connect prompt carries the right chain; here we only record the choice. + if (this.resolveWalletMode(options.wallet) === "browser") { + state.ownerIsBrowserWallet = true; + state.accountName = "browser wallet"; + console.log("Owner account: browser wallet (MetaMask) — the cold key stays in your wallet."); + console.log("You will connect and sign in your browser after selecting the network.\n"); + return; + } + // Check if account override provided if (options.account) { const keystorePath = this.getKeystorePath(options.account); @@ -132,6 +194,10 @@ export class ValidatorWizardAction extends StakingAction { } else { // Accounts exist, choose or create const choices = [ + { + name: "Connect browser wallet (MetaMask) — cold key stays in your wallet", + value: BROWSER_WALLET_CHOICE, + }, ...accounts.map(a => ({ name: `${a.name} (${a.address})`, value: a.name, @@ -148,7 +214,12 @@ export class ValidatorWizardAction extends StakingAction { }, ]); - if (selectedAccount === "__create_new__") { + if (selectedAccount === BROWSER_WALLET_CHOICE) { + state.ownerIsBrowserWallet = true; + state.accountName = "browser wallet"; + console.log("\nOwner account: browser wallet (MetaMask)."); + console.log("You will connect and sign in your browser after selecting the network."); + } else if (selectedAccount === "__create_new__") { const {accountName} = await inquirer.prompt([ { type: "input", @@ -205,25 +276,147 @@ export class ValidatorWizardAction extends StakingAction { value: alias, })); + // Also offer any custom networks the user configured (`genlayer network add`). + const customNetworks = this.getCustomNetworks(); + const customChoices = Object.entries(customNetworks).map(([alias, profile]) => ({ + name: `${resolveNetwork(alias, customNetworks).name} (custom, base: ${profile.base})`, + value: alias, + })); + const {selectedNetwork} = await inquirer.prompt([ { type: "list", name: "selectedNetwork", message: "Select network:", - choices: networks, + choices: [...networks, ...customChoices], default: currentNetwork || "testnet-asimov", }, ]); state.networkAlias = selectedNetwork; this.writeConfig("network", selectedNetwork); - console.log(`\nNetwork set to: ${BUILT_IN_NETWORKS[selectedNetwork].name}\n`); + // Resolve through both built-in and custom maps so a custom alias doesn't crash. + console.log(`\nNetwork set to: ${resolveNetwork(selectedNetwork, this.getCustomNetworks()).name}\n`); + } + + /** + * Account-less (read-only) client typed for the vesting lookups the wizard + * needs (getBeneficiaryVestings / getVestingState / getValidatorWallets). The + * genlayer-js client exposes the vesting actions at runtime; the cast bridges + * the CLI-facing type shim (same pattern as VestingAction.getReadOnlyVestingClient). + */ + private getWizardVestingReadClient(state: Partial, options: WizardOptions): VestingClient { + const network = resolveNetwork(state.networkAlias!, this.getCustomNetworks()); + return createClient({ + chain: network, + account: state.accountAddress as Address, + endpoint: options.rpc, + }) as unknown as VestingClient; + } + + /** + * Choose where the self-stake is funded from: the owner's wallet (default, + * original flow) or one of the owner's vesting contracts. When vesting is + * chosen we resolve the concrete contract up-front (none → loop back with a + * clear message; one → use it; many → pick), so the balance/join steps have a + * concrete `state.vestingContract` to work with. + */ + private async stepStakeSource(state: Partial, options: WizardOptions): Promise { + console.log("Step: Funding Source"); + console.log("--------------------\n"); + + // Loop so a "no vesting contracts" answer can bounce the user straight back + // to the source choice instead of crashing or dead-ending. + for (;;) { + const {stakeSource} = await inquirer.prompt([ + { + type: "list", + name: "stakeSource", + message: "Fund this validator from:", + choices: [ + {name: "Your wallet", value: "wallet"}, + {name: "A vesting contract", value: "vesting"}, + ], + default: "wallet", + }, + ]); + + if (stakeSource === "wallet") { + state.stakeSource = "wallet"; + console.log(""); + return; + } + + // Vesting: the beneficiary is the owner. For a browser owner not yet + // connected, start the shared session now (network is already selected) so + // we can read the connected address — the same session the join reuses. + let beneficiary = state.accountAddress; + if (!beneficiary && state.ownerIsBrowserWallet) { + console.log("Connect your browser wallet to continue..."); + const session = await this.ensureBrowserSession(state, options); + beneficiary = session.signerAddress; + } + + this.startSpinner("Looking up vesting contracts..."); + let vestings: Address[] = []; + try { + const readClient = this.getWizardVestingReadClient(state, options); + vestings = await readClient.getBeneficiaryVestings(beneficiary as Address); + } catch (error: any) { + this.stopSpinner(); + this.logWarning(`Could not look up vesting contracts: ${error.message || error}`); + continue; + } + this.stopSpinner(); + + if (!vestings || vestings.length === 0) { + this.logWarning( + `No vesting contracts found for ${beneficiary}. ` + + `Choose 'Your wallet' or fund a vesting contract first.`, + ); + continue; + } + + if (vestings.length === 1) { + state.vestingContract = ensureHexPrefix(vestings[0]); + } else { + const {selectedVesting} = await inquirer.prompt([ + { + type: "list", + name: "selectedVesting", + message: "Select the vesting contract to fund from:", + choices: vestings.map(v => ({name: v, value: v})), + }, + ]); + state.vestingContract = ensureHexPrefix(selectedVesting); + } + + state.stakeSource = "vesting"; + console.log(`\nFunding from vesting contract: ${state.vestingContract}\n`); + return; + } } private async stepBalanceCheck(state: Partial, options: WizardOptions): Promise { console.log("Step 3: Balance Check"); console.log("---------------------\n"); + // For a browser-wallet owner, start the bridge now (network is known) and + // obtain the owner address from the wallet connect handshake. The session may + // already be up if a vesting source was chosen in the funding step. + if (state.ownerIsBrowserWallet) { + if (!this._wizardSession) console.log("Connect your browser wallet to continue..."); + const session = await this.ensureBrowserSession(state, options); + state.accountAddress = session.signerAddress; + console.log(`Connected owner: ${session.signerAddress}\n`); + } + + // A vesting-funded validator checks the vesting contract's balance, not the + // wallet's (gas is still paid from the wallet — see the sanity check inside). + if (state.stakeSource === "vesting") { + return this.stepBalanceCheckVesting(state, options); + } + this.startSpinner("Checking balance and staking requirements..."); const network = resolveNetwork(state.networkAlias!, this.getCustomNetworks()); @@ -263,7 +456,7 @@ export class ValidatorWizardAction extends StakingAction { const minFormatted = currentEpoch === 0n ? "0.01 GEN (for gas)" : `${minStakeFormatted} + gas`; this.failSpinner( `Insufficient balance. You need at least ${minFormatted} to become a validator.\n` + - `Fund your account (${state.accountAddress}) and run the wizard again.` + `Fund your account (${state.accountAddress}) and run the wizard again.`, ); } @@ -273,6 +466,94 @@ export class ValidatorWizardAction extends StakingAction { console.log("Balance sufficient!\n"); } + /** + * Balance check for a vesting-funded validator. The stake is committed from + * the vesting contract, so we validate the contract's available-to-stake + * against the minimum. "Available" is the contract's LIVE ON-CHAIN BALANCE + * (0 once revoked): Vesting.sol enforces staking against address(this).balance + * — reverting InsufficientContractBalance when the amount exceeds it — so the + * balance is the true cap (shared with `genlayer balances`). It already + * includes still-locked (unvested) tokens, which vesting-backed staking may + * commit (they return to the contract on exit). Gas is still paid from the + * wallet, so we keep a non-blocking low-wallet-balance warning. + */ + private async stepBalanceCheckVesting(state: Partial, options: WizardOptions): Promise { + this.startSpinner("Checking vesting balance and staking requirements..."); + + const network = resolveNetwork(state.networkAlias!, this.getCustomNetworks()); + const walletClient = createClient({ + chain: network, + account: state.accountAddress as Address, + endpoint: options.rpc, + }); + const vestingClient = this.getWizardVestingReadClient(state, options); + + const [walletBalance, epochInfo, vestingState] = await Promise.all([ + walletClient.getBalance({address: state.accountAddress as Address}), + walletClient.getEpochInfo(), + vestingClient.getVestingState(state.vestingContract as Address), + ]); + + this.stopSpinner(); + + const minStakeRaw = epochInfo.validatorMinStakeRaw; + const minStakeFormatted = epochInfo.validatorMinStake; + const currentEpoch = epochInfo.currentEpoch; + + // A revoked contract can never stake again (Vesting.sol blocks every stake + // path once revoked), so its available-to-stake is 0 regardless of balance. + // Bail out cleanly — like the no-contracts path — rather than presenting a + // 0 cap the user can't act on. + if (vestingState.revoked) { + this.logError( + `This vesting contract has been revoked; it can no longer stake.\n` + + `Re-run the wizard and choose 'Your wallet'.`, + ); + throw new Error("WIZARD_ABORTED"); + } + + // Authoritative cap: the vesting contract's live native balance (not + // total − withdrawn), shared with `genlayer balances`. + const available = await vestingAvailableToStake( + vestingClient, + state.vestingContract as Address, + vestingState.revoked, + ); + + console.log(`Vesting contract: ${state.vestingContract}`); + console.log(`Available to stake: ${this.formatAmount(available)}`); + console.log(`Minimum stake required: ${minStakeFormatted}`); + if (currentEpoch === 0n) { + console.log("(Epoch 0: minimum stake not enforced)"); + console.log(`Note: Validator won't become active until self-stake reaches ${minStakeFormatted}`); + } + + const minRequired = currentEpoch === 0n ? 0n : minStakeRaw; + if (available < minRequired) { + console.log(""); + this.failSpinner( + `Insufficient vesting balance. The vesting contract has ${this.formatAmount(available)} available, ` + + `but at least ${minStakeFormatted} is required to become a validator.\n` + + `Fund the vesting contract or re-run the wizard and choose 'Your wallet'.`, + ); + } + + // Gas for the create tx is paid from the wallet, not the vesting contract. + const MIN_GAS_BUFFER = parseEther("0.01"); + if (walletBalance < MIN_GAS_BUFFER) { + this.logWarning( + `Your wallet balance (${formatEther(walletBalance)} GEN) is low. Gas for the create ` + + `transaction is paid from your wallet (${state.accountAddress}), not the vesting contract.`, + ); + } + + // stepStakeAmount reuses state.balance as the max and state.minStake as the floor. + state.balance = available; + state.minStake = currentEpoch === 0n ? 0n : minStakeRaw; + + console.log("Vesting balance sufficient!\n"); + } + private async stepOperatorSetup(state: Partial): Promise { console.log("Step 4: Operator Setup"); console.log("----------------------\n"); @@ -592,6 +873,19 @@ export class ValidatorWizardAction extends StakingAction { console.log("Step 6: Join as Validator"); console.log("-------------------------\n"); + // Vesting-funded: build+send the vesting validator-create tx (funds come from + // the vesting contract, not the wallet). Operator is still required + passed. + if (state.stakeSource === "vesting") { + if (state.ownerIsBrowserWallet) { + return this.stepJoinValidatorVestingBrowser(state, options); + } + return this.stepJoinValidatorVestingKeystore(state, options); + } + + if (state.ownerIsBrowserWallet) { + return this.stepJoinValidatorBrowser(state, options); + } + this.startSpinner("Creating validator..."); try { @@ -627,10 +921,174 @@ export class ValidatorWizardAction extends StakingAction { } } + private async stepJoinValidatorBrowser(state: Partial, options: WizardOptions): Promise { + const session = await this.ensureBrowserSession(state, options); + const amount = this.parseAmount(state.stakeAmount!); + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + const {to, data} = buildValidatorJoinTx(session.stakingAddress, state.operatorAddress); + const receipt = await session.sendTransaction({ + to, + data, + value: amount, + label: `Join as validator (${this.formatAmount(amount)})`, + }); + + const validatorWallet = extractValidatorWallet(receipt); + state.validatorWalletAddress = ensureHexPrefix(validatorWallet); + + this.succeedSpinner("Validator created successfully!", { + transactionHash: receipt.transactionHash, + validatorWallet: state.validatorWalletAddress, + amount: this.formatAmount(amount), + operator: state.operatorAddress, + blockNumber: receipt.blockNumber.toString(), + }); + + console.log(""); + } catch (error: any) { + // Abort the wizard on a failed/rejected join (nothing downstream can proceed). + this.failSpinner("Failed to create validator", error.message || error); + throw new Error("WIZARD_ABORTED"); + } + } + + /** + * Keystore owner + vesting source: create the validator via the vesting client's + * vestingValidatorJoin (mirrors `vesting validator create`). The genlayer-js + * client the keystore path builds exposes the vesting actions at runtime; the + * cast bridges the CLI-facing type shim. + */ + private async stepJoinValidatorVestingKeystore( + state: Partial, + options: WizardOptions, + ): Promise { + this.startSpinner("Creating vesting-backed validator..."); + + try { + const client = (await this.getStakingClient({ + ...options, + account: state.accountName, + network: state.networkAlias, + })) as unknown as VestingClient; + + const amount = this.parseAmount(state.stakeAmount!); + const vesting = state.vestingContract as Address; + + this.setSpinnerText(`Creating validator with ${this.formatAmount(amount)} from vesting ${vesting}...`); + + // Pin the CLI-facing result shape: the SDK's GenLayerClient typing omits the + // optional validatorWallet/wallet fields, so read them off the local shim. + const result: VestingValidatorJoinResult = await client.vestingValidatorJoin({ + vesting, + operator: state.operatorAddress as Address, + amount, + }); + + // The join receipt does not carry the wallet address; the vesting contract + // tracks its wallets, so the newest entry is the one just created. + let validatorWallet = result.validatorWallet || result.wallet; + if (!validatorWallet) { + try { + const wallets = await client.getValidatorWallets(vesting); + validatorWallet = wallets[wallets.length - 1]; + } catch { + // Leave undefined; the summary falls back to the owner address. + } + } + if (validatorWallet) state.validatorWalletAddress = ensureHexPrefix(validatorWallet); + + this.succeedSpinner("Vesting-backed validator created successfully!", { + transactionHash: result.transactionHash, + vesting, + validatorWallet: state.validatorWalletAddress, + amount: result.amount || this.formatAmount(amount), + operator: result.operator || state.operatorAddress, + blockNumber: result.blockNumber.toString(), + }); + + console.log(""); + } catch (error: any) { + this.failSpinner("Failed to create vesting-backed validator", error.message || error); + } + } + + /** + * Browser owner + vesting source: send the vestingValidatorJoin tx through the + * shared browser session (reusing the same tx-builder as `vesting validator + * create --wallet browser`). Funds come from the vesting contract, so there is + * no msg.value on this tx. + */ + private async stepJoinValidatorVestingBrowser( + state: Partial, + options: WizardOptions, + ): Promise { + const session = await this.ensureBrowserSession(state, options); + const amount = this.parseAmount(state.stakeAmount!); + const vesting = state.vestingContract as Address; + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + const {to, data} = buildTx(abi.VESTING_ABI as any, vesting, "vestingValidatorJoin", [ + state.operatorAddress, + amount, + ]); + + const receipt = await session.sendTransaction({ + to, + data, + label: `Create vesting validator (${this.formatAmount(amount)})`, + }); + + // The join receipt does not carry the wallet address; read the newest entry + // the vesting contract tracks. + let validatorWallet: Address | undefined; + try { + const readClient = this.getWizardVestingReadClient(state, options); + const wallets = await readClient.getValidatorWallets(vesting); + validatorWallet = wallets[wallets.length - 1]; + } catch { + // Leave undefined; the summary falls back to the owner address. + } + if (validatorWallet) state.validatorWalletAddress = ensureHexPrefix(validatorWallet); + + this.succeedSpinner("Vesting-backed validator created successfully!", { + transactionHash: receipt.transactionHash, + vesting, + validatorWallet: state.validatorWalletAddress, + amount: this.formatAmount(amount), + operator: state.operatorAddress, + blockNumber: receipt.blockNumber.toString(), + }); + + console.log(""); + } catch (error: any) { + // Abort the wizard on a failed/rejected join (nothing downstream can proceed). + this.failSpinner("Failed to create vesting-backed validator", error.message || error); + throw new Error("WIZARD_ABORTED"); + } + } + private async stepIdentitySetup(state: Partial, options: WizardOptions): Promise { console.log("Step 7: Identity Setup"); console.log("----------------------\n"); + // Setting identity on a vesting-backed validator wallet goes through the + // vesting contract (a different call than staking's setIdentity). Rather than + // fork the whole identity step, point the user at the dedicated command. + if (state.stakeSource === "vesting") { + const walletHint = state.validatorWalletAddress || ""; + console.log("Identity setup is skipped for vesting-backed validators in the wizard."); + console.log( + `Set it later with: genlayer vesting validator set-identity ${walletHint} ` + + `--vesting ${state.vestingContract} --moniker ""\n`, + ); + return; + } + const {setupIdentity} = await inquirer.prompt([ { type: "confirm", @@ -724,27 +1182,43 @@ export class ValidatorWizardAction extends StakingAction { this.startSpinner("Setting validator identity..."); - try { - const client = await this.getStakingClient({ - ...options, - account: state.accountName, - network: state.networkAlias, - }); + // Use the validator wallet address (contract), not owner address + const validatorAddress = ensureHexPrefix(state.validatorWalletAddress || state.accountAddress!); - // Use the validator wallet address (contract), not owner address - const validatorAddress = state.validatorWalletAddress || state.accountAddress; - - await client.setIdentity({ - validator: ensureHexPrefix(validatorAddress) as Address, - moniker, - logoUri: logoUri || undefined, - website: website || undefined, - description: description || undefined, - email: email || undefined, - twitter: twitter || undefined, - telegram: telegram || undefined, - github: github || undefined, - }); + try { + if (state.ownerIsBrowserWallet) { + const session = await this.ensureBrowserSession(state, options); + this.setSpinnerText("Confirm the identity transaction in your browser wallet..."); + const {to, data} = buildSetIdentityTx(validatorAddress, { + moniker, + logoUri: logoUri || undefined, + website: website || undefined, + description: description || undefined, + email: email || undefined, + twitter: twitter || undefined, + telegram: telegram || undefined, + github: github || undefined, + }); + await session.sendTransaction({to, data, label: `Set validator identity (${moniker})`}); + } else { + const client = await this.getStakingClient({ + ...options, + account: state.accountName, + network: state.networkAlias, + }); + + await client.setIdentity({ + validator: validatorAddress as Address, + moniker, + logoUri: logoUri || undefined, + website: website || undefined, + description: description || undefined, + email: email || undefined, + twitter: twitter || undefined, + telegram: telegram || undefined, + github: github || undefined, + }); + } this.succeedSpinner("Validator identity set!"); console.log(""); @@ -770,6 +1244,13 @@ export class ValidatorWizardAction extends StakingAction { console.log(` Validator Wallet: ${validatorWallet}`); console.log(` Owner: ${ownerAddress} (${state.accountName})`); + if (state.stakeSource === "vesting") { + console.log(` Funding Source: Vesting contract ${ensureHexPrefix(state.vestingContract || "")}`); + console.log(` (staked funds return to the vesting contract on exit/claim)`); + } else { + console.log(` Funding Source: Your wallet (${ownerAddress})`); + } + // Operator - show account name if it's a CLI account if (state.operatorAccountName) { console.log(` Operator: ${operatorAddress} (${state.operatorAccountName})`); @@ -801,7 +1282,9 @@ export class ValidatorWizardAction extends StakingAction { } console.log(` ${step++}. Monitor your validator:`); console.log(` genlayer staking validator-info --validator ${validatorWallet}`); - console.log(` ${step++}. Lock your account when done: genlayer account lock`); + if (!state.ownerIsBrowserWallet) { + console.log(` ${step++}. Lock your account when done: genlayer account lock`); + } console.log("\n========================================\n"); } } diff --git a/src/commands/transactions/appeal.ts b/src/commands/transactions/appeal.ts index 0c4d9082..f5f80866 100644 --- a/src/commands/transactions/appeal.ts +++ b/src/commands/transactions/appeal.ts @@ -5,6 +5,7 @@ import {BaseAction} from "../../lib/actions/BaseAction"; export interface AppealOptions { rpc?: string; bond?: string; + wallet?: "keystore" | "browser"; } export interface AppealBondOptions { @@ -20,12 +21,16 @@ export class AppealAction extends BaseAction { txId, rpc, bond, + wallet, }: { txId: TransactionHash; rpc?: string; bond?: string; + wallet?: "keystore" | "browser"; }): Promise { + if (this.isBrowserWallet({wallet})) this.walletModeOverride = "browser"; const client = await this.getClient(rpc); + this.browserSession?.setNextLabel(`Appeal ${txId}`); try { let value: bigint | undefined; @@ -60,6 +65,8 @@ export class AppealAction extends BaseAction { this.succeedSpinner("Appeal successfully executed", result); } catch (error) { this.failSpinner("Error during appeal operation", error); + } finally { + await this.closeBrowserSession(); } } diff --git a/src/commands/transactions/finalize.ts b/src/commands/transactions/finalize.ts index cd780f26..af271387 100644 --- a/src/commands/transactions/finalize.ts +++ b/src/commands/transactions/finalize.ts @@ -3,6 +3,7 @@ import {BaseAction} from "../../lib/actions/BaseAction"; export interface FinalizeOptions { rpc?: string; + wallet?: "keystore" | "browser"; } export class FinalizeAction extends BaseAction { @@ -10,8 +11,18 @@ export class FinalizeAction extends BaseAction { super(); } - async finalize({txId, rpc}: {txId: TransactionHash; rpc?: string}): Promise { + async finalize({ + txId, + rpc, + wallet, + }: { + txId: TransactionHash; + rpc?: string; + wallet?: "keystore" | "browser"; + }): Promise { + if (this.isBrowserWallet({wallet})) this.walletModeOverride = "browser"; const client = await this.getClient(rpc); + this.browserSession?.setNextLabel(`Finalize ${txId}`); this.startSpinner(`Finalizing transaction ${txId}...`); try { @@ -19,16 +30,28 @@ export class FinalizeAction extends BaseAction { this.succeedSpinner("Transaction finalized", {txId, evmTransactionHash: evmHash}); } catch (error) { this.failSpinner("Error finalizing transaction", error); + } finally { + await this.closeBrowserSession(); } } - async finalizeBatch({txIds, rpc}: {txIds: TransactionHash[]; rpc?: string}): Promise { + async finalizeBatch({ + txIds, + rpc, + wallet, + }: { + txIds: TransactionHash[]; + rpc?: string; + wallet?: "keystore" | "browser"; + }): Promise { if (txIds.length === 0) { this.failSpinner("At least one txId is required."); return; } + if (this.isBrowserWallet({wallet})) this.walletModeOverride = "browser"; const client = await this.getClient(rpc); + this.browserSession?.setNextLabel(`Finalize ${txIds.length} idle transaction(s)`); this.startSpinner(`Finalizing ${txIds.length} idle transaction(s)...`); try { @@ -40,6 +63,8 @@ export class FinalizeAction extends BaseAction { }); } catch (error) { this.failSpinner("Error finalizing idle transactions", error); + } finally { + await this.closeBrowserSession(); } } } diff --git a/src/commands/transactions/index.ts b/src/commands/transactions/index.ts index 58269fa4..ef08d3de 100644 --- a/src/commands/transactions/index.ts +++ b/src/commands/transactions/index.ts @@ -4,6 +4,7 @@ import {ReceiptAction, ReceiptOptions} from "./receipt"; import {AppealAction, AppealOptions, AppealBondOptions} from "./appeal"; import {TraceAction, TraceOptions} from "./trace"; import {FinalizeAction, FinalizeOptions} from "./finalize"; +import {addWalletModeOption} from "../../lib/wallet/walletOption"; function parseIntOption(value: string, fallback: number): number { const parsed = parseInt(value, 10); @@ -28,15 +29,16 @@ export function initializeTransactionsCommands(program: Command) { await receiptAction.receipt({txId, ...options}); }) - program - .command("appeal ") - .description("Appeal a transaction by its hash") - .option("--bond ", "Appeal bond amount (e.g. 500gen, 0.5gen). Auto-calculated if omitted") - .option("--rpc ", "RPC URL for the network") - .action(async (txId: TransactionHash, options: AppealOptions) => { - const appealAction = new AppealAction(); - await appealAction.appeal({txId, ...options}); - }); + addWalletModeOption( + program + .command("appeal ") + .description("Appeal a transaction by its hash") + .option("--bond ", "Appeal bond amount (e.g. 500gen, 0.5gen). Auto-calculated if omitted") + .option("--rpc ", "RPC URL for the network"), + ).action(async (txId: TransactionHash, options: AppealOptions) => { + const appealAction = new AppealAction(); + await appealAction.appeal({txId, ...options}); + }); program .command("appeal-bond ") @@ -57,23 +59,25 @@ export function initializeTransactionsCommands(program: Command) { await traceAction.trace({txId, ...options}); }); - program - .command("finalize ") - .description("Finalize a transaction that is ready to be finalized (public call)") - .option("--rpc ", "RPC URL for the network") - .action(async (txId: TransactionHash, options: FinalizeOptions) => { - const finalizeAction = new FinalizeAction(); - await finalizeAction.finalize({txId, ...options}); - }); + addWalletModeOption( + program + .command("finalize ") + .description("Finalize a transaction that is ready to be finalized (public call)") + .option("--rpc ", "RPC URL for the network"), + ).action(async (txId: TransactionHash, options: FinalizeOptions) => { + const finalizeAction = new FinalizeAction(); + await finalizeAction.finalize({txId, ...options}); + }); - program - .command("finalize-batch ") - .description("Finalize a batch of idle transactions in a single call (public call)") - .option("--rpc ", "RPC URL for the network") - .action(async (txIds: TransactionHash[], options: FinalizeOptions) => { - const finalizeAction = new FinalizeAction(); - await finalizeAction.finalizeBatch({txIds, ...options}); - }); + addWalletModeOption( + program + .command("finalize-batch ") + .description("Finalize a batch of idle transactions in a single call (public call)") + .option("--rpc ", "RPC URL for the network"), + ).action(async (txIds: TransactionHash[], options: FinalizeOptions) => { + const finalizeAction = new FinalizeAction(); + await finalizeAction.finalizeBatch({txIds, ...options}); + }); return program; } diff --git a/src/commands/vesting/VestingAction.ts b/src/commands/vesting/VestingAction.ts index 8e1627d0..ef1c504b 100644 --- a/src/commands/vesting/VestingAction.ts +++ b/src/commands/vesting/VestingAction.ts @@ -15,6 +15,10 @@ export interface VestingConfig { vesting?: string; factory?: string; addressManager?: string; + /** Signing mode (from --wallet). "browser" routes through the MetaMask bridge. */ + wallet?: "keystore" | "browser"; + /** Deprecated alias for the validator-wallet positional arg (from --validator-wallet). */ + validatorWallet?: string; } export class VestingAction extends BaseAction { @@ -135,12 +139,26 @@ export class VestingAction extends BaseAction { return Object.keys(lookup).length > 0 ? lookup : undefined; } + /** + * Open (or reuse) a vesting browser-wallet session. Delegates to the shared + * BaseAction seam; validates flags (--password conflict; --account conflicts). + * The beneficiary is the connected wallet address (see resolveBeneficiaryVesting). + */ + protected async getVestingBrowserSession(options: VestingConfig) { + this.assertWalletFlags(options, {accountFlagExists: true, context: "vesting"}); + return this.getBrowserSession({network: options.network, rpc: options.rpc}); + } + protected async resolveBeneficiaryVesting(client: VestingClient, options: VestingConfig): Promise
{ if (options.vesting) { return options.vesting as Address; } - const beneficiary = await this.getSignerAddress(); + // In browser mode the beneficiary is the connected wallet address; the + // lookup itself runs on the account-less read-only client. + const beneficiary = this.browserSession + ? this.browserSession.signerAddress + : await this.getSignerAddress(); const vestings = await client.getBeneficiaryVestings(beneficiary, this.getFactoryLookupOptions(options)); if (vestings.length === 0) { diff --git a/src/commands/vesting/claim.ts b/src/commands/vesting/claim.ts index 13481e81..3d27dc7f 100644 --- a/src/commands/vesting/claim.ts +++ b/src/commands/vesting/claim.ts @@ -1,5 +1,7 @@ import {VestingAction, VestingConfig} from "./VestingAction"; import type {Address} from "genlayer-js/types"; +import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface VestingClaimOptions extends VestingConfig { validator: string; @@ -11,6 +13,10 @@ export class VestingClaimAction extends VestingAction { } async execute(options: VestingClaimOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Claiming vesting delegation withdrawal..."); try { @@ -37,4 +43,41 @@ export class VestingClaimAction extends VestingAction { this.failSpinner("Failed to claim vesting withdrawal", error.message || error); } } + + private async executeWithBrowserWallet(options: VestingClaimOptions): Promise { + let session; + try { + session = await this.getVestingBrowserSession(options); + } catch (error: any) { + this.failSpinner("Failed to claim vesting withdrawal", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + const readClient = await this.getReadOnlyVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(readClient, options); + + const {to, data} = buildTx(abi.VESTING_ABI as any, vesting, "vestingDelegatorClaim", [options.validator]); + + const receipt = await session.sendTransaction({ + to, + data, + label: "Claim vesting delegation withdrawal", + }); + + this.succeedSpinner("Vesting claim successful!", { + transactionHash: receipt.transactionHash, + vesting, + validator: options.validator, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to claim vesting withdrawal", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/vesting/delegate.ts b/src/commands/vesting/delegate.ts index e6c24720..7807e3d1 100644 --- a/src/commands/vesting/delegate.ts +++ b/src/commands/vesting/delegate.ts @@ -1,5 +1,7 @@ import {VestingAction, VestingConfig} from "./VestingAction"; import type {Address} from "genlayer-js/types"; +import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface VestingDelegateOptions extends VestingConfig { validator: string; @@ -12,6 +14,10 @@ export class VestingDelegateAction extends VestingAction { } async execute(options: VestingDelegateOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Delegating vesting tokens..."); try { @@ -42,4 +48,47 @@ export class VestingDelegateAction extends VestingAction { this.failSpinner("Failed to delegate vesting tokens", error.message || error); } } + + private async executeWithBrowserWallet(options: VestingDelegateOptions): Promise { + let session; + try { + session = await this.getVestingBrowserSession(options); + } catch (error: any) { + this.failSpinner("Failed to delegate vesting tokens", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + const readClient = await this.getReadOnlyVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(readClient, options); + const amount = this.parseAmount(options.amount); + + const {to, data} = buildTx(abi.VESTING_ABI as any, vesting, "vestingDelegatorJoin", [ + options.validator, + amount, + ]); + + const receipt = await session.sendTransaction({ + to, + data, + label: `Delegate ${this.formatAmount(amount)} to validator`, + }); + + this.succeedSpinner("Vesting delegation successful!", { + transactionHash: receipt.transactionHash, + vesting, + validator: options.validator, + beneficiary: session.signerAddress, + amount: this.formatAmount(amount), + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to delegate vesting tokens", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/vesting/index.ts b/src/commands/vesting/index.ts index c73ef39f..19b24c9b 100644 --- a/src/commands/vesting/index.ts +++ b/src/commands/vesting/index.ts @@ -16,6 +16,7 @@ import { } from "./validatorOperatorTransfer"; import {VestingValidatorSetIdentityAction, VestingValidatorSetIdentityOptions} from "./validatorSetIdentity"; import {VestingValidatorListAction, VestingValidatorListOptions} from "./validatorList"; +import {addWalletModeOption} from "../../lib/wallet/walletOption"; function addReadOptions(command: Command): Command { return command @@ -27,14 +28,16 @@ function addReadOptions(command: Command): Command { } function addWriteOptions(command: Command): Command { - return command - .option("--vesting
", "Vesting contract address (overrides beneficiary lookup)") - .option("--account ", "Account to use") - .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "built-in or custom network alias (see: genlayer network list)") - .option("--rpc ", "RPC URL for the network") - .option("--factory
", "VestingFactory address (overrides AddressManager lookup)") - .option("--address-manager
", "AddressManager address (overrides consensus lookup)"); + return addWalletModeOption( + command + .option("--vesting
", "Vesting contract address (overrides beneficiary lookup)") + .option("--account ", "Account to use") + .option("--password ", "Password to unlock account (skips interactive prompt)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") + .option("--rpc ", "RPC URL for the network") + .option("--factory
", "VestingFactory address (overrides AddressManager lookup)") + .option("--address-manager
", "AddressManager address (overrides consensus lookup)"), + ); } function addValidatorReadOptions(command: Command): Command { @@ -45,12 +48,15 @@ function addValidatorReadOptions(command: Command): Command { ); } -function addWalletOption(command: Command): Command { - return command.option("--wallet
", "Validator wallet address (deprecated, use positional arg)"); +function addValidatorWalletOption(command: Command): Command { + return command.option( + "--validator-wallet
", + "Validator wallet address (deprecated, use positional arg)", + ); } -function requireWallet(walletArg: string | undefined, options: {wallet?: string}): string { - const wallet = walletArg || options.wallet; +function requireWallet(walletArg: string | undefined, options: {validatorWallet?: string}): string { + const wallet = walletArg || options.validatorWallet; if (!wallet) { console.error("Error: validator wallet address is required"); process.exit(1); @@ -156,7 +162,7 @@ export function initializeVestingCommands(program: Command) { addCreateCommand("join"); addWriteOptions( - addWalletOption( + addValidatorWalletOption( validator .command("deposit [wallet]") .description("Deposit more vesting-held tokens to a validator wallet") @@ -165,11 +171,11 @@ export function initializeVestingCommands(program: Command) { ).action(async (walletArg: string | undefined, options: VestingValidatorDepositOptions) => { const wallet = requireWallet(walletArg, options); const action = new VestingValidatorDepositAction(); - await action.execute({...options, wallet}); + await action.execute({...options, walletAddress: wallet}); }); addWriteOptions( - addWalletOption( + addValidatorWalletOption( validator .command("exit [wallet]") .description("Exit vesting validator self-stake by withdrawing shares") @@ -178,11 +184,11 @@ export function initializeVestingCommands(program: Command) { ).action(async (walletArg: string | undefined, options: VestingValidatorExitOptions) => { const wallet = requireWallet(walletArg, options); const action = new VestingValidatorExitAction(); - await action.execute({...options, wallet}); + await action.execute({...options, walletAddress: wallet}); }); addWriteOptions( - addWalletOption( + addValidatorWalletOption( validator .command("claim [wallet]") .description("Claim vesting validator withdrawals after unbonding period"), @@ -190,13 +196,13 @@ export function initializeVestingCommands(program: Command) { ).action(async (walletArg: string | undefined, options: VestingValidatorClaimOptions) => { const wallet = requireWallet(walletArg, options); const action = new VestingValidatorClaimAction(); - await action.execute({...options, wallet}); + await action.execute({...options, walletAddress: wallet}); }); const operatorTransfer = validator.command("operator-transfer").description("Manage vesting validator operator transfers"); addWriteOptions( - addWalletOption( + addValidatorWalletOption( operatorTransfer .command("initiate [wallet] [newOperator]") .description("Initiate a vesting validator operator transfer") @@ -210,11 +216,11 @@ export function initializeVestingCommands(program: Command) { process.exit(1); } const action = new VestingValidatorInitiateOperatorTransferAction(); - await action.execute({...options, wallet, newOperator}); + await action.execute({...options, walletAddress: wallet, newOperator}); }); addWriteOptions( - addWalletOption( + addValidatorWalletOption( operatorTransfer .command("complete [wallet]") .description("Complete a vesting validator operator transfer"), @@ -222,11 +228,11 @@ export function initializeVestingCommands(program: Command) { ).action(async (walletArg: string | undefined, options: VestingValidatorOperatorTransferOptions) => { const wallet = requireWallet(walletArg, options); const action = new VestingValidatorCompleteOperatorTransferAction(); - await action.execute({...options, wallet}); + await action.execute({...options, walletAddress: wallet}); }); addWriteOptions( - addWalletOption( + addValidatorWalletOption( operatorTransfer .command("cancel [wallet]") .description("Cancel a vesting validator operator transfer"), @@ -234,11 +240,11 @@ export function initializeVestingCommands(program: Command) { ).action(async (walletArg: string | undefined, options: VestingValidatorOperatorTransferOptions) => { const wallet = requireWallet(walletArg, options); const action = new VestingValidatorCancelOperatorTransferAction(); - await action.execute({...options, wallet}); + await action.execute({...options, walletAddress: wallet}); }); addWriteOptions( - addWalletOption( + addValidatorWalletOption( validator .command("set-identity [wallet]") .description("Set vesting validator identity metadata") @@ -255,7 +261,7 @@ export function initializeVestingCommands(program: Command) { ).action(async (walletArg: string | undefined, options: VestingValidatorSetIdentityOptions) => { const wallet = requireWallet(walletArg, options); const action = new VestingValidatorSetIdentityAction(); - await action.execute({...options, wallet}); + await action.execute({...options, walletAddress: wallet}); }); addValidatorReadOptions( diff --git a/src/commands/vesting/undelegate.ts b/src/commands/vesting/undelegate.ts index bc232142..93f4557e 100644 --- a/src/commands/vesting/undelegate.ts +++ b/src/commands/vesting/undelegate.ts @@ -1,5 +1,7 @@ import {VestingAction, VestingConfig} from "./VestingAction"; import type {Address} from "genlayer-js/types"; +import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface VestingUndelegateOptions extends VestingConfig { validator: string; @@ -11,6 +13,10 @@ export class VestingUndelegateAction extends VestingAction { } async execute(options: VestingUndelegateOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Initiating vesting undelegation..."); try { @@ -50,4 +56,55 @@ export class VestingUndelegateAction extends VestingAction { this.failSpinner("Failed to undelegate vesting tokens", error.message || error); } } + + private async executeWithBrowserWallet(options: VestingUndelegateOptions): Promise { + let session; + try { + session = await this.getVestingBrowserSession(options); + } catch (error: any) { + this.failSpinner("Failed to undelegate vesting tokens", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + const readClient = await this.getReadOnlyVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(readClient, options); + + const stakeInfo = await readClient.getStakeInfo(vesting, options.validator as Address); + const shares = stakeInfo.shares; + + if (shares <= 0n) { + this.failSpinner(`No delegation shares found for vesting ${vesting} with validator ${options.validator}.`); + return; + } + + const {to, data} = buildTx(abi.VESTING_ABI as any, vesting, "vestingDelegatorExit", [ + options.validator, + shares, + ]); + + const receipt = await session.sendTransaction({ + to, + data, + label: `Undelegate ${shares.toString()} shares from validator`, + }); + + this.succeedSpinner("Vesting undelegation initiated!", { + transactionHash: receipt.transactionHash, + vesting, + validator: options.validator, + sharesWithdrawn: shares.toString(), + stake: stakeInfo.stake, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + note: "Withdrawal will be claimable after the unbonding period", + }); + } catch (error: any) { + this.failSpinner("Failed to undelegate vesting tokens", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/vesting/validatorClaim.ts b/src/commands/vesting/validatorClaim.ts index c6d33490..2b806323 100644 --- a/src/commands/vesting/validatorClaim.ts +++ b/src/commands/vesting/validatorClaim.ts @@ -1,8 +1,10 @@ import {VestingAction, VestingConfig} from "./VestingAction"; import type {Address} from "genlayer-js/types"; +import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface VestingValidatorClaimOptions extends VestingConfig { - wallet: string; + walletAddress: string; } export class VestingValidatorClaimAction extends VestingAction { @@ -11,23 +13,27 @@ export class VestingValidatorClaimAction extends VestingAction { } async execute(options: VestingValidatorClaimOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Claiming vesting validator withdrawal..."); try { const client = await this.getVestingClient(options); const vesting = await this.resolveBeneficiaryVesting(client, options); - this.setSpinnerText(`Claiming vesting validator withdrawal from wallet ${options.wallet}...`); + this.setSpinnerText(`Claiming vesting validator withdrawal from wallet ${options.walletAddress}...`); const result = await client.vestingValidatorClaim({ vesting, - wallet: options.wallet as Address, + wallet: options.walletAddress as Address, }); const output = { transactionHash: result.transactionHash, vesting, - wallet: options.wallet, + wallet: options.walletAddress, blockNumber: result.blockNumber.toString(), gasUsed: result.gasUsed.toString(), }; @@ -37,4 +43,43 @@ export class VestingValidatorClaimAction extends VestingAction { this.failSpinner("Failed to claim vesting validator withdrawal", error.message || error); } } + + private async executeWithBrowserWallet(options: VestingValidatorClaimOptions): Promise { + let session; + try { + session = await this.getVestingBrowserSession(options); + } catch (error: any) { + this.failSpinner("Failed to claim vesting validator withdrawal", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + const readClient = await this.getReadOnlyVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(readClient, options); + + const {to, data} = buildTx(abi.VESTING_ABI as any, vesting, "vestingValidatorClaim", [ + options.walletAddress, + ]); + + const receipt = await session.sendTransaction({ + to, + data, + label: "Claim vesting validator withdrawal", + }); + + this.succeedSpinner("Vesting validator claim successful!", { + transactionHash: receipt.transactionHash, + vesting, + wallet: options.walletAddress, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to claim vesting validator withdrawal", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/vesting/validatorCreate.ts b/src/commands/vesting/validatorCreate.ts index 0b76aaf0..c23fdc71 100644 --- a/src/commands/vesting/validatorCreate.ts +++ b/src/commands/vesting/validatorCreate.ts @@ -1,5 +1,7 @@ import {VestingAction, VestingConfig} from "./VestingAction"; import type {Address} from "genlayer-js/types"; +import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface VestingValidatorCreateOptions extends VestingConfig { operator: string; @@ -12,6 +14,10 @@ export class VestingValidatorCreateAction extends VestingAction { } async execute(options: VestingValidatorCreateOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Creating vesting-backed validator..."); try { @@ -54,4 +60,57 @@ export class VestingValidatorCreateAction extends VestingAction { this.failSpinner("Failed to create vesting-backed validator", error.message || error); } } + + private async executeWithBrowserWallet(options: VestingValidatorCreateOptions): Promise { + let session; + try { + session = await this.getVestingBrowserSession(options); + } catch (error: any) { + this.failSpinner("Failed to create vesting-backed validator", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + const readClient = await this.getReadOnlyVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(readClient, options); + const amount = this.parseAmount(options.amount); + + const {to, data} = buildTx(abi.VESTING_ABI as any, vesting, "vestingValidatorJoin", [ + options.operator, + amount, + ]); + + const receipt = await session.sendTransaction({ + to, + data, + label: "Create vesting validator", + }); + + // The join receipt does not carry the wallet address; the vesting + // contract tracks its wallets, so the newest entry is the one created. + let validatorWallet; + try { + const wallets = await readClient.getValidatorWallets(vesting); + validatorWallet = wallets[wallets.length - 1]; + } catch { + validatorWallet = "(read getValidatorWallets to inspect)"; + } + + this.succeedSpinner("Vesting-backed validator created!", { + transactionHash: receipt.transactionHash, + vesting, + validatorWallet, + operator: options.operator, + amount: this.formatAmount(amount), + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to create vesting-backed validator", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/vesting/validatorDeposit.ts b/src/commands/vesting/validatorDeposit.ts index f6a0b802..5f4cedc0 100644 --- a/src/commands/vesting/validatorDeposit.ts +++ b/src/commands/vesting/validatorDeposit.ts @@ -1,8 +1,10 @@ import {VestingAction, VestingConfig} from "./VestingAction"; import type {Address} from "genlayer-js/types"; +import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface VestingValidatorDepositOptions extends VestingConfig { - wallet: string; + walletAddress: string; amount: string; } @@ -12,6 +14,10 @@ export class VestingValidatorDepositAction extends VestingAction { } async execute(options: VestingValidatorDepositOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Depositing vesting tokens to validator..."); try { @@ -19,18 +25,18 @@ export class VestingValidatorDepositAction extends VestingAction { const amount = this.parseAmount(options.amount); const vesting = await this.resolveBeneficiaryVesting(client, options); - this.setSpinnerText(`Depositing ${this.formatAmount(amount)} from vesting ${vesting} to wallet ${options.wallet}...`); + this.setSpinnerText(`Depositing ${this.formatAmount(amount)} from vesting ${vesting} to wallet ${options.walletAddress}...`); const result = await client.vestingValidatorDeposit({ vesting, - wallet: options.wallet as Address, + wallet: options.walletAddress as Address, amount, }); const output = { transactionHash: result.transactionHash, vesting, - wallet: options.wallet, + wallet: options.walletAddress, amount: this.formatAmount(amount), blockNumber: result.blockNumber.toString(), gasUsed: result.gasUsed.toString(), @@ -41,4 +47,46 @@ export class VestingValidatorDepositAction extends VestingAction { this.failSpinner("Failed to deposit vesting validator tokens", error.message || error); } } + + private async executeWithBrowserWallet(options: VestingValidatorDepositOptions): Promise { + let session; + try { + session = await this.getVestingBrowserSession(options); + } catch (error: any) { + this.failSpinner("Failed to deposit vesting validator tokens", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + const readClient = await this.getReadOnlyVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(readClient, options); + const amount = this.parseAmount(options.amount); + + const {to, data} = buildTx(abi.VESTING_ABI as any, vesting, "vestingValidatorDeposit", [ + options.walletAddress, + amount, + ]); + + const receipt = await session.sendTransaction({ + to, + data, + label: `Deposit ${this.formatAmount(amount)} to validator wallet`, + }); + + this.succeedSpinner("Vesting validator deposit successful!", { + transactionHash: receipt.transactionHash, + vesting, + wallet: options.walletAddress, + amount: this.formatAmount(amount), + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to deposit vesting validator tokens", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/vesting/validatorExit.ts b/src/commands/vesting/validatorExit.ts index 18e53363..7c2005d5 100644 --- a/src/commands/vesting/validatorExit.ts +++ b/src/commands/vesting/validatorExit.ts @@ -1,8 +1,10 @@ import {VestingAction, VestingConfig} from "./VestingAction"; import type {Address} from "genlayer-js/types"; +import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface VestingValidatorExitOptions extends VestingConfig { - wallet: string; + walletAddress: string; shares: string; } @@ -12,6 +14,10 @@ export class VestingValidatorExitAction extends VestingAction { } async execute(options: VestingValidatorExitOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Initiating vesting validator exit..."); try { @@ -27,18 +33,18 @@ export class VestingValidatorExitAction extends VestingAction { const client = await this.getVestingClient(options); const vesting = await this.resolveBeneficiaryVesting(client, options); - this.setSpinnerText(`Exiting ${shares.toString()} validator shares from wallet ${options.wallet}...`); + this.setSpinnerText(`Exiting ${shares.toString()} validator shares from wallet ${options.walletAddress}...`); const result = await client.vestingValidatorExit({ vesting, - wallet: options.wallet as Address, + wallet: options.walletAddress as Address, shares, }); const output = { transactionHash: result.transactionHash, vesting, - wallet: options.wallet, + wallet: options.walletAddress, sharesWithdrawn: shares.toString(), blockNumber: result.blockNumber.toString(), gasUsed: result.gasUsed.toString(), @@ -50,4 +56,55 @@ export class VestingValidatorExitAction extends VestingAction { this.failSpinner("Failed to exit vesting validator", error.message || error); } } + + private async executeWithBrowserWallet(options: VestingValidatorExitOptions): Promise { + let session; + try { + session = await this.getVestingBrowserSession(options); + } catch (error: any) { + this.failSpinner("Failed to exit vesting validator", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + let shares: bigint; + try { + shares = BigInt(options.shares); + if (shares <= 0n) throw new Error("must be positive"); + } catch { + this.failSpinner(`Invalid shares value: "${options.shares}". Must be a positive whole number.`); + return; + } + + const readClient = await this.getReadOnlyVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(readClient, options); + + const {to, data} = buildTx(abi.VESTING_ABI as any, vesting, "vestingValidatorExit", [ + options.walletAddress, + shares, + ]); + + const receipt = await session.sendTransaction({ + to, + data, + label: `Exit ${shares.toString()} validator shares`, + }); + + this.succeedSpinner("Vesting validator exit initiated!", { + transactionHash: receipt.transactionHash, + vesting, + wallet: options.walletAddress, + sharesWithdrawn: shares.toString(), + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + note: "Withdrawal will be claimable after the unbonding period unless settled immediately in epoch 0", + }); + } catch (error: any) { + this.failSpinner("Failed to exit vesting validator", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/vesting/validatorOperatorTransfer.ts b/src/commands/vesting/validatorOperatorTransfer.ts index 51b026e4..607fcb82 100644 --- a/src/commands/vesting/validatorOperatorTransfer.ts +++ b/src/commands/vesting/validatorOperatorTransfer.ts @@ -1,8 +1,10 @@ import {VestingAction, VestingConfig} from "./VestingAction"; import type {Address} from "genlayer-js/types"; +import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface VestingValidatorOperatorTransferOptions extends VestingConfig { - wallet: string; + walletAddress: string; newOperator?: string; } @@ -12,6 +14,10 @@ export class VestingValidatorInitiateOperatorTransferAction extends VestingActio } async execute(options: VestingValidatorOperatorTransferOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Initiating vesting validator operator transfer..."); try { @@ -23,18 +29,18 @@ export class VestingValidatorInitiateOperatorTransferAction extends VestingActio const client = await this.getVestingClient(options); const vesting = await this.resolveBeneficiaryVesting(client, options); - this.setSpinnerText(`Initiating operator transfer for wallet ${options.wallet} to ${options.newOperator}...`); + this.setSpinnerText(`Initiating operator transfer for wallet ${options.walletAddress} to ${options.newOperator}...`); const result = await client.vestingValidatorInitiateOperatorTransfer({ vesting, - wallet: options.wallet as Address, + wallet: options.walletAddress as Address, newOperator: options.newOperator as Address, }); const output = { transactionHash: result.transactionHash, vesting, - wallet: options.wallet, + wallet: options.walletAddress, newOperator: options.newOperator, blockNumber: result.blockNumber.toString(), gasUsed: result.gasUsed.toString(), @@ -45,6 +51,52 @@ export class VestingValidatorInitiateOperatorTransferAction extends VestingActio this.failSpinner("Failed to initiate vesting validator operator transfer", error.message || error); } } + + private async executeWithBrowserWallet(options: VestingValidatorOperatorTransferOptions): Promise { + if (!options.newOperator) { + this.failSpinner("New operator address is required."); + return; + } + + let session; + try { + session = await this.getVestingBrowserSession(options); + } catch (error: any) { + this.failSpinner("Failed to initiate vesting validator operator transfer", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + const readClient = await this.getReadOnlyVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(readClient, options); + + const {to, data} = buildTx(abi.VESTING_ABI as any, vesting, "vestingValidatorInitiateOperatorTransfer", [ + options.walletAddress, + options.newOperator, + ]); + + const receipt = await session.sendTransaction({ + to, + data, + label: "Initiate validator operator transfer", + }); + + this.succeedSpinner("Vesting validator operator transfer initiated!", { + transactionHash: receipt.transactionHash, + vesting, + wallet: options.walletAddress, + newOperator: options.newOperator, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to initiate vesting validator operator transfer", error.message || error); + } finally { + await session.close(); + } + } } export class VestingValidatorCompleteOperatorTransferAction extends VestingAction { @@ -53,23 +105,27 @@ export class VestingValidatorCompleteOperatorTransferAction extends VestingActio } async execute(options: VestingValidatorOperatorTransferOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Completing vesting validator operator transfer..."); try { const client = await this.getVestingClient(options); const vesting = await this.resolveBeneficiaryVesting(client, options); - this.setSpinnerText(`Completing operator transfer for wallet ${options.wallet}...`); + this.setSpinnerText(`Completing operator transfer for wallet ${options.walletAddress}...`); const result = await client.vestingValidatorCompleteOperatorTransfer({ vesting, - wallet: options.wallet as Address, + wallet: options.walletAddress as Address, }); const output = { transactionHash: result.transactionHash, vesting, - wallet: options.wallet, + wallet: options.walletAddress, blockNumber: result.blockNumber.toString(), gasUsed: result.gasUsed.toString(), }; @@ -79,6 +135,45 @@ export class VestingValidatorCompleteOperatorTransferAction extends VestingActio this.failSpinner("Failed to complete vesting validator operator transfer", error.message || error); } } + + private async executeWithBrowserWallet(options: VestingValidatorOperatorTransferOptions): Promise { + let session; + try { + session = await this.getVestingBrowserSession(options); + } catch (error: any) { + this.failSpinner("Failed to complete vesting validator operator transfer", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + const readClient = await this.getReadOnlyVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(readClient, options); + + const {to, data} = buildTx(abi.VESTING_ABI as any, vesting, "vestingValidatorCompleteOperatorTransfer", [ + options.walletAddress, + ]); + + const receipt = await session.sendTransaction({ + to, + data, + label: "Complete validator operator transfer", + }); + + this.succeedSpinner("Vesting validator operator transfer completed!", { + transactionHash: receipt.transactionHash, + vesting, + wallet: options.walletAddress, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to complete vesting validator operator transfer", error.message || error); + } finally { + await session.close(); + } + } } export class VestingValidatorCancelOperatorTransferAction extends VestingAction { @@ -87,23 +182,27 @@ export class VestingValidatorCancelOperatorTransferAction extends VestingAction } async execute(options: VestingValidatorOperatorTransferOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Cancelling vesting validator operator transfer..."); try { const client = await this.getVestingClient(options); const vesting = await this.resolveBeneficiaryVesting(client, options); - this.setSpinnerText(`Cancelling operator transfer for wallet ${options.wallet}...`); + this.setSpinnerText(`Cancelling operator transfer for wallet ${options.walletAddress}...`); const result = await client.vestingValidatorCancelOperatorTransfer({ vesting, - wallet: options.wallet as Address, + wallet: options.walletAddress as Address, }); const output = { transactionHash: result.transactionHash, vesting, - wallet: options.wallet, + wallet: options.walletAddress, blockNumber: result.blockNumber.toString(), gasUsed: result.gasUsed.toString(), }; @@ -113,4 +212,43 @@ export class VestingValidatorCancelOperatorTransferAction extends VestingAction this.failSpinner("Failed to cancel vesting validator operator transfer", error.message || error); } } + + private async executeWithBrowserWallet(options: VestingValidatorOperatorTransferOptions): Promise { + let session; + try { + session = await this.getVestingBrowserSession(options); + } catch (error: any) { + this.failSpinner("Failed to cancel vesting validator operator transfer", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + const readClient = await this.getReadOnlyVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(readClient, options); + + const {to, data} = buildTx(abi.VESTING_ABI as any, vesting, "vestingValidatorCancelOperatorTransfer", [ + options.walletAddress, + ]); + + const receipt = await session.sendTransaction({ + to, + data, + label: "Cancel validator operator transfer", + }); + + this.succeedSpinner("Vesting validator operator transfer cancelled!", { + transactionHash: receipt.transactionHash, + vesting, + wallet: options.walletAddress, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to cancel vesting validator operator transfer", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/vesting/validatorSetIdentity.ts b/src/commands/vesting/validatorSetIdentity.ts index 368e7e5b..13bb7a40 100644 --- a/src/commands/vesting/validatorSetIdentity.ts +++ b/src/commands/vesting/validatorSetIdentity.ts @@ -1,9 +1,11 @@ import {VestingAction, VestingConfig} from "./VestingAction"; import type {Address} from "genlayer-js/types"; import {toHex} from "viem"; +import {abi} from "genlayer-js"; +import {buildTx, encodeExtraCid} from "../../lib/wallet/txBuilders"; export interface VestingValidatorSetIdentityOptions extends VestingConfig { - wallet: string; + walletAddress: string; moniker?: string; logoUri?: string; website?: string; @@ -21,6 +23,10 @@ export class VestingValidatorSetIdentityAction extends VestingAction { } async execute(options: VestingValidatorSetIdentityOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Setting vesting validator identity..."); try { @@ -28,11 +34,11 @@ export class VestingValidatorSetIdentityAction extends VestingAction { const vesting = await this.resolveBeneficiaryVesting(client, options); const extraCid = options.extraCid ? toHex(new TextEncoder().encode(options.extraCid)) : "0x"; - this.setSpinnerText(`Setting identity for vesting validator wallet ${options.wallet}...`); + this.setSpinnerText(`Setting identity for vesting validator wallet ${options.walletAddress}...`); const result = await client.vestingValidatorSetIdentity({ vesting, - wallet: options.wallet as Address, + wallet: options.walletAddress as Address, moniker: options.moniker || "", logoUri: options.logoUri || "", website: options.website || "", @@ -47,7 +53,7 @@ export class VestingValidatorSetIdentityAction extends VestingAction { const output: Record = { transactionHash: result.transactionHash, vesting, - wallet: options.wallet, + wallet: options.walletAddress, blockNumber: result.blockNumber.toString(), gasUsed: result.gasUsed.toString(), }; @@ -67,4 +73,64 @@ export class VestingValidatorSetIdentityAction extends VestingAction { this.failSpinner("Failed to set vesting validator identity", error.message || error); } } + + private async executeWithBrowserWallet(options: VestingValidatorSetIdentityOptions): Promise { + let session; + try { + session = await this.getVestingBrowserSession(options); + } catch (error: any) { + this.failSpinner("Failed to set vesting validator identity", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + const readClient = await this.getReadOnlyVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(readClient, options); + + const {to, data} = buildTx(abi.VESTING_ABI as any, vesting, "vestingValidatorSetIdentity", [ + options.walletAddress, + options.moniker || "", + options.logoUri || "", + options.website || "", + options.description || "", + options.email || "", + options.twitter || "", + options.telegram || "", + options.github || "", + encodeExtraCid(options.extraCid), + ]); + + const receipt = await session.sendTransaction({ + to, + data, + label: "Set vesting validator identity", + }); + + const output: Record = { + transactionHash: receipt.transactionHash, + vesting, + wallet: options.walletAddress, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + }; + + if (options.moniker) output.moniker = options.moniker; + if (options.logoUri) output.logoUri = options.logoUri; + if (options.website) output.website = options.website; + if (options.description) output.description = options.description; + if (options.email) output.email = options.email; + if (options.twitter) output.twitter = options.twitter; + if (options.telegram) output.telegram = options.telegram; + if (options.github) output.github = options.github; + if (options.extraCid) output.extraCid = options.extraCid; + + this.succeedSpinner("Vesting validator identity set!", output); + } catch (error: any) { + this.failSpinner("Failed to set vesting validator identity", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/vesting/withdraw.ts b/src/commands/vesting/withdraw.ts index 024d58e3..0412c938 100644 --- a/src/commands/vesting/withdraw.ts +++ b/src/commands/vesting/withdraw.ts @@ -1,4 +1,6 @@ import {VestingAction, VestingConfig} from "./VestingAction"; +import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface VestingWithdrawOptions extends VestingConfig { amount: string; @@ -10,6 +12,10 @@ export class VestingWithdrawAction extends VestingAction { } async execute(options: VestingWithdrawOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Withdrawing vested tokens..."); try { @@ -38,4 +44,43 @@ export class VestingWithdrawAction extends VestingAction { this.failSpinner("Failed to withdraw vested tokens", error.message || error); } } + + private async executeWithBrowserWallet(options: VestingWithdrawOptions): Promise { + let session; + try { + session = await this.getVestingBrowserSession(options); + } catch (error: any) { + this.failSpinner("Failed to withdraw vested tokens", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + const readClient = await this.getReadOnlyVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(readClient, options); + const amount = this.parseAmount(options.amount); + + const {to, data} = buildTx(abi.VESTING_ABI as any, vesting, "vestingWithdraw", [amount]); + + const receipt = await session.sendTransaction({ + to, + data, + label: `Withdraw ${this.formatAmount(amount)} from vesting`, + }); + + this.succeedSpinner("Vesting withdrawal successful!", { + transactionHash: receipt.transactionHash, + vesting, + beneficiary: session.signerAddress, + amount: this.formatAmount(amount), + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to withdraw vested tokens", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/wallet/WalletAction.ts b/src/commands/wallet/WalletAction.ts new file mode 100644 index 00000000..e66982d6 --- /dev/null +++ b/src/commands/wallet/WalletAction.ts @@ -0,0 +1,190 @@ +import {BaseAction, resolveNetwork} from "../../lib/actions/BaseAction"; +import type {GenLayerChain} from "genlayer-js/types"; +import {WalletSessionClient} from "../../lib/wallet/sessionClient"; +import { + descriptorPath, + readDescriptor, + removeDescriptor, + isPidAlive, +} from "../../lib/wallet/sessionDescriptor"; +import {spawnWalletDaemon, waitForDaemonReady} from "../../lib/wallet/spawnDaemon"; +import {runWalletSessionDaemon} from "../../lib/wallet/sessionDaemon"; +import {DAEMON_LOG_FILENAME, HEARTBEAT_DEAD_MS, CONNECT_TIMEOUT_MS} from "../../lib/wallet/sessionConstants"; + +export interface WalletConnectOptions { + network?: string; + rpc?: string; +} + +export class WalletAction extends BaseAction { + private resolveChain(network?: string): GenLayerChain { + return network + ? {...resolveNetwork(network, this.getCustomNetworks())} + : resolveNetwork(this.getConfig().network, this.getCustomNetworks()); + } + + private networkAlias(network?: string): string { + return network ?? this.getConfig().network ?? "localnet"; + } + + /** `genlayer wallet connect` — start (or reuse) the persistent session. */ + async connect(options: WalletConnectOptions): Promise { + const chain = this.resolveChain(options.network); + const alias = this.networkAlias(options.network); + const dpath = descriptorPath(this); + + const existing = readDescriptor(dpath); + if (existing && isPidAlive(existing.pid)) { + const client = new WalletSessionClient(existing); + if (await client.ping()) { + const state = await client.state().catch(() => null); + if (state && state.chainId === chain.id) { + const tabDead = + state.lastPagePollAt > 0 && Date.now() - state.lastPagePollAt > HEARTBEAT_DEAD_MS; + if (tabDead) { + // Daemon is alive and pinging, but its browser tab is gone (stale + // page heartbeat). Reporting "Already connected" here would strand + // the user — the next sign fails on the dead tab. Tear the stale + // daemon down and start a fresh session so connect can recover. + this.logInfo("Previous wallet tab was closed; starting a fresh session."); + await client.shutdown(); + await this.waitForDescriptorGone(dpath, 5000); + } else { + if (state.connected && state.address) { + this.logSuccess(`Already connected as ${state.address} on ${existing.network}.`); + } else { + this.logInfo( + `A session is starting on ${existing.network}. Approve the connection in your browser.`, + ); + } + return; + } + } else { + // Different chain → explicit switch: shut the old one down first. + this.logInfo( + `Switching wallet session from ${existing.network} (chain ${state?.chainId}) to ${alias} (chain ${chain.id}).`, + ); + await client.shutdown(); + await this.waitForDescriptorGone(dpath, 5000); + } + } else { + removeDescriptor(dpath); + } + } else if (existing) { + removeDescriptor(dpath); + } + + const logPath = this.getFilePath(DAEMON_LOG_FILENAME); + this.startSpinner("Starting wallet session..."); + spawnWalletDaemon({network: options.network, rpc: options.rpc, logPath}); + const ready = await waitForDaemonReady(dpath, {logPath}); + this.stopSpinner(); + + const client = new WalletSessionClient(ready); + const state = await client.state(); + this.logInfo(`Open this URL in a browser with your wallet to connect:\n ${state.url}`); + this.logInfo("(Remote/SSH? Forward the port first: ssh -L :127.0.0.1: ...)"); + + this.startSpinner("Waiting for wallet connection..."); + try { + const address = await client.waitForConnection(CONNECT_TIMEOUT_MS); + this.succeedSpinner( + `Connected as ${address} on ${alias}. This session stays active; run 'genlayer wallet disconnect' to end it.`, + ); + } catch (err) { + this.failSpinner((err as Error)?.message || "Wallet did not connect."); + } + } + + /** `genlayer wallet status` — report the current session. */ + async status(): Promise { + const dpath = descriptorPath(this); + const d = readDescriptor(dpath); + if (!d) { + this.logInfo("No active wallet session."); + process.exitCode = 1; + return; + } + const client = new WalletSessionClient(d); + const alive = isPidAlive(d.pid) && (await client.ping()); + if (!alive) { + this.logWarning("Wallet session descriptor is stale (daemon not reachable). Cleaning it up."); + removeDescriptor(dpath); + process.exitCode = 1; + return; + } + const state = await client.state(); + const now = Date.now(); + const ageMin = Math.round((now - state.createdAt) / 60_000); + const idleMin = Math.round((now - d.lastUsed) / 60_000); + const heartbeatFresh = state.lastPagePollAt > 0 ? now - state.lastPagePollAt <= HEARTBEAT_DEAD_MS : true; + + this.log("Wallet session:", { + status: state.connected ? "connected" : "connecting", + address: state.address ?? "(not connected)", + network: d.network, + chainId: state.chainId, + port: d.port, + url: state.url, + ageMinutes: ageMin, + idleMinutes: idleMin, + tabHeartbeat: heartbeatFresh ? "fresh" : "stale (tab may be closed)", + queuedTransactions: state.queuedCount, + }); + process.exitCode = state.connected ? 0 : 1; + } + + /** `genlayer wallet disconnect` — shut the session down. */ + async disconnect(): Promise { + const dpath = descriptorPath(this); + const d = readDescriptor(dpath); + if (!d) { + this.logInfo("No active wallet session."); + return; + } + const client = new WalletSessionClient(d); + await client.shutdown(); + + // Wait briefly for the daemon to exit; otherwise SIGTERM it. + const gone = await this.waitForPidGone(d.pid, 5000); + if (!gone && isPidAlive(d.pid)) { + try { + process.kill(d.pid, "SIGTERM"); + } catch { + // Already gone / not ours. + } + } + removeDescriptor(dpath); + this.logSuccess("Disconnected."); + } + + /** Hidden entry point for the detached daemon process. */ + async daemon(options: WalletConnectOptions): Promise { + await runWalletSessionDaemon({ + network: options.network, + rpc: options.rpc, + configManager: this, + log: (msg: string) => { + // Daemon logs go to the redirected stdout (wallet-daemon.log). + console.log(`[${new Date().toISOString()}] ${msg}`); + }, + }); + // runWalletSessionDaemon installs its own timers/handlers and exits via + // process.exit on its shutdown paths; nothing more to do here. + } + + private async waitForDescriptorGone(dpath: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (readDescriptor(dpath) && Date.now() < deadline) { + await new Promise(r => setTimeout(r, 100)); + } + } + + private async waitForPidGone(pid: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (isPidAlive(pid) && Date.now() < deadline) { + await new Promise(r => setTimeout(r, 100)); + } + return !isPidAlive(pid); + } +} diff --git a/src/commands/wallet/index.ts b/src/commands/wallet/index.ts new file mode 100644 index 00000000..8af603d3 --- /dev/null +++ b/src/commands/wallet/index.ts @@ -0,0 +1,36 @@ +import {Command} from "commander"; +import {WalletAction, type WalletConnectOptions} from "./WalletAction"; + +export function initializeWalletCommands(program: Command) { + const wallet = new WalletAction(); + + const walletCommand = program + .command("wallet") + .description("Manage the persistent browser-wallet (MetaMask) signing session"); + + walletCommand + .command("connect") + .description("Start a persistent browser-wallet session (connect once, reuse across commands)") + .option("--network ", "Network alias to connect on (defaults to config network)") + .option("--rpc ", "Override the RPC URL") + .action((options: WalletConnectOptions) => wallet.connect(options)); + + walletCommand + .command("status") + .description("Show the current browser-wallet session (address, network, heartbeat, queue)") + .action(() => wallet.status()); + + walletCommand + .command("disconnect") + .description("End the active browser-wallet session") + .action(() => wallet.disconnect()); + + // Hidden: the detached daemon process entry point. Not intended for humans. + walletCommand + .command("daemon", {hidden: true}) + .option("--network ", "Network alias") + .option("--rpc ", "Override the RPC URL") + .action((options: WalletConnectOptions) => wallet.daemon(options)); + + return program; +} diff --git a/src/index.ts b/src/index.ts index c8bc1850..81015faa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,8 @@ import {initializeNetworkCommands} from "../src/commands/network"; import {initializeTransactionsCommands} from "../src/commands/transactions"; import {initializeStakingCommands} from "../src/commands/staking"; import {initializeVestingCommands} from "../src/commands/vesting"; +import {initializeWalletCommands} from "../src/commands/wallet"; +import {initializeBalancesCommands} from "../src/commands/balances"; export function initializeCLI() { program.version(version).description(CLI_DESCRIPTION); @@ -27,6 +29,8 @@ export function initializeCLI() { initializeTransactionsCommands(program); initializeStakingCommands(program); initializeVestingCommands(program); + initializeWalletCommands(program); + initializeBalancesCommands(program); program.parse(process.argv); } diff --git a/src/lib/actions/BaseAction.ts b/src/lib/actions/BaseAction.ts index 92a0d665..e59faa13 100644 --- a/src/lib/actions/BaseAction.ts +++ b/src/lib/actions/BaseAction.ts @@ -13,6 +13,9 @@ import { normalizeCustomNetworks, type CustomNetworksConfig, } from "../networks/customNetworks"; +import {type BrowserSession, type WalletMode} from "../wallet/browserSend"; +import {resolveBrowserWalletSession, type SessionFallback} from "../wallet/sessionResolver"; +import {descriptorPath, readDescriptor, isPidAlive} from "../wallet/sessionDescriptor"; // Built-in networks - always resolve fresh from genlayer-js export const BUILT_IN_NETWORKS: Record = { @@ -40,7 +43,11 @@ export function resolveNetwork(stored: string | undefined, customNetworks?: Cust if (!baseNetwork) { throw new Error(`Custom network ${stored} references unknown base network: ${customNetwork.base}`); } - return applyCustomNetworkProfile(baseNetwork, customNetwork); + // A custom network's display name is the alias the user chose + // (`network add `) — not the base chain's name. Otherwise a "clarke" + // network shows up as "Genlayer Bradbury Testnet" everywhere (wizard picker, + // network info, prompts), which is confusing. + return {...applyCustomNetworkProfile(baseNetwork, customNetwork), name: stored}; } // Backwards compat: try parsing as JSON (old format) @@ -70,6 +77,8 @@ export class BaseAction extends ConfigFileManager { private _genlayerClient: GenLayerClient | null = null; protected keychainManager: KeychainManager; protected accountOverride: string | null = null; + protected walletModeOverride: WalletMode | null = null; + protected browserSession: BrowserSession | null = null; constructor() { super(); @@ -81,6 +90,116 @@ export class BaseAction extends ConfigFileManager { return normalizeCustomNetworks(this.getConfigByKey(CUSTOM_NETWORKS_CONFIG_KEY)); } + // --- Browser-wallet (MetaMask) signing seam ------------------------------ + + /** + * Resolve the effective signing mode. Precedence: explicit --wallet flag > + * config `walletMode` > a live wallet session > "keystore". An invalid flag + * throws; an unknown config value warns and falls back to keystore. + * + * The live-session rung is what makes `genlayer wallet connect` alone enough: + * once a session is up, bare commands default to browser signing without a + * separate `config set walletMode browser`. Explicit `--wallet keystore` (or + * `walletMode=keystore` in config) still overrides a live session, so opting + * back out is one flag/config away. + */ + protected resolveWalletMode(flag?: string): WalletMode { + if (flag === "browser" || flag === "keystore") return flag; + if (flag !== undefined) { + throw new Error(`Invalid --wallet value '${flag}'. Use 'keystore' or 'browser'.`); + } + const cfg = this.getConfigByKey("walletMode"); + if (cfg === "browser") return "browser"; + if (cfg === "keystore") return "keystore"; // explicit opt-out wins over a live session + if (cfg !== null && cfg !== undefined) { + this.logWarning(`Ignoring invalid walletMode config value '${cfg}'. Using 'keystore'.`); + return "keystore"; + } + // No flag, no config: a live wallet session implies browser mode. + if (this.hasLiveWalletSession()) return "browser"; + return "keystore"; + } + + /** + * Cheap, synchronous "is a wallet session up?" gate: descriptor present and + * its daemon pid still alive. This mirrors the pid rung of + * resolveBrowserWalletSession — the authoritative /api/ping happens there when + * the command actually runs, so a stale-but-pid-alive descriptor still gets + * cleaned up and falls back correctly. Kept sync because resolveWalletMode + * (and its callers) are sync. Never throws — a bad/locked descriptor file + * just reads as "no session". + */ + protected hasLiveWalletSession(): boolean { + try { + const descriptor = readDescriptor(descriptorPath(this)); + return descriptor !== null && isPidAlive(descriptor.pid); + } catch { + return false; + } + } + + protected isBrowserWallet(config: {wallet?: string}): boolean { + return this.resolveWalletMode(config.wallet) === "browser"; + } + + /** + * Validate flag combinations for browser-wallet mode. Kept here (not in + * commander) so it is reusable and unit-testable. When browser: --password + * always conflicts; --account conflicts where the command has it. The + * invalid-value check now lives in resolveWalletMode. + */ + protected assertWalletFlags( + config: {wallet?: string; password?: string; account?: string}, + opts: {accountFlagExists: boolean; context: string}, + ): void { + if (!this.isBrowserWallet(config)) { + return; + } + if (config.password !== undefined) { + throw new Error("--password cannot be used with --wallet browser"); + } + if (opts.accountFlagExists && config.account !== undefined) { + throw new Error("--account selects a keystore; not applicable with --wallet browser"); + } + } + + /** + * Open (or reuse) a browser-wallet session for the current process. Resolves + * the chain, starts the bridge, prints the URL, and caches the session so + * multi-tx flows share one browser tab. Never touches keystore code paths. + */ + protected async getBrowserSession( + opts: {network?: string; rpc?: string; fallback?: SessionFallback} = {}, + ): Promise { + if (this.browserSession) return this.browserSession; + + const chain = opts.network + ? {...resolveNetwork(opts.network, this.getCustomNetworks())} + : resolveNetwork(this.getConfig().network, this.getCustomNetworks()); + const rpcUrl = opts.rpc || chain.rpcUrls.default.http[0]; + + // Prefer a persistent daemon session (connect-once). No live session → + // auto-start one and leave it up for subsequent commands. + this.browserSession = await resolveBrowserWalletSession({ + chain, + rpcUrl, + networkAlias: opts.network ?? this.getConfig().network, + configManager: this, + fallback: opts.fallback ?? "auto-start", + log: (msg: string) => this.log(msg), + logInfo: (msg: string) => this.logInfo(msg), + logWarning: (msg: string) => this.logWarning(msg), + }); + return this.browserSession; + } + + protected async closeBrowserSession(finalMessage?: string): Promise { + if (!this.browserSession) return; + const session = this.browserSession; + this.browserSession = null; + await session.close(finalMessage); + } + private async decryptKeystore(keystoreJson: string, attempt: number = 1): Promise { try { const message = attempt === 1 @@ -117,6 +236,21 @@ export class BaseAction extends ConfigFileManager { protected async getClient(rpcUrl?: string, readOnly: boolean = false): Promise> { if (!this._genlayerClient) { const network = resolveNetwork(this.getConfig().network, this.getCustomNetworks()); + + // Lane B (browser wallet): a plain Address account + EIP-1193 provider + // routes eth_sendTransaction through the bridge. Skip getAccount() so no + // keystore/keychain/password prompt is ever triggered. + if (this.walletModeOverride === "browser") { + const session = await this.getBrowserSession({rpc: rpcUrl}); + this._genlayerClient = createClient({ + chain: network, + endpoint: rpcUrl, + account: session.signerAddress, + provider: session.eip1193Provider, + } as Parameters[0]); + return this._genlayerClient; + } + const account = await this.getAccount(readOnly); this._genlayerClient = createClient({ chain: network, diff --git a/src/lib/vesting/availableToStake.ts b/src/lib/vesting/availableToStake.ts new file mode 100644 index 00000000..698e8123 --- /dev/null +++ b/src/lib/vesting/availableToStake.ts @@ -0,0 +1,29 @@ +import type {Address} from "genlayer-js/types"; + +/** Minimal client surface: all this helper needs is a native-balance read. */ +interface BalanceReader { + getBalance: (args: {address: Address}) => Promise; +} + +/** + * Authoritative "available to stake" for a vesting contract. + * + * Vesting.sol enforces every staking path (vestingDelegatorJoin / + * vestingValidatorJoin / vestingValidatorDeposit) against its LIVE NATIVE + * BALANCE — each reverts `InsufficientContractBalance` when the amount exceeds + * `address(this).balance` — and blocks staking entirely once revoked. So the + * contract's balance IS the cap: it already nets withdrawals, committed + * principal, and realized rewards/losses, and correctly includes still-locked + * (unvested) tokens. It must NOT be derived from vested/total/withdrawn/ + * committed arithmetic. + * + * @returns 0 when the contract is revoked (staking disabled), otherwise its + * on-chain balance. + */ +export async function vestingAvailableToStake( + client: BalanceReader, + vestingAddress: Address, + revoked: boolean, +): Promise { + return revoked ? 0n : client.getBalance({address: vestingAddress}); +} diff --git a/src/lib/wallet/bridgePage.ts b/src/lib/wallet/bridgePage.ts new file mode 100644 index 00000000..90f04c4d --- /dev/null +++ b/src/lib/wallet/bridgePage.ts @@ -0,0 +1,266 @@ +/** + * Inline HTML+JS served by BrowserWalletBridge on 127.0.0.1. Kept as a single + * template string (no static-asset build step; esbuild only bundles TS). + * + * Page-side flow: + * 1. Read the session token from location.hash (#s=) and send it on + * every API call via the X-Bridge-Token header. + * 2. Detect window.ethereum; eth_requestAccounts; POST /api/connected. + * 3. wallet_switchEthereumChain (add chain on 4902), re-verify chainId. + * 4. Long-poll GET /api/next; on {type:"tx"} eth_sendTransaction then POST + * /api/result; on {type:"done"|"abort"} show final panel and stop. + * + * No secrets are embedded; the token lives only in the URL fragment the CLI + * printed/opened. + */ +export const BRIDGE_PAGE_HTML = /* html */ ` + + + + +GenLayer CLI — Wallet Bridge + + + +
+

GenLayer CLI — Wallet Bridge

+

This page connects your browser wallet to the GenLayer CLI running on this machine. It only talks to 127.0.0.1.

+
Initializing…
+
+ +
+ + +`; diff --git a/src/lib/wallet/browserBridge.ts b/src/lib/wallet/browserBridge.ts new file mode 100644 index 00000000..e4abfad2 --- /dev/null +++ b/src/lib/wallet/browserBridge.ts @@ -0,0 +1,694 @@ +import http from "node:http"; +import {randomUUID} from "node:crypto"; +import type {AddressInfo} from "node:net"; +import {hexToBigInt} from "viem"; +import type {Address, Hash} from "genlayer-js/types"; +import {openUrl as defaultOpenUrl} from "../clients/system"; +import {BRIDGE_PAGE_HTML} from "./bridgePage"; +import {LONG_POLL_MS, HEARTBEAT_DEAD_MS} from "./sessionConstants"; + +export interface BridgeChainParams { + chainId: number; + chainName: string; + rpcUrls: string[]; + nativeCurrency: {name: string; symbol: string; decimals: number}; + blockExplorerUrls?: string[]; +} + +export interface BridgeTxRequest { + id: string; + to: Address; + data: `0x${string}`; + value?: bigint; + gasPrice?: bigint; + gas?: bigint; + nonce?: number; + type?: string; + label: string; +} + +/** + * Wire shape of a tx over HTTP: bigint quantities are hex strings. This is + * exactly what `serializeBridgeTx` emits and what the session client POSTs to + * `/api/enqueue`. The daemon parses it back with `parseBridgeTx`. Keeping the + * pair here means client and server can never drift. + */ +export interface SerializedBridgeTx { + to: Address; + data: `0x${string}`; + value?: string; + gasPrice?: string; + gas?: string; + nonce?: string; + type?: string; + label: string; +} + +/** bigint → 0x-hex serialization for the wire (client → daemon enqueue). */ +export function serializeBridgeTx(tx: Omit): SerializedBridgeTx { + return { + to: tx.to, + data: tx.data, + value: tx.value !== undefined ? `0x${tx.value.toString(16)}` : undefined, + gasPrice: tx.gasPrice !== undefined ? `0x${tx.gasPrice.toString(16)}` : undefined, + gas: tx.gas !== undefined ? `0x${tx.gas.toString(16)}` : undefined, + nonce: tx.nonce !== undefined ? `0x${tx.nonce.toString(16)}` : undefined, + type: tx.type, + label: tx.label, + }; +} + +/** 0x-hex → bigint parsing on the daemon side (enqueue handler). */ +export function parseBridgeTx(s: SerializedBridgeTx): Omit { + return { + to: s.to, + data: s.data, + value: s.value !== undefined ? hexToBigInt(s.value as `0x${string}`) : undefined, + gasPrice: s.gasPrice !== undefined ? hexToBigInt(s.gasPrice as `0x${string}`) : undefined, + gas: s.gas !== undefined ? hexToBigInt(s.gas as `0x${string}`) : undefined, + nonce: s.nonce !== undefined ? Number(hexToBigInt(s.nonce as `0x${string}`)) : undefined, + type: s.type, + label: s.label, + }; +} + +/** Terminal record for a tx the wallet handled (result store, remote polling). */ +export type TxResultRecord = + | {state: "pending"} + | {state: "delivered"} + | {state: "done"; status: "sent"; txHash: Hash; from?: Address; completedAt: number} + | {state: "done"; status: "rejected" | "error"; message: string; from?: Address; completedAt: number}; + +/** Shape returned by GET /api/state (also mirrored in sessionClient.ts). */ +export interface BridgeSessionState { + connected: boolean; + address: Address | null; + chainId: number; + chainIdHex: string; + chainName: string; + url: string; + lastPagePollAt: number; + queuedCount: number; + createdAt: number; +} + +export interface BrowserWalletBridgeOptions { + chain: BridgeChainParams; + openUrl?: (url: string) => Promise; + connectTimeoutMs?: number; + txTimeoutMs?: number; + log?: (msg: string) => void; + /** Register a SIGINT handler that closes the server. Default: true. */ + handleSigint?: boolean; + /** + * Persistent (daemon) mode: enables /api/enqueue, /api/tx, /api/state, + * /api/ping, /api/shutdown, the result store, and heartbeat tracking. The + * page also learns it should stay open between txs. Default false — the + * per-command / wizard path keeps exactly the current one-shot behaviour. + */ + persistent?: boolean; + /** Called with the connected address on every /api/connected (daemon: rewrite descriptor). */ + onConnected?: (address: Address) => void; + /** Called when a tx is enqueued (daemon: bump lastUsed). */ + onActivity?: () => void; + /** Called when /api/shutdown is received (daemon: remove descriptor + exit). */ + onShutdown?: () => void; +} + +const RESULT_GC_MS = 10 * 60_000; +/** Routes whose POSTs are page-originated and must pass the strict Origin check. */ +const PAGE_POST_ROUTES = new Set(["/api/connected", "/api/result"]); + +/** Reason codes surfaced as HTTP 409 on /api/enqueue and mapped to fail-fast messages. */ +export type EnqueueErrorReason = "bridge-closed" | "wallet-not-connected" | "tab-closed"; +export class EnqueueError extends Error { + constructor(readonly reason: EnqueueErrorReason) { + super(reason); + this.name = "EnqueueError"; + } +} + +interface PendingTx { + request: BridgeTxRequest; + resolve: (hash: Hash) => void; + reject: (err: Error) => void; + timer?: NodeJS.Timeout; + delivered: boolean; + from?: Address; +} + +interface NextWaiter { + resolve: (payload: unknown) => void; + timer: NodeJS.Timeout; +} + +const DEFAULT_CONNECT_TIMEOUT = 180_000; +const DEFAULT_TX_TIMEOUT = 300_000; +// LONG_POLL_MS is imported from ./sessionConstants (single source of truth). + +/** + * Dependency-free localhost bridge that lets a browser wallet (MetaMask, any + * injected window.ethereum) sign-and-broadcast transactions on behalf of the + * CLI. See docs/design in bridgePage.ts for the page side. + * + * Security posture: + * - binds 127.0.0.1 only, ephemeral port (listen(0)) + * - per-session token (URL fragment) required on every /api/* call + * - Origin header checked on POSTs (blocks DNS-rebinding / cross-site POST) + * - Cache-Control: no-store everywhere; single session; closed after use. + */ +export class BrowserWalletBridge { + private readonly chain: BridgeChainParams; + private readonly openUrl: (url: string) => Promise; + private readonly connectTimeoutMs: number; + private readonly txTimeoutMs: number; + private readonly log: (msg: string) => void; + private readonly handleSigint: boolean; + private readonly persistent: boolean; + private readonly onConnected?: (address: Address) => void; + private readonly onActivity?: () => void; + private readonly onShutdown?: () => void; + + private readonly token = randomUUID(); + private server: http.Server | null = null; + private origin = ""; + private url = ""; + private closed = false; + private finalMessage = "All done. You can close this tab."; + private readonly createdAt = Date.now(); + + private connectedAddress: Address | null = null; + private connectResolve: ((addr: Address) => void) | null = null; + private connectReject: ((err: Error) => void) | null = null; + private connectTimer: NodeJS.Timeout | null = null; + + private readonly txQueue: PendingTx[] = []; + private nextWaiter: NextWaiter | null = null; + private sigintHandler: (() => void) | null = null; + + /** Last time the page polled /api/next — the tab-liveness heartbeat. */ + private lastPagePollAt = 0; + /** Result store for remote (HTTP-polling) callers, keyed by tx id. */ + private readonly resultStore = new Map(); + + constructor(options: BrowserWalletBridgeOptions) { + this.chain = options.chain; + this.openUrl = options.openUrl ?? defaultOpenUrl; + this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT; + this.txTimeoutMs = options.txTimeoutMs ?? DEFAULT_TX_TIMEOUT; + this.log = options.log ?? (() => {}); + this.handleSigint = options.handleSigint ?? true; + this.persistent = options.persistent ?? false; + this.onConnected = options.onConnected; + this.onActivity = options.onActivity; + this.onShutdown = options.onShutdown; + } + + /** Whether the bridge is running in persistent (daemon) mode. */ + isPersistent(): boolean { + return this.persistent; + } + + getToken(): string { + return this.token; + } + + getPort(): number { + const addr = this.server?.address() as AddressInfo | null; + return addr?.port ?? 0; + } + + /** True when the page heartbeat is fresh enough to consider the tab alive. */ + private tabAlive(): boolean { + if (this.lastPagePollAt === 0) return true; // no poll yet: not yet stale + return Date.now() - this.lastPagePollAt <= HEARTBEAT_DEAD_MS; + } + + getState(): BridgeSessionState { + return { + connected: this.connectedAddress !== null, + address: this.connectedAddress, + chainId: this.chain.chainId, + chainIdHex: `0x${this.chain.chainId.toString(16)}`, + chainName: this.chain.chainName, + url: this.url, + lastPagePollAt: this.lastPagePollAt, + queuedCount: this.txQueue.length, + createdAt: this.createdAt, + }; + } + + async start(): Promise<{url: string}> { + if (this.server) return {url: this.url}; + + this.server = http.createServer((req, res) => this.handleRequest(req, res)); + + await new Promise((resolve, reject) => { + this.server!.once("error", reject); + this.server!.listen(0, "127.0.0.1", () => { + this.server!.removeListener("error", reject); + resolve(); + }); + }); + + const address = this.server.address() as AddressInfo; + this.origin = `http://127.0.0.1:${address.port}`; + this.url = `${this.origin}/#s=${this.token}`; + + if (this.handleSigint) { + this.sigintHandler = () => { + void this.close("The CLI was interrupted."); + }; + process.once("SIGINT", this.sigintHandler); + } + + await this.openUrl(this.url).catch(() => { + // Non-fatal: user can open the URL manually (headless / SSH). + }); + + return {url: this.url}; + } + + getUrl(): string { + return this.url; + } + + async waitForConnection(): Promise
{ + if (this.connectedAddress) return this.connectedAddress; + return new Promise
((resolve, reject) => { + this.connectResolve = resolve; + this.connectReject = reject; + this.connectTimer = setTimeout(() => { + this.connectReject = null; + this.connectResolve = null; + reject( + new Error( + `Timed out waiting for the browser wallet to connect. Open this URL and connect:\n ${this.url}`, + ), + ); + }, this.connectTimeoutMs); + }); + } + + async sendTransaction(tx: Omit): Promise { + if (this.closed) throw new Error("Bridge is closed."); + const {id, promise} = this.queueTx(tx); + void id; + return promise; + } + + /** + * Persistent-mode entry point for remote callers: enqueue a tx, record its + * result in the store (so an HTTP client can poll GET /api/tx?id=…), and + * return the id synchronously. Throws a 409-mappable reason if the wallet is + * not usable right now, so commands fail fast rather than queueing into a + * dead session. + */ + enqueueTx(tx: Omit): string { + if (this.closed) throw new EnqueueError("bridge-closed"); + if (!this.connectedAddress) throw new EnqueueError("wallet-not-connected"); + if (!this.tabAlive()) throw new EnqueueError("tab-closed"); + + const {id, promise} = this.queueTx(tx); + this.resultStore.set(id, {state: "pending"}); + this.onActivity?.(); + + promise.then( + txHash => { + const rec = this.resultStore.get(id); + this.resultStore.set(id, { + state: "done", + status: "sent", + txHash, + from: (rec && "from" in rec ? (rec as any).from : undefined) ?? this.lastResultFrom.get(id), + completedAt: Date.now(), + }); + this.scheduleResultGc(); + }, + (err: Error) => { + const message = err?.message || String(err); + const status = /rejected in wallet/i.test(message) ? "rejected" : "error"; + this.resultStore.set(id, { + state: "done", + status, + message, + from: this.lastResultFrom.get(id), + completedAt: Date.now(), + }); + this.scheduleResultGc(); + }, + ); + + return id; + } + + /** Look up a stored result for a remote poller. */ + getTxResult(id: string): TxResultRecord | null { + return this.resultStore.get(id) ?? null; + } + + private lastResultFrom = new Map(); + + private scheduleResultGc(): void { + const cutoff = Date.now() - RESULT_GC_MS; + for (const [id, rec] of this.resultStore) { + if (rec.state === "done" && rec.completedAt < cutoff) { + this.resultStore.delete(id); + this.lastResultFrom.delete(id); + } + } + } + + private queueTx(tx: Omit): {id: string; promise: Promise} { + const request: BridgeTxRequest = {...tx, id: randomUUID()}; + const promise = new Promise((resolve, reject) => { + const pending: PendingTx = { + request, + resolve, + reject, + delivered: false, + }; + pending.timer = setTimeout(() => { + const idx = this.txQueue.indexOf(pending); + if (idx >= 0) this.txQueue.splice(idx, 1); + reject( + new Error( + `Timed out waiting for the wallet to sign "${request.label}". ` + + `Confirm in your wallet at ${this.url}`, + ), + ); + }, this.txTimeoutMs); + + this.txQueue.push(pending); + this.tryDeliverNext(); + }); + return {id: request.id, promise}; + } + + /** Address of the tx the caller can inspect (for cross-checking `from`). */ + lastConnectedAddress(): Address | null { + return this.connectedAddress; + } + + async close(finalMessage?: string): Promise { + if (this.closed) return; + this.closed = true; + if (finalMessage) this.finalMessage = finalMessage; + + // Flush any waiter with a terminal message so the page stops polling. + if (this.nextWaiter) { + clearTimeout(this.nextWaiter.timer); + this.nextWaiter.resolve({type: "done", message: this.finalMessage}); + this.nextWaiter = null; + } + + // Reject anything still pending. + for (const pending of this.txQueue.splice(0)) { + if (pending.timer) clearTimeout(pending.timer); + pending.reject(new Error("Bridge closed before the transaction completed.")); + } + if (this.connectTimer) { + clearTimeout(this.connectTimer); + this.connectTimer = null; + } + if (this.connectReject) { + this.connectReject(new Error("Bridge closed before the wallet connected.")); + this.connectReject = null; + this.connectResolve = null; + } + if (this.sigintHandler) { + process.removeListener("SIGINT", this.sigintHandler); + this.sigintHandler = null; + } + + const server = this.server; + this.server = null; + if (server) { + // Give the page a brief moment to receive the terminal poll response. + await new Promise(resolve => { + setTimeout(() => { + server.closeAllConnections?.(); + server.close(() => resolve()); + }, 50); + }); + } + } + + // --- HTTP handling ------------------------------------------------------- + + private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { + res.setHeader("Cache-Control", "no-store"); + + const url = new URL(req.url ?? "/", this.origin); + const path = url.pathname; + + if (path === "/" && req.method === "GET") { + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(BRIDGE_PAGE_HTML); + return; + } + + if (!path.startsWith("/api/")) { + res.statusCode = 404; + res.end("Not found"); + return; + } + + // Token auth for all API routes. + if (req.headers["x-bridge-token"] !== this.token) { + res.statusCode = 403; + res.end("Forbidden"); + return; + } + + // Origin check for PAGE-originated POSTs (anti DNS-rebinding). Client + // routes (/api/enqueue, /api/shutdown) are exempt: a Node CLI client sends + // no Origin header, and token-in-header already forces a CORS preflight for + // any cross-site browser attempt (we emit no CORS headers, so it's blocked). + if (req.method === "POST" && PAGE_POST_ROUTES.has(path)) { + const origin = req.headers["origin"]; + if (origin !== this.origin) { + res.statusCode = 403; + res.end("Bad origin"); + return; + } + } + + if (path === "/api/session" && req.method === "GET") { + this.json(res, { + status: "ok", + persistent: this.persistent, + chain: { + ...this.chain, + chainIdHex: `0x${this.chain.chainId.toString(16)}`, + }, + }); + return; + } + + // --- Client (CLI) routes — persistent mode only ----------------------- + if (path === "/api/ping" && req.method === "GET") { + this.json(res, {status: "ok"}); + return; + } + + if (path === "/api/state" && req.method === "GET") { + this.json(res, this.getState()); + return; + } + + if (path === "/api/enqueue" && req.method === "POST") { + if (!this.persistent) { + res.statusCode = 404; + res.end("Not found"); + return; + } + void this.readJson(req).then(body => { + try { + const parsed = parseBridgeTx(body as SerializedBridgeTx); + const id = this.enqueueTx(parsed); + this.json(res, {id}); + } catch (err) { + if (err instanceof EnqueueError) { + res.statusCode = 409; + this.json(res, {error: err.reason}); + } else { + res.statusCode = 400; + this.json(res, {error: (err as Error)?.message || "bad request"}); + } + } + }); + return; + } + + if (path === "/api/tx" && req.method === "GET") { + const id = url.searchParams.get("id") ?? ""; + const rec = this.getTxResult(id); + if (!rec) { + res.statusCode = 404; + this.json(res, {state: "unknown"}); + return; + } + this.json(res, rec); + return; + } + + if (path === "/api/shutdown" && req.method === "POST") { + this.json(res, {status: "ok"}); + // Deterministic shutdown: if a daemon registered onShutdown, IT owns the + // ordered teardown (remove descriptor → close bridge → exit) so the + // "daemon exits ⇒ descriptor removed" invariant always holds — never rely + // on unref'd polling. Only close directly when nobody orchestrates + // (non-daemon / own-bridge usage). + if (this.onShutdown) { + this.onShutdown(); + } else { + void this.close("Disconnected. You can close this tab."); + } + return; + } + + if (path === "/api/connected" && req.method === "POST") { + void this.readJson(req).then(body => { + const address = (body?.address ?? "") as Address; + this.connectedAddress = address; + if (this.connectTimer) { + clearTimeout(this.connectTimer); + this.connectTimer = null; + } + if (this.connectResolve) { + this.connectResolve(address); + this.connectResolve = null; + this.connectReject = null; + } + this.onConnected?.(address); + this.json(res, {status: "ok"}); + }); + return; + } + + if (path === "/api/next" && req.method === "GET") { + this.handleNext(res); + return; + } + + if (path === "/api/result" && req.method === "POST") { + void this.readJson(req).then(body => { + this.handleResult(body); + this.json(res, {status: "ok"}); + }); + return; + } + + res.statusCode = 404; + res.end("Not found"); + } + + private handleNext(res: http.ServerResponse): void { + // Heartbeat: every page poll proves the tab is alive. + this.lastPagePollAt = Date.now(); + + // Deliver a queued-but-undelivered tx immediately. + const pending = this.txQueue.find(p => !p.delivered); + if (pending) { + pending.delivered = true; + if (this.resultStore.has(pending.request.id)) { + this.resultStore.set(pending.request.id, {state: "delivered"}); + } + this.json(res, {type: "tx", tx: this.serializeTx(pending.request)}); + return; + } + + if (this.closed) { + this.json(res, {type: "done", message: this.finalMessage}); + return; + } + + // Long-poll: hold until a tx arrives or the poll window elapses. + if (this.nextWaiter) { + clearTimeout(this.nextWaiter.timer); + this.nextWaiter.resolve({type: "none"}); + this.nextWaiter = null; + } + + const timer = setTimeout(() => { + if (this.nextWaiter) { + this.nextWaiter = null; + this.json(res, {type: "none"}); + } + }, LONG_POLL_MS); + + this.nextWaiter = { + resolve: payload => this.json(res, payload), + timer, + }; + } + + private handleResult(body: any): void { + const id = body?.id as string; + const pending = this.txQueue.find(p => p.request.id === id); + if (!pending) return; + + const idx = this.txQueue.indexOf(pending); + if (idx >= 0) this.txQueue.splice(idx, 1); + if (pending.timer) clearTimeout(pending.timer); + pending.from = body?.from as Address | undefined; + if (pending.from) this.lastResultFrom.set(id, pending.from); + + if (body?.status === "sent" && body?.txHash) { + pending.resolve(body.txHash as Hash); + } else if (body?.status === "rejected") { + pending.reject(new Error("Transaction rejected in wallet")); + } else { + pending.reject(new Error(body?.message || "Transaction failed in wallet")); + } + } + + private tryDeliverNext(): void { + if (!this.nextWaiter) return; + const pending = this.txQueue.find(p => !p.delivered); + if (!pending) return; + pending.delivered = true; + if (this.resultStore.has(pending.request.id)) { + this.resultStore.set(pending.request.id, {state: "delivered"}); + } + const waiter = this.nextWaiter; + this.nextWaiter = null; + clearTimeout(waiter.timer); + waiter.resolve({type: "tx", tx: this.serializeTx(pending.request)}); + } + + private serializeTx(tx: BridgeTxRequest): Record { + return { + id: tx.id, + // nonce/chainId are serialized for completeness but the page deliberately + // does NOT forward them to eth_sendTransaction (MetaMask tracks its own + // pending nonce and enforces the chain itself; some wallet versions reject + // dapp-supplied nonce/chainId keys). + ...serializeBridgeTx(tx), + chainId: `0x${this.chain.chainId.toString(16)}`, + }; + } + + private json(res: http.ServerResponse, payload: unknown): void { + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + res.end(JSON.stringify(payload)); + } + + private readJson(req: http.IncomingMessage): Promise { + return new Promise(resolve => { + const chunks: Buffer[] = []; + req.on("data", c => chunks.push(c as Buffer)); + req.on("end", () => { + try { + const raw = Buffer.concat(chunks).toString("utf-8"); + resolve(raw ? JSON.parse(raw) : {}); + } catch { + resolve({}); + } + }); + req.on("error", () => resolve({})); + }); + } +} diff --git a/src/lib/wallet/browserSend.ts b/src/lib/wallet/browserSend.ts new file mode 100644 index 00000000..a2479312 --- /dev/null +++ b/src/lib/wallet/browserSend.ts @@ -0,0 +1,316 @@ +import { + createPublicClient, + http, + hexToBigInt, + type PublicClient, + type Chain, + type HttpTransportConfig, + type TransactionReceipt, +} from "viem"; +import type {GenLayerChain, Address, Hash} from "genlayer-js/types"; +import {BrowserWalletBridge, type BridgeChainParams, type BridgeTxRequest} from "./browserBridge"; +import type {WalletSessionClient} from "./sessionClient"; + +// GenLayer RPC rejects JSON-RPC requests with id=0 (treats 0 as missing). +// Viem starts its id counter at 0, so we ensure non-zero ids. Owned here now +// (was StakingAction.ts) and re-exported for back-compat. +export const glHttpConfig: HttpTransportConfig = { + async fetchFn(url, init) { + if (init?.body) { + const body = JSON.parse(init.body as string); + if (body.id === 0) body.id = 1; + init = {...init, body: JSON.stringify(body)}; + } + return fetch(url, init); + }, +}; + +export type WalletMode = "keystore" | "browser"; + +export interface BrowserSessionParams { + /** Already-resolved chain (via resolveNetwork / getNetwork). */ + chain: GenLayerChain; + /** config.rpc || chain.rpcUrls.default.http[0]. */ + rpcUrl: string; + log?: (msg: string) => void; + logInfo?: (msg: string) => void; + /** Injectable for tests (default: the bridge's own openUrl). */ + openUrl?: (url: string) => Promise; + /** Register a SIGINT handler (default: true; tests pass false). */ + handleSigint?: boolean; +} + +/** EIP-1193-compatible request shape the genlayer-js client provider expects. */ +export interface Eip1193Provider { + request(args: {method: string; params?: any[]}): Promise; +} + +/** + * Transport seam between "how a tx gets to the wallet" and everything above it + * (preflight, receipt wait, EIP-1193 shim, labels). A local transport owns a + * bridge in-process; a remote transport enqueues to a running daemon over HTTP. + */ +export interface BridgeTransport { + readonly kind: "local" | "remote"; + readonly signerAddress: Address; + sendTransaction(tx: Omit): Promise; + /** Local: close the bridge. Remote: no-op (detach only — never kill a shared session). */ + close(finalMessage?: string): Promise; +} + +export interface BrowserSession { + /** Present only for local (own-bridge) sessions; absent for remote daemon sessions. */ + bridge?: BrowserWalletBridge; + kind: "local" | "remote"; + sessionUrl: string; + publicClient: PublicClient; + chain: GenLayerChain; + signerAddress: Address; + /** + * Lane A: preflight (publicClient.call) + queue to the wallet + await the EVM + * receipt; throws on predicted or on-chain revert. + */ + sendTransaction(tx: { + to: Address; + data: `0x${string}`; + value?: bigint; + gas?: bigint; + label: string; + }): Promise; + /** + * Lane B: EIP-1193 shim for genlayer-js `createClient({account, provider})`. + * Forwards eth_sendTransaction to the bridge; answers eth_chainId/eth_accounts + * locally. Does NOT wait for the receipt (the SDK does that against the RPC). + */ + eip1193Provider: Eip1193Provider; + /** Label shown on the bridge page for the NEXT provider-originated tx. */ + setNextLabel(label: string): void; + close(finalMessage?: string): Promise; +} + +/** Build the bridge/add-chain params from a resolved GenLayer chain. */ +export function buildBridgeChain(chain: GenLayerChain, rpcUrl: string): BridgeChainParams { + return { + chainId: chain.id, + chainName: chain.name, + rpcUrls: [rpcUrl], + nativeCurrency: chain.nativeCurrency + ? { + name: chain.nativeCurrency.name, + symbol: chain.nativeCurrency.symbol, + decimals: chain.nativeCurrency.decimals, + } + : {name: "GEN Token", symbol: "GEN", decimals: 18}, + blockExplorerUrls: chain.blockExplorers?.default?.url ? [chain.blockExplorers.default.url] : undefined, + }; +} + +/** + * Build the shared signing lanes (preflight + receipt wait Lane A, EIP-1193 + * shim Lane B, labels) on top of a transport. This body is identical for local + * and remote sessions — only the transport differs. + */ +export function buildBrowserSession( + transport: BridgeTransport, + chain: GenLayerChain, + rpcUrl: string, + bridgeChain: BridgeChainParams, + kind: "local" | "remote", + sessionUrl: string, + bridge?: BrowserWalletBridge, +): BrowserSession { + const publicClient = createPublicClient({ + chain: chain as unknown as Chain, + transport: http(rpcUrl, glHttpConfig), + }); + + const signerAddress = transport.signerAddress; + const chainIdHex = `0x${chain.id.toString(16)}`; + let nextLabel: string | undefined; + + const sendTransaction = async (tx: { + to: Address; + data: `0x${string}`; + value?: bigint; + gas?: bigint; + label: string; + }): Promise => { + // Preflight to surface reverts before the wallet ever prompts. + try { + await publicClient.call({ + account: signerAddress, + to: tx.to, + data: tx.data, + value: tx.value, + }); + } catch (error: any) { + // Remote transport.close() is a no-op: one failed preflight must NOT kill + // a shared daemon session. Local closes its own single-use bridge. + await transport.close("The CLI aborted: the transaction would revert."); + throw new Error( + `Transaction would revert (preflight): ${error.shortMessage || error.message || error}`, + ); + } + + const hash = await transport.sendTransaction({ + to: tx.to, + data: tx.data, + value: tx.value, + gas: tx.gas, + label: tx.label, + }); + + const receipt = await publicClient.waitForTransactionReceipt({ + hash, + timeout: 300_000, + }); + + if (receipt.status === "reverted") { + const explorer = bridgeChain.blockExplorerUrls?.[0]; + const hint = explorer ? ` See ${explorer.replace(/\/$/, "")}/tx/${hash}` : ""; + throw new Error(`Transaction ${hash} reverted.${hint}`); + } + + return receipt; + }; + + const eip1193Provider: Eip1193Provider = { + async request({method, params = []}: {method: string; params?: any[]}) { + switch (method) { + case "eth_chainId": + // Satisfies the SDK's assertChainMatch; real chain enforcement is + // page-side (wallet_switchEthereumChain, re-verified before each send). + return chainIdHex; + case "eth_accounts": + case "eth_requestAccounts": + return [signerAddress]; + case "eth_sendTransaction": { + const req = (params[0] ?? {}) as { + to?: Address; + data?: `0x${string}`; + value?: string; + gas?: string; + gasPrice?: string; + nonce?: string; + type?: string; + }; + const hash = await transport.sendTransaction({ + to: req.to as Address, + data: (req.data ?? "0x") as `0x${string}`, + value: req.value !== undefined ? hexToBigInt(req.value as `0x${string}`) : undefined, + gas: req.gas !== undefined ? hexToBigInt(req.gas as `0x${string}`) : undefined, + gasPrice: req.gasPrice !== undefined ? hexToBigInt(req.gasPrice as `0x${string}`) : undefined, + nonce: req.nonce !== undefined ? Number(hexToBigInt(req.nonce as `0x${string}`)) : undefined, + type: req.type, + label: nextLabel ?? "GenLayer transaction", + }); + nextLabel = undefined; + return hash; + } + default: + throw new Error(`Method ${method} is not supported by the browser-wallet bridge`); + } + }, + }; + + const setNextLabel = (label: string): void => { + nextLabel = label; + }; + + return { + bridge, + kind, + sessionUrl, + publicClient, + chain, + signerAddress, + sendTransaction, + eip1193Provider, + setNextLabel, + close: (finalMessage?: string) => transport.close(finalMessage), + }; +} + +/** Transport that owns an in-process BrowserWalletBridge (per-command / wizard). */ +class LocalBridgeTransport implements BridgeTransport { + readonly kind = "local" as const; + constructor( + private readonly bridge: BrowserWalletBridge, + readonly signerAddress: Address, + ) {} + sendTransaction(tx: Omit): Promise { + return this.bridge.sendTransaction(tx); + } + close(finalMessage?: string): Promise { + return this.bridge.close(finalMessage); + } +} + +/** Transport that enqueues to a running daemon over HTTP; close() detaches only. */ +class RemoteSessionTransport implements BridgeTransport { + readonly kind = "remote" as const; + constructor( + private readonly client: WalletSessionClient, + readonly signerAddress: Address, + ) {} + async sendTransaction(tx: Omit): Promise { + const id = await this.client.enqueueTx(tx); + return this.client.waitForTxResult(id); + } + async close(): Promise { + // No-op: the daemon session is shared and outlives this command. + } +} + +/** + * Open a browser-wallet signing session with an in-process bridge: start the + * localhost bridge, open the wallet page, wait for connect, and return both + * signing lanes. Never touches keystore/keychain/password code paths. + * Action-agnostic (reused by the wizard and the resolver's own-bridge fallback). + */ +export async function openBrowserWalletSession(params: BrowserSessionParams): Promise { + const {chain, rpcUrl} = params; + const log = params.log ?? (() => {}); + const logInfo = params.logInfo ?? (() => {}); + + const bridgeChain = buildBridgeChain(chain, rpcUrl); + + const bridge = new BrowserWalletBridge({ + chain: bridgeChain, + openUrl: params.openUrl, + handleSigint: params.handleSigint, + log, + }); + + const {url} = await bridge.start(); + logInfo(`Open this URL in a browser with your wallet to sign:\n ${url}`); + logInfo("(Remote/SSH? Forward the port first: ssh -L :127.0.0.1: ...)"); + + const signerAddress = await bridge.waitForConnection(); + const transport = new LocalBridgeTransport(bridge, signerAddress); + return buildBrowserSession(transport, chain, rpcUrl, bridgeChain, "local", url, bridge); +} + +/** + * Build a session backed by a running daemon (discovered via the descriptor). + * Asserts the wallet is already connected, then wraps a remote transport whose + * close() is a no-op so per-command finally blocks never tear the session down. + */ +export async function openRemoteWalletSession(params: { + client: WalletSessionClient; + chain: GenLayerChain; + rpcUrl: string; + log?: (msg: string) => void; + logInfo?: (msg: string) => void; +}): Promise { + const {client, chain, rpcUrl} = params; + const state = await client.state(); + if (!state.connected || !state.address) { + throw new Error( + "The wallet session is not connected. Run 'genlayer wallet connect' and approve in your browser.", + ); + } + const bridgeChain = buildBridgeChain(chain, rpcUrl); + const transport = new RemoteSessionTransport(client, state.address); + return buildBrowserSession(transport, chain, rpcUrl, bridgeChain, "remote", state.url); +} diff --git a/src/lib/wallet/sessionClient.ts b/src/lib/wallet/sessionClient.ts new file mode 100644 index 00000000..ae26de20 --- /dev/null +++ b/src/lib/wallet/sessionClient.ts @@ -0,0 +1,148 @@ +import type {Address, Hash} from "genlayer-js/types"; +import {serializeBridgeTx, type BridgeTxRequest, type SerializedBridgeTx} from "./browserBridge"; +import type {WalletSessionDescriptor} from "./sessionDescriptor"; +import {HEARTBEAT_DEAD_MS, TX_TIMEOUT_MS, CONNECT_TIMEOUT_MS, TAB_CLOSED_MESSAGE} from "./sessionConstants"; + +/** Mirror of BridgeSessionState over the wire (GET /api/state). */ +export interface SessionState { + connected: boolean; + address: Address | null; + chainId: number; + chainIdHex: string; + chainName: string; + url: string; + lastPagePollAt: number; + queuedCount: number; + createdAt: number; +} + +type FetchFn = typeof fetch; + +/** + * HTTP client for a running wallet-session daemon. Every request carries the + * bridge token in X-Bridge-Token. No new dependencies — plain fetch against + * http://127.0.0.1:. + */ +export class WalletSessionClient { + private readonly base: string; + private readonly token: string; + private readonly fetchFn: FetchFn; + private readonly pollIntervalMs: number; + + constructor( + readonly descriptor: WalletSessionDescriptor, + opts: {fetchFn?: FetchFn; pollIntervalMs?: number} = {}, + ) { + this.base = `http://127.0.0.1:${descriptor.port}`; + this.token = descriptor.token; + this.fetchFn = opts.fetchFn ?? fetch; + this.pollIntervalMs = opts.pollIntervalMs ?? 1000; + } + + private headers(json = false): Record { + const h: Record = {"X-Bridge-Token": this.token}; + if (json) h["Content-Type"] = "application/json"; + return h; + } + + /** Liveness probe. Any connection error (ECONNREFUSED, etc.) → false. */ + async ping(): Promise { + try { + const res = await this.fetchFn(`${this.base}/api/ping`, {headers: this.headers()}); + if (!res.ok) return false; + const body: any = await res.json().catch(() => ({})); + return body?.status === "ok"; + } catch { + return false; + } + } + + async state(): Promise { + const res = await this.fetchFn(`${this.base}/api/state`, {headers: this.headers()}); + if (!res.ok) throw new Error(`Wallet session returned ${res.status} for /api/state`); + return (await res.json()) as SessionState; + } + + /** Enqueue a tx onto the daemon's wallet queue; returns the tx id. */ + async enqueueTx(tx: Omit): Promise { + const payload: SerializedBridgeTx = serializeBridgeTx(tx); + const res = await this.fetchFn(`${this.base}/api/enqueue`, { + method: "POST", + headers: this.headers(true), + body: JSON.stringify(payload), + }); + if (res.status === 409) { + const body: any = await res.json().catch(() => ({})); + if (body?.error === "tab-closed") throw new Error(TAB_CLOSED_MESSAGE); + if (body?.error === "wallet-not-connected") { + throw new Error( + "The wallet session is not connected yet. Approve the connection in your browser, " + + "or run 'genlayer wallet connect'.", + ); + } + throw new Error(`Wallet session cannot accept transactions: ${body?.error ?? "unknown"}`); + } + if (!res.ok) throw new Error(`Wallet session returned ${res.status} for /api/enqueue`); + const body: any = await res.json(); + if (!body?.id) throw new Error("Wallet session did not return a transaction id"); + return body.id as string; + } + + /** + * Poll for a tx result. Fails fast if the page heartbeat goes stale (tab + * closed) rather than blocking for the full timeout. + */ + async waitForTxResult(id: string, timeoutMs = TX_TIMEOUT_MS): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const res = await this.fetchFn(`${this.base}/api/tx?id=${encodeURIComponent(id)}`, { + headers: this.headers(), + }); + if (res.ok) { + const rec: any = await res.json(); + if (rec.state === "done") { + if (rec.status === "sent" && rec.txHash) return rec.txHash as Hash; + if (rec.status === "rejected") throw new Error("Transaction rejected in wallet"); + throw new Error(rec.message || "Transaction failed in wallet"); + } + // pending | delivered → keep polling. + } + + // Fail fast on a dead tab instead of hanging until timeout. + const st = await this.state().catch(() => null); + if (st && st.lastPagePollAt > 0 && Date.now() - st.lastPagePollAt > HEARTBEAT_DEAD_MS) { + throw new Error(TAB_CLOSED_MESSAGE); + } + + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for the wallet to sign the transaction (id ${id}).`); + } + await new Promise(r => setTimeout(r, this.pollIntervalMs)); + } + } + + /** Poll session state until the wallet is connected; returns the signer address. */ + async waitForConnection(timeoutMs = CONNECT_TIMEOUT_MS): Promise
{ + const deadline = Date.now() + timeoutMs; + for (;;) { + const st = await this.state().catch(() => null); + if (st?.connected && st.address) return st.address; + if (Date.now() > deadline) { + throw new Error( + "Timed out waiting for the browser wallet to connect. " + + "Open the session tab and approve the connection, or run 'genlayer wallet connect'.", + ); + } + await new Promise(r => setTimeout(r, this.pollIntervalMs)); + } + } + + /** Ask the daemon to shut down. Best-effort — tolerate a dropped socket. */ + async shutdown(): Promise { + try { + await this.fetchFn(`${this.base}/api/shutdown`, {method: "POST", headers: this.headers(true)}); + } catch { + // The server may close the socket before the response flushes. + } + } +} diff --git a/src/lib/wallet/sessionConstants.ts b/src/lib/wallet/sessionConstants.ts new file mode 100644 index 00000000..ca241437 --- /dev/null +++ b/src/lib/wallet/sessionConstants.ts @@ -0,0 +1,50 @@ +/** + * Single source of truth for the persistent wallet-session timing constants. + * Shared by the daemon, the session client, and the bridge page so the + * heartbeat / liveness budgets never drift between producer and consumer. + */ + +/** Page long-poll window (existing bridge behaviour). */ +export const LONG_POLL_MS = 25_000; + +/** + * Client + `/api/enqueue` treat the tab as closed after this much silence on + * the page heartbeat (~3 missed long-poll windows; tolerates background-tab + * throttling). Commands fail fast instead of hanging on a dead tab. + */ +export const HEARTBEAT_DEAD_MS = 90_000; + +/** + * Surfaced when the page heartbeat has gone stale (tab closed / crashed). + * Single source of truth so the session client and the resolver emit the + * identical reconnect instruction. + */ +export const TAB_CLOSED_MESSAGE = + "The wallet session tab appears to be closed. Run 'genlayer wallet connect' to reconnect."; + +/** Daemon self-terminates after sustained page silence (tab closed / crashed). */ +export const TAB_DEAD_GRACE_MS = 10 * 60_000; + +/** Daemon self-terminates when unused this long (config: walletSessionTtlMinutes). */ +export const IDLE_TTL_MS = 30 * 60_000; + +/** spawn → descriptor-written + /api/ping answers. */ +export const DAEMON_READY_TIMEOUT_MS = 10_000; + +/** Wallet connect wait (existing bridge behaviour). */ +export const CONNECT_TIMEOUT_MS = 180_000; + +/** Per-tx wallet confirmation wait (existing bridge behaviour). */ +export const TX_TIMEOUT_MS = 300_000; + +/** Descriptor file name under ~/.genlayer. */ +export const SESSION_DESCRIPTOR_FILENAME = "wallet-session.json"; + +/** Daemon log file name under ~/.genlayer. */ +export const DAEMON_LOG_FILENAME = "wallet-daemon.log"; + +/** Config key controlling the default signing mode ("keystore" | "browser"). */ +export const WALLET_MODE_CONFIG_KEY = "walletMode"; + +/** Config key (minutes) overriding IDLE_TTL_MS. */ +export const WALLET_SESSION_TTL_CONFIG_KEY = "walletSessionTtlMinutes"; diff --git a/src/lib/wallet/sessionDaemon.ts b/src/lib/wallet/sessionDaemon.ts new file mode 100644 index 00000000..d366f2c0 --- /dev/null +++ b/src/lib/wallet/sessionDaemon.ts @@ -0,0 +1,238 @@ +import type {Address} from "genlayer-js/types"; +import type {ConfigFileManager} from "../config/ConfigFileManager"; +import {resolveNetwork} from "../actions/BaseAction"; +import {normalizeCustomNetworks, CUSTOM_NETWORKS_CONFIG_KEY} from "../networks/customNetworks"; +import {BrowserWalletBridge} from "./browserBridge"; +import {buildBridgeChain} from "./browserSend"; +import { + descriptorPath, + readDescriptor, + removeDescriptor, + writeDescriptor, + isPidAlive, + type WalletSessionDescriptor, +} from "./sessionDescriptor"; +import {WalletSessionClient} from "./sessionClient"; +import { + IDLE_TTL_MS, + TAB_DEAD_GRACE_MS, + CONNECT_TIMEOUT_MS, + WALLET_SESSION_TTL_CONFIG_KEY, +} from "./sessionConstants"; + +export interface RunDaemonOptions { + network?: string; + rpc?: string; + configManager: ConfigFileManager; + openUrl?: (url: string) => Promise; + idleTtlMs?: number; + tabDeadGraceMs?: number; + connectTimeoutMs?: number; + /** Injectable for tests — avoids process.exit killing the test runner. */ + onExit?: (code: number) => void; + log?: (msg: string) => void; + /** For tests: resolve as soon as the runtime is set up (bridge listening + descriptor written). */ + onReady?: (ctx: DaemonHandle) => void; +} + +export interface DaemonHandle { + bridge: BrowserWalletBridge; + descriptor: WalletSessionDescriptor; + /** Force one timer tick (tests). */ + tick(): void; + /** Stop timers + close the bridge without exiting the process (tests). */ + dispose(): Promise; +} + +const LAST_USED_THROTTLE_MS = 5000; + +/** + * The persistent wallet-session daemon runtime. Owns the bridge server + browser + * tab + descriptor file lifecycle. Self-terminates on idle TTL, sustained tab + * silence, /api/shutdown, connect timeout, or a fatal signal — always removing + * the descriptor first. + */ +export async function runWalletSessionDaemon(opts: RunDaemonOptions): Promise { + const {configManager} = opts; + const log = opts.log ?? (() => {}); + const exit = opts.onExit ?? ((code: number) => process.exit(code)); + + const idleTtlMs = resolveIdleTtl(configManager, opts.idleTtlMs); + const tabDeadGraceMs = opts.tabDeadGraceMs ?? TAB_DEAD_GRACE_MS; + const connectTimeoutMs = opts.connectTimeoutMs ?? CONNECT_TIMEOUT_MS; + + const dpath = descriptorPath(configManager); + + // Singleton guard: an already-live daemon wins. + const existing = readDescriptor(dpath); + if (existing && isPidAlive(existing.pid)) { + const client = new WalletSessionClient(existing); + if (await client.ping()) { + log(`Wallet session already running (pid ${existing.pid}). Exiting.`); + exit(0); + throw new Error("daemon-already-running"); + } + } + if (existing) removeDescriptor(dpath); + + // Resolve chain exactly like BaseAction.getBrowserSession. + const customNetworks = normalizeCustomNetworks(configManager.getConfigByKey(CUSTOM_NETWORKS_CONFIG_KEY)); + const networkAlias = opts.network || configManager.getConfigByKey("network") || "localnet"; + const chain = opts.network + ? {...resolveNetwork(opts.network, customNetworks)} + : resolveNetwork(configManager.getConfigByKey("network"), customNetworks); + const rpcUrl = opts.rpc || chain.rpcUrls.default.http[0]; + + const bridgeChain = buildBridgeChain(chain, rpcUrl); + + let lastUsedWrite = 0; + let disposed = false; + // Forward reference: cleanupAndExit is defined after the bridge is built, but + // /api/shutdown must trigger it deterministically. The bridge calls this thunk + // synchronously from the shutdown handler. + let onShutdownCb: (() => void) | undefined; + + const bridge = new BrowserWalletBridge({ + chain: bridgeChain, + persistent: true, + handleSigint: false, // the daemon installs its own signal handling below + onShutdown: () => onShutdownCb?.(), + openUrl: opts.openUrl, + log, + onConnected: (address: Address) => { + const d = readDescriptor(dpath); + if (d) writeDescriptor(dpath, {...d, address}); + log(`Wallet connected: ${address}`); + }, + onActivity: () => { + const now = Date.now(); + if (now - lastUsedWrite < LAST_USED_THROTTLE_MS) return; + lastUsedWrite = now; + const d = readDescriptor(dpath); + if (d) writeDescriptor(dpath, {...d, lastUsed: now}); + }, + connectTimeoutMs, + }); + + await bridge.start(); + + // Write the descriptor ONLY after listening succeeds, so a readable + // descriptor always implies a live port. + const now = Date.now(); + const descriptor: WalletSessionDescriptor = { + version: 1, + pid: process.pid, + port: bridge.getPort(), + token: bridge.getToken(), + address: null, + chainId: chain.id, + network: networkAlias, + rpcUrl, + createdAt: now, + lastUsed: now, + }; + writeDescriptor(dpath, descriptor); + log(`Wallet session started (pid ${process.pid}, port ${descriptor.port}). URL: ${bridge.getUrl()}`); + + let everConnected = false; + let cleaned = false; + + const cleanupAndExit = async (code: number, finalMessage?: string): Promise => { + if (cleaned) return; + cleaned = true; + clearInterval(timer); + removeDescriptor(dpath); + await bridge.close(finalMessage).catch(() => {}); + exit(code); + }; + + const checkTimers = (): void => { + if (cleaned || disposed) return; + const state = bridge.getState(); + if (state.connected) everConnected = true; + + const d = readDescriptor(dpath); + const lastUsed = d ? Math.max(d.lastUsed, d.createdAt) : descriptor.createdAt; + + // Idle TTL. + if (Date.now() - lastUsed > idleTtlMs) { + log("Idle TTL reached — shutting down."); + void cleanupAndExit( + 0, + "Session expired after inactivity. Run 'genlayer wallet connect' to start a new one.", + ); + return; + } + + // Tab-dead: connected once, but the page heartbeat went silent. + if (everConnected && state.lastPagePollAt > 0 && Date.now() - state.lastPagePollAt > tabDeadGraceMs) { + log("Tab heartbeat lost — shutting down."); + void cleanupAndExit(0, "The wallet tab was closed. Run 'genlayer wallet connect' to start a new one."); + return; + } + + // Connect timeout: never connected within the window → no zombie daemons. + if (!everConnected && Date.now() - descriptor.createdAt > connectTimeoutMs) { + log("Connect timeout — nobody connected. Shutting down."); + void cleanupAndExit(0, "No wallet connected in time."); + return; + } + }; + + // /api/shutdown (via wallet disconnect) → deterministic ordered teardown. + // Wired here (not via unref'd polling): the bridge fires onShutdown, which + // removes the descriptor, closes the bridge, then exits — so the invariant + // "daemon process gone ⇒ descriptor removed" always holds. + onShutdownCb = () => void cleanupAndExit(0, "Disconnected. You can close this tab."); + + // NOT unref'd: the interval keeps the event loop alive so the daemon only + // ever exits through cleanupAndExit (idle/tab-dead/connect-timeout/shutdown/ + // signal), never by the loop draining after the server socket closes. + const timer = setInterval(checkTimers, 30_000); + + // Signal + fatal-error handling: always remove the descriptor first. + const onSignal = (sig: string) => () => { + log(`Received ${sig} — shutting down.`); + void cleanupAndExit(0); + }; + const sigHandlers: Record void> = {}; + if (opts.onExit === undefined) { + for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"] as const) { + const h = onSignal(sig); + sigHandlers[sig] = h; + process.once(sig, h); + } + process.once("uncaughtException", err => { + log(`uncaughtException: ${err instanceof Error ? err.stack : String(err)}`); + void cleanupAndExit(1); + }); + process.once("unhandledRejection", reason => { + log(`unhandledRejection: ${String(reason)}`); + void cleanupAndExit(1); + }); + } + + const handle: DaemonHandle = { + bridge, + descriptor, + tick: checkTimers, + dispose: async () => { + disposed = true; + clearInterval(timer); + for (const [sig, h] of Object.entries(sigHandlers)) process.removeListener(sig, h); + removeDescriptor(dpath); + await bridge.close().catch(() => {}); + }, + }; + + opts.onReady?.(handle); + return handle; +} + +function resolveIdleTtl(configManager: ConfigFileManager, override?: number): number { + if (override !== undefined) return override; + const configured = configManager.getConfigByKey(WALLET_SESSION_TTL_CONFIG_KEY); + const minutes = Number(configured); + if (Number.isFinite(minutes) && minutes > 0) return minutes * 60_000; + return IDLE_TTL_MS; +} diff --git a/src/lib/wallet/sessionDescriptor.ts b/src/lib/wallet/sessionDescriptor.ts new file mode 100644 index 00000000..9b7bde8b --- /dev/null +++ b/src/lib/wallet/sessionDescriptor.ts @@ -0,0 +1,95 @@ +import fs from "node:fs"; +import type {ConfigFileManager} from "../config/ConfigFileManager"; +import {SESSION_DESCRIPTOR_FILENAME} from "./sessionConstants"; + +/** + * On-disk descriptor for a running wallet-session daemon. Written 0600 next to + * the keystores in ~/.genlayer. Any CLI process reads it to discover the live + * daemon and talk to it over token-authed localhost HTTP. + */ +export interface WalletSessionDescriptor { + version: 1; + pid: number; + port: number; + /** Bridge session token — same one the page carries in its URL fragment. */ + token: string; + /** null until the wallet connects. */ + address: string | null; + chainId: number; + /** Network alias passed to resolveNetwork (or "custom"). */ + network: string; + rpcUrl: string; + createdAt: number; + /** Updated by the daemon on every enqueue (throttled). */ + lastUsed: number; +} + +export function descriptorPath(configManager: ConfigFileManager): string { + return configManager.getFilePath(SESSION_DESCRIPTOR_FILENAME); +} + +/** + * Atomically write the descriptor with 0600 perms: write a temp file, then + * rename over the target (rename is atomic on the same filesystem). chmod after + * rename is belt-and-braces in case the tmp inherited a laxer umask. + */ +export function writeDescriptor(path: string, d: WalletSessionDescriptor): void { + const tmp = `${path}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(d, null, 2), {mode: 0o600}); + fs.renameSync(tmp, path); + try { + fs.chmodSync(path, 0o600); + } catch { + // Non-fatal (e.g. exotic FS); the tmp already had 0600. + } +} + +function isValidDescriptor(v: any): v is WalletSessionDescriptor { + return ( + v && + v.version === 1 && + typeof v.pid === "number" && + typeof v.port === "number" && + typeof v.token === "string" && + (v.address === null || typeof v.address === "string") && + typeof v.chainId === "number" && + typeof v.network === "string" && + typeof v.rpcUrl === "string" && + typeof v.createdAt === "number" && + typeof v.lastUsed === "number" + ); +} + +/** Parse + schema-validate the descriptor, or return null (bad JSON / shape). */ +export function readDescriptor(path: string): WalletSessionDescriptor | null { + let raw: string; + try { + raw = fs.readFileSync(path, "utf-8"); + } catch { + return null; + } + try { + const parsed = JSON.parse(raw); + return isValidDescriptor(parsed) ? parsed : null; + } catch { + return null; + } +} + +export function removeDescriptor(path: string): void { + fs.rmSync(path, {force: true}); +} + +/** + * Cheap first-gate liveness check. Signal 0 does not kill; it only probes. + * EPERM means the process exists but is owned by another user (still "alive"). + * The authoritative check is a token-authed /api/ping (handles PID/port reuse). + */ +export function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (e: any) { + return e?.code === "EPERM"; + } +} diff --git a/src/lib/wallet/sessionResolver.ts b/src/lib/wallet/sessionResolver.ts new file mode 100644 index 00000000..e5326efe --- /dev/null +++ b/src/lib/wallet/sessionResolver.ts @@ -0,0 +1,131 @@ +import type {GenLayerChain} from "genlayer-js/types"; +import type {ConfigFileManager} from "../config/ConfigFileManager"; +import {openBrowserWalletSession, openRemoteWalletSession, type BrowserSession} from "./browserSend"; +import {WalletSessionClient} from "./sessionClient"; +import {descriptorPath, readDescriptor, removeDescriptor, isPidAlive} from "./sessionDescriptor"; +import {spawnWalletDaemon, waitForDaemonReady} from "./spawnDaemon"; +import { + DAEMON_LOG_FILENAME, + CONNECT_TIMEOUT_MS, + HEARTBEAT_DEAD_MS, + TAB_CLOSED_MESSAGE, +} from "./sessionConstants"; + +export type SessionFallback = "auto-start" | "own-bridge" | "error"; + +export interface ResolveSessionParams { + /** Already-resolved chain (network flag > config). */ + chain: GenLayerChain; + rpcUrl: string; + /** Network alias for the descriptor / daemon argv. */ + networkAlias?: string; + configManager: ConfigFileManager; + fallback: SessionFallback; + log?: (msg: string) => void; + logInfo?: (msg: string) => void; + logWarning?: (msg: string) => void; + openUrl?: (url: string) => Promise; + handleSigint?: boolean; + // Test seams. + spawnFn?: Parameters[0]["spawnFn"]; + fetchFn?: typeof fetch; + /** Override daemon-ready poll timeout (tests). */ + readyTimeoutMs?: number; +} + +/** + * The single entry point every browser-mode command uses. Finds a live daemon + * session and returns a remote session bound to it; otherwise applies the + * fallback (auto-start a persistent daemon, open an own in-process bridge, or + * error). Stale descriptors are cleaned up transparently. + */ +export async function resolveBrowserWalletSession(params: ResolveSessionParams): Promise { + const {chain, rpcUrl, configManager} = params; + const log = params.log ?? (() => {}); + const logInfo = params.logInfo ?? (() => {}); + const logWarning = params.logWarning ?? (() => {}); + const dpath = descriptorPath(configManager); + + // 1. Discover. + const descriptor = readDescriptor(dpath); + if (descriptor) { + // 2. Liveness (cheap pid gate, then authoritative ping). + const client = new WalletSessionClient(descriptor, {fetchFn: params.fetchFn}); + const alive = isPidAlive(descriptor.pid) && (await client.ping()); + if (!alive) { + removeDescriptor(dpath); // stale cleanup + } else { + // 3. Live session found. + let state = await client.state(); + if (state.chainId !== chain.id) { + throw new Error( + `Browser wallet session is connected to ${descriptor.network} (chain ${state.chainId}) ` + + `but this command targets ${chain.name} (chain ${chain.id}). ` + + `Run 'genlayer wallet connect --network ${params.networkAlias ?? descriptor.network}' to switch, ` + + `or pass --wallet keystore.`, + ); + } + if (!state.connected) { + await client.waitForConnection(CONNECT_TIMEOUT_MS); + // Re-read: a just-connected page has polled, so its heartbeat is fresh. + state = await client.state(); + } + // Fail fast on a dead tab (stale page heartbeat) instead of returning a + // session that only fails at the final sign step. The daemon self-manages + // its own tab-dead shutdown, so we do not touch the descriptor here — just + // surface the reconnect instruction immediately. lastPagePollAt === 0 + // means the page has never polled yet (freshly started) → not stale. + if (state.lastPagePollAt > 0 && Date.now() - state.lastPagePollAt > HEARTBEAT_DEAD_MS) { + throw new Error(TAB_CLOSED_MESSAGE); + } + return openRemoteWalletSession({client, chain, rpcUrl, log, logInfo}); + } + } + + // 4. No live session — apply the fallback. + if (params.fallback === "error") { + throw new Error("No active browser wallet session. Run 'genlayer wallet connect' first."); + } + + if (params.fallback === "auto-start") { + try { + const logPath = configManager.getFilePath(DAEMON_LOG_FILENAME); + spawnWalletDaemon({ + network: params.networkAlias, + rpc: rpcUrl, + logPath, + spawnFn: params.spawnFn, + }); + const ready = await waitForDaemonReady(dpath, { + logPath, + fetchFn: params.fetchFn, + timeoutMs: params.readyTimeoutMs, + }); + logInfo( + "Started a persistent wallet session — approve the connection in your browser. " + + "Subsequent commands will reuse it; end it with 'genlayer wallet disconnect'.", + ); + const client = new WalletSessionClient(ready, {fetchFn: params.fetchFn}); + await client.waitForConnection(CONNECT_TIMEOUT_MS); + return openRemoteWalletSession({client, chain, rpcUrl, log, logInfo}); + } catch (err) { + // Degrade to an own in-process bridge (e.g. weird packaging where re-exec + // fails). A lone command still works exactly like before. + logWarning( + `Could not start a persistent wallet session (${ + (err as Error)?.message || err + }). Falling back to a single-use bridge for this command.`, + ); + } + } + + // own-bridge (explicit, or degraded auto-start). + return openBrowserWalletSession({ + chain, + rpcUrl, + log, + logInfo, + openUrl: params.openUrl, + handleSigint: params.handleSigint, + }); +} diff --git a/src/lib/wallet/spawnDaemon.ts b/src/lib/wallet/spawnDaemon.ts new file mode 100644 index 00000000..79b38087 --- /dev/null +++ b/src/lib/wallet/spawnDaemon.ts @@ -0,0 +1,104 @@ +import fs from "node:fs"; +import {spawn, type SpawnOptions} from "node:child_process"; +import {readDescriptor, isPidAlive, type WalletSessionDescriptor} from "./sessionDescriptor"; +import {WalletSessionClient} from "./sessionClient"; +import {DAEMON_READY_TIMEOUT_MS} from "./sessionConstants"; + +type SpawnFn = ( + command: string, + args: readonly string[], + options: SpawnOptions, +) => {pid?: number; unref(): void}; + +export interface SpawnDaemonParams { + /** Network alias; omitted → daemon uses config network. */ + network?: string; + rpc?: string; + /** Default process.argv[1]; injectable for tests. */ + cliPath?: string; + /** Default process.execPath; injectable for tests. */ + execPath?: string; + /** Daemon log path (configManager.getFilePath("wallet-daemon.log")). */ + logPath: string; + /** Injectable spawn for tests. */ + spawnFn?: SpawnFn; +} + +/** + * Detach-spawn the daemon by re-exec'ing this same bundled CLI with the hidden + * `wallet daemon` subcommand. Re-exec (rather than pointing at a separate entry + * file) is the only mechanism that works uniformly for global installs, npx, + * and `node dist/index.js`, with zero esbuild config changes. + * + * The token is NEVER placed on argv (visible in `ps`); the daemon generates it + * itself and publishes it only via the 0600 descriptor file. + */ +export function spawnWalletDaemon(p: SpawnDaemonParams): number { + const execPath = p.execPath ?? process.execPath; + const cliPath = p.cliPath ?? process.argv[1]; + const spawnFn = p.spawnFn ?? (spawn as unknown as SpawnFn); + + const out = fs.openSync(p.logPath, "a"); + try { + fs.chmodSync(p.logPath, 0o600); + } catch { + // Non-fatal. + } + + const args = [cliPath, "wallet", "daemon"]; + if (p.network) args.push("--network", p.network); + if (p.rpc) args.push("--rpc", p.rpc); + + const child = spawnFn(execPath, args, { + detached: true, + stdio: ["ignore", out, out], + windowsHide: true, + env: process.env, + }); + child.unref(); + fs.closeSync(out); + + if (!child.pid) { + throw new Error("Failed to spawn the wallet-session daemon (no pid)."); + } + return child.pid; +} + +/** + * Poll until the descriptor exists, its pid is live, and its /api/ping answers + * with the token. On timeout, surface the tail of the daemon log to aid debugging. + */ +export async function waitForDaemonReady( + descriptorPath: string, + opts: { + timeoutMs?: number; + logPath?: string; + fetchFn?: typeof fetch; + intervalMs?: number; + } = {}, +): Promise { + const timeoutMs = opts.timeoutMs ?? DAEMON_READY_TIMEOUT_MS; + const intervalMs = opts.intervalMs ?? 200; + const deadline = Date.now() + timeoutMs; + + for (;;) { + const d = readDescriptor(descriptorPath); + if (d && isPidAlive(d.pid)) { + const client = new WalletSessionClient(d, {fetchFn: opts.fetchFn}); + if (await client.ping()) return d; + } + if (Date.now() > deadline) { + let tail = ""; + if (opts.logPath) { + try { + const log = fs.readFileSync(opts.logPath, "utf-8"); + tail = "\n" + log.split("\n").slice(-15).join("\n"); + } catch { + // ignore + } + } + throw new Error(`Wallet-session daemon did not become ready within ${timeoutMs}ms.${tail}`); + } + await new Promise(r => setTimeout(r, intervalMs)); + } +} diff --git a/src/lib/wallet/stakingTx.ts b/src/lib/wallet/stakingTx.ts new file mode 100644 index 00000000..3465e496 --- /dev/null +++ b/src/lib/wallet/stakingTx.ts @@ -0,0 +1,12 @@ +/** + * Back-compat shim. The staking tx builders now live in the generalized + * `txBuilders.ts` (shared across staking + vesting). Re-exported here so the + * PR #367 call sites and tests that import from `stakingTx` keep working. + */ +export { + buildValidatorJoinTx, + buildSetIdentityTx, + extractValidatorWallet, + type BuiltTx, + type IdentityFields, +} from "./txBuilders"; diff --git a/src/lib/wallet/txBuilders.ts b/src/lib/wallet/txBuilders.ts new file mode 100644 index 00000000..70b4e309 --- /dev/null +++ b/src/lib/wallet/txBuilders.ts @@ -0,0 +1,114 @@ +import {encodeFunctionData, decodeEventLog, toHex, type Abi, type TransactionReceipt} from "viem"; +import {abi} from "genlayer-js"; +import type {Address} from "genlayer-js/types"; + +/** + * Pure transaction-building / event-decoding helpers for the browser-wallet + * signing path (Lane A: staking + vesting). These expose only the calldata so + * the CLI can hand `{to, data}` to a browser wallet that signs-and-broadcasts + * (eth_sendTransaction) instead of the SDK's sign-then-sendRawTransaction path + * (which MetaMask cannot satisfy). + * + * Dependency-free and side-effect-free so they are trivially unit-testable. + */ + +export interface BuiltTx { + to: Address; + data: `0x${string}`; +} + +function normalizeAddress(address: string): Address { + return (address.startsWith("0x") ? address : `0x${address}`) as Address; +} + +/** + * Generic calldata builder: `encodeFunctionData` against any ABI + a target. + * Per-command usage is a one-liner; avoids bespoke builders per function. + */ +export function buildTx(abiDef: Abi, to: string, functionName: string, args?: unknown[]): BuiltTx { + const data = encodeFunctionData( + args && args.length > 0 ? {abi: abiDef, functionName, args} : {abi: abiDef, functionName}, + ); + return {to: normalizeAddress(to), data}; +} + +/** + * Encode an `extraCid` identity field to bytes hex. `0x`-prefixed input passes + * through; anything else is UTF-8 encoded; empty/undefined → "0x". Mirrors the + * genlayer-js encoding used by setIdentity / vestingValidatorSetIdentity. + */ +export function encodeExtraCid(extraCid?: string): `0x${string}` { + if (!extraCid) return "0x"; + return extraCid.startsWith("0x") ? (extraCid as `0x${string}`) : toHex(new TextEncoder().encode(extraCid)); +} + +/** + * Build the calldata for `validatorJoin`. Two payable overloads exist: + * `validatorJoin(address _operator)` and `validatorJoin()`. Stake is msg.value + * (carried separately as the tx `value`, not encoded here). + */ +export function buildValidatorJoinTx(stakingAddress: string, operator?: string): BuiltTx { + return buildTx( + abi.STAKING_ABI as unknown as Abi, + stakingAddress, + "validatorJoin", + operator ? [operator as Address] : undefined, + ); +} + +export interface IdentityFields { + moniker: string; + logoUri?: string; + website?: string; + description?: string; + email?: string; + twitter?: string; + telegram?: string; + github?: string; + extraCid?: string; +} + +/** + * Build the calldata for `setIdentity` on a ValidatorWallet contract. + * `to` is the validator wallet address (not the staking contract). + */ +export function buildSetIdentityTx(validatorWallet: string, identity: IdentityFields): BuiltTx { + return buildTx(abi.VALIDATOR_WALLET_ABI as unknown as Abi, validatorWallet, "setIdentity", [ + identity.moniker, + identity.logoUri || "", + identity.website || "", + identity.description || "", + identity.email || "", + identity.twitter || "", + identity.telegram || "", + identity.github || "", + encodeExtraCid(identity.extraCid), + ]); +} + +/** + * Decode the `ValidatorJoin` event from a receipt's logs and return the new + * ValidatorWallet contract address. Throws with the same "event not found" + * style as genlayer-js if no matching log is present. + */ +export function extractValidatorWallet(receipt: TransactionReceipt): Address { + for (const log of receipt.logs) { + try { + const decoded = decodeEventLog({ + abi: abi.STAKING_ABI, + data: log.data, + topics: log.topics, + }); + if (decoded.eventName === "ValidatorJoin") { + return (decoded.args as unknown as {validator: Address}).validator; + } + } catch { + // Not a ValidatorJoin event - keep searching. + } + } + + throw new Error( + `ValidatorJoin event not found in transaction ${receipt.transactionHash}. ` + + `Transaction succeeded but validator wallet address could not be determined.`, + ); +} diff --git a/src/lib/wallet/walletOption.ts b/src/lib/wallet/walletOption.ts new file mode 100644 index 00000000..49cc6e54 --- /dev/null +++ b/src/lib/wallet/walletOption.ts @@ -0,0 +1,23 @@ +import type {Command} from "commander"; + +/** + * Shared registrar for the `--wallet ` signing-mode flag. Applied to every + * write command so keystore (default) and browser (MetaMask via local bridge) + * are selectable uniformly. Mutual exclusion with --password/--account is + * enforced in the Action layer (BaseAction.assertWalletFlags), not commander, + * so it stays testable and reusable. + */ +export const WALLET_OPTION_FLAG = "--wallet "; +export const WALLET_OPTION_DESC = + "Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; " + + "forward the port for remote/SSH: ssh -L :127.0.0.1:). " + + "Defaults to the 'walletMode' config value, else 'keystore'."; + +/** + * No commander default: with one, "flag omitted" is indistinguishable from an + * explicit "--wallet keystore", which would break the config-default override. + * The default is resolved in BaseAction.resolveWalletMode (config > keystore). + */ +export function addWalletModeOption(cmd: Command): Command { + return cmd.option(WALLET_OPTION_FLAG, WALLET_OPTION_DESC); +} diff --git a/tests/actions/balances.test.ts b/tests/actions/balances.test.ts new file mode 100644 index 00000000..53f19464 --- /dev/null +++ b/tests/actions/balances.test.ts @@ -0,0 +1,247 @@ +import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import {createClient} from "genlayer-js"; +import {testnetBradbury} from "genlayer-js/chains"; +import {BalancesAction} from "../../src/commands/balances/BalancesAction"; + +// Keep genlayer-js real except createClient (no network I/O). The read-only +// vesting client is stubbed per-test, so createClient is never actually hit. +vi.mock("genlayer-js", async importOriginal => { + const actual = await importOriginal(); + return {...actual, createClient: vi.fn()}; +}); + +const WEI = 10n ** 18n; + +// Only the fields BalancesAction reads matter; the client is mocked so the +// shape isn't type-checked at runtime. +function makeState(overrides: Record = {}) { + return { + name: "Team grant", + totalAmountRaw: 100n * WEI, + vestedAmountRaw: 20n * WEI, + unvestedAmountRaw: 80n * WEI, + withdrawableAmountRaw: 18n * WEI, + totalWithdrawnRaw: 2n * WEI, + revoked: false, + ...overrides, + }; +} + +function makeClient(overrides: Record = {}) { + return { + getBalance: vi.fn().mockResolvedValue(7n * WEI), + getBeneficiaryVestings: vi.fn().mockResolvedValue([]), + getVestingState: vi.fn(), + getValidatorWallets: vi.fn().mockResolvedValue([]), + validatorDeposited: vi.fn().mockResolvedValue(0n), + getActiveValidators: vi.fn().mockResolvedValue([]), + vestingDepositedPerValidator: vi.fn().mockResolvedValue(0n), + ...overrides, + }; +} + +describe("BalancesAction", () => { + let tempHome: string; + let action: BalancesAction; + let failSpy: any; + let renderSpy: any; + + beforeEach(() => { + vi.clearAllMocks(); + // Own hermetic home so real config/keystore reads stay isolated per test. + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "gl-cli-balances-")); + vi.spyOn(os, "homedir").mockReturnValue(tempHome); + vi.mocked(createClient).mockReturnValue({} as any); + + action = new BalancesAction(); + + vi.spyOn(action as any, "startSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "setSpinnerText").mockImplementation(() => {}); + vi.spyOn(action as any, "stopSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "succeedSpinner").mockImplementation(() => {}); + failSpy = vi.spyOn(action as any, "failSpinner").mockImplementation(() => {}); + // Capture the composed summary instead of asserting brittle console output. + renderSpy = vi.spyOn(action as any, "renderSummary").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tempHome, {recursive: true, force: true}); + }); + + function stub(client: any) { + vi.spyOn(action as any, "getReadOnlyVestingClient").mockResolvedValue(client); + } + + test("(a) address with no vesting contracts → wallet-only summary", async () => { + const client = makeClient({getBeneficiaryVestings: vi.fn().mockResolvedValue([])}); + stub(client); + vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xBen"); + + await action.execute({}); + + expect(failSpy).not.toHaveBeenCalled(); + const summary = renderSpy.mock.calls[0][0]; + expect(summary.address).toBe("0xBen"); + expect(summary.walletBalanceRaw).toBe(7n * WEI); + expect(summary.vestings).toEqual([]); + // No vesting → never touches vesting state / validator enumeration. + expect(client.getVestingState).not.toHaveBeenCalled(); + expect(client.getActiveValidators).not.toHaveBeenCalled(); + }); + + test("(b) one vesting: committed principal computed; available is the contract balance", async () => { + const client = makeClient({ + getBeneficiaryVestings: vi.fn().mockResolvedValue(["0xV1"]), + getVestingState: vi.fn().mockResolvedValue(makeState()), + getValidatorWallets: vi.fn().mockResolvedValue(["0xW1"]), + validatorDeposited: vi.fn().mockResolvedValue(5n * WEI), // self-stake principal 5 + getActiveValidators: vi.fn().mockResolvedValue(["0xVal1"]), + vestingDepositedPerValidator: vi.fn().mockResolvedValue(4n * WEI), // delegated principal 4 + // Wallet reads 7; the vesting contract's live on-chain balance is 30. + getBalance: vi.fn(async ({address}: {address: string}) => (address === "0xV1" ? 30n * WEI : 7n * WEI)), + }); + stub(client); + vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xBen"); + + await action.execute({}); + + expect(failSpy).not.toHaveBeenCalled(); + const summary = renderSpy.mock.calls[0][0]; + expect(summary.vestings).toHaveLength(1); + const v = summary.vestings[0]; + expect(v.selfStakeRaw).toBe(5n * WEI); + expect(v.delegatedRaw).toBe(4n * WEI); + expect(v.committedRaw).toBe(9n * WEI); + // available = the vesting contract's live balance, NOT vested−withdrawn−committed. + expect(v.availableToStakeRaw).toBe(30n * WEI); + expect(v.revoked).toBe(false); + // Delegated principal getter takes (vesting, validator); self takes (vesting, wallet). + expect(client.vestingDepositedPerValidator).toHaveBeenCalledWith("0xV1", "0xVal1"); + expect(client.validatorDeposited).toHaveBeenCalledWith("0xV1", "0xW1"); + expect(client.getBalance).toHaveBeenCalledWith({address: "0xV1"}); + }); + + test("(b') revoked vesting → available is 0 even though the contract still holds a balance", async () => { + const client = makeClient({ + getBeneficiaryVestings: vi.fn().mockResolvedValue(["0xV1"]), + getVestingState: vi.fn().mockResolvedValue(makeState({revoked: true})), + getValidatorWallets: vi.fn().mockResolvedValue(["0xW1"]), + validatorDeposited: vi.fn().mockResolvedValue(10n * WEI), // still-committed principal + getActiveValidators: vi.fn().mockResolvedValue([]), + // Non-zero balance, but staking is disabled post-revoke ⇒ available must be 0. + getBalance: vi.fn().mockResolvedValue(50n * WEI), + }); + stub(client); + vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xBen"); + + await action.execute({}); + + const v = renderSpy.mock.calls[0][0].vestings[0]; + expect(v.revoked).toBe(true); + expect(v.committedRaw).toBe(10n * WEI); // committed breakdown still shown + expect(v.availableToStakeRaw).toBe(0n); // revoked ⇒ 0, not the 50 balance + }); + + test("(c) multiple vesting contracts each summarized; validator set fetched once", async () => { + const stateA = makeState({name: "A"}); + const stateB = makeState({name: "B"}); + const client = makeClient({ + getBeneficiaryVestings: vi.fn().mockResolvedValue(["0xVA", "0xVB"]), + getVestingState: vi.fn().mockImplementation((addr: string) => (addr === "0xVA" ? stateA : stateB)), + getValidatorWallets: vi.fn().mockResolvedValue([]), + getActiveValidators: vi.fn().mockResolvedValue([]), + // Each contract's available-to-stake is its own live on-chain balance. + getBalance: vi.fn(async ({address}: {address: string}) => + (({"0xVA": 20n * WEI, "0xVB": 45n * WEI}) as Record)[address] ?? 7n * WEI, + ), + }); + stub(client); + vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xBen"); + + await action.execute({}); + + const summary = renderSpy.mock.calls[0][0]; + expect(summary.vestings).toHaveLength(2); + expect(summary.vestings[0].name).toBe("A"); + expect(summary.vestings[0].availableToStakeRaw).toBe(20n * WEI); // balance of 0xVA + expect(summary.vestings[1].name).toBe("B"); + expect(summary.vestings[1].availableToStakeRaw).toBe(45n * WEI); // balance of 0xVB + // Active validator set is global: fetched once and reused across vestings. + expect(client.getActiveValidators).toHaveBeenCalledTimes(1); + }); + + test("(d) custom active network shows alias + chainId, not the base chain name", async () => { + action.writeConfig("customNetworks", { + myclarke: {base: "testnet-bradbury", overrides: {chainId: 4221, rpcUrl: "http://localhost:9999"}}, + }); + action.writeConfig("network", "myclarke"); + + const client = makeClient({getBeneficiaryVestings: vi.fn().mockResolvedValue([])}); + stub(client); + vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xBen"); + + await action.execute({}); + + expect(failSpy).not.toHaveBeenCalled(); + const summary = renderSpy.mock.calls[0][0]; + expect(summary.network).toBe("myclarke"); + expect(summary.chainId).toBe(4221); + // The naive bug would print chain.name, which for a custom net is its base's name. + expect(summary.network).not.toBe(testnetBradbury.name); + }); + + test("(e) --beneficiary override needs no account (getSignerAddress not called)", async () => { + const client = makeClient({getBeneficiaryVestings: vi.fn().mockResolvedValue([])}); + stub(client); + // If the code fell back to the keystore it would reject here. + const signerSpy = vi + .spyOn(action as any, "getSignerAddress") + .mockRejectedValue(new Error("Account 'default' not found.")); + + await action.execute({beneficiary: "0xExplicit"}); + + expect(failSpy).not.toHaveBeenCalled(); + const summary = renderSpy.mock.calls[0][0]; + expect(summary.address).toBe("0xExplicit"); + expect(client.getBeneficiaryVestings).toHaveBeenCalledWith("0xExplicit", undefined); + expect(client.getBalance).toHaveBeenCalledWith({address: "0xExplicit"}); + expect(signerSpy).not.toHaveBeenCalled(); + }); + + test("(f) live browser session is the active identity (wins over the keystore default)", async () => { + const client = makeClient({getBeneficiaryVestings: vi.fn().mockResolvedValue([])}); + stub(client); + // A session is live and no keystore opt-out, so resolveWalletMode → browser. + vi.spyOn(action as any, "resolveWalletMode").mockReturnValue("browser"); + const sessionSpy = vi.spyOn(action as any, "liveSessionAddress").mockResolvedValue("0xSession"); + const signerSpy = vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xKeystore"); + + await action.execute({}); + + expect(failSpy).not.toHaveBeenCalled(); + const summary = renderSpy.mock.calls[0][0]; + expect(summary.address).toBe("0xSession"); + expect(client.getBeneficiaryVestings).toHaveBeenCalledWith("0xSession", undefined); + expect(sessionSpy).toHaveBeenCalled(); + // The keystore default must not be consulted once a live session resolves. + expect(signerSpy).not.toHaveBeenCalled(); + }); + + test("(g) explicit --account overrides a live session", async () => { + const client = makeClient({getBeneficiaryVestings: vi.fn().mockResolvedValue([])}); + stub(client); + const sessionSpy = vi.spyOn(action as any, "liveSessionAddress").mockResolvedValue("0xSession"); + vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xKeystore"); + + await action.execute({account: "clarke"}); + + const summary = renderSpy.mock.calls[0][0]; + expect(summary.address).toBe("0xKeystore"); + // --account short-circuits before the session is ever consulted. + expect(sessionSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/actions/customNetworkProfiles.test.ts b/tests/actions/customNetworkProfiles.test.ts index a9e6f0f8..880af1fb 100644 --- a/tests/actions/customNetworkProfiles.test.ts +++ b/tests/actions/customNetworkProfiles.test.ts @@ -261,6 +261,9 @@ describe("custom network profiles", () => { expect(resolvedChain.consensusMainContract.address).toBe(ADDR_1); expect(resolvedChain.consensusMainContract.abi).toBe(baseChain.consensusMainContract.abi); expect(resolvedChain.consensusDataContract.abi).toBe(baseChain.consensusDataContract.abi); + // Display name is the alias the user chose, not the base chain's name. + expect(resolvedChain.name).toBe("bradbury-clarke"); + expect(resolvedChain.name).not.toBe(baseChain.name); }); test("StakingAction.getNetwork accepts a custom alias", () => { diff --git a/tests/actions/deploy.test.ts b/tests/actions/deploy.test.ts index b6453055..35ba7877 100644 --- a/tests/actions/deploy.test.ts +++ b/tests/actions/deploy.test.ts @@ -690,4 +690,46 @@ describe("DeployAction", () => { rpcUrl, ); }); + + describe("DeployAction --wallet browser", () => { + test("wires the browser provider into the client and never touches the keystore", async () => { + const session = { + signerAddress: "0xBrowser", + eip1193Provider: {request: vi.fn()}, + setNextLabel: vi.fn(), + close: vi.fn().mockResolvedValue(undefined), + }; + // Lane B: getClient (BaseAction) builds the client itself. Stub the browser + // session opener so the real getClient runs, then assert the wiring. + const getBrowserSessionSpy = vi + .spyOn(deployer as any, "getBrowserSession") + .mockResolvedValue(session); + const getAccountSpy = vi.spyOn(deployer as any, "getAccount"); + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue("contract code"); + vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "ACCEPTED", + txExecutionResultName: "FINISHED_WITH_RETURN", + data: {contract_address: "0xdeployed"}, + }); + + await deployer.deploy({contract: "/x.py", args: [], wallet: "browser"}); + + expect(getBrowserSessionSpy).toHaveBeenCalled(); + expect(createClient).toHaveBeenCalledWith( + expect.objectContaining({ + account: "0xBrowser", + provider: session.eip1193Provider, + }), + ); + // No keystore/keychain/password path in browser mode. + expect(getAccountSpy).not.toHaveBeenCalled(); + expect(deployer["succeedSpinner"]).toHaveBeenCalledWith( + "Contract deployed successfully.", + expect.objectContaining({"Consensus Status": "ACCEPTED"}), + ); + }); + }); }); diff --git a/tests/actions/hasLiveWalletSession.test.ts b/tests/actions/hasLiveWalletSession.test.ts new file mode 100644 index 00000000..540fde9e --- /dev/null +++ b/tests/actions/hasLiveWalletSession.test.ts @@ -0,0 +1,55 @@ +import {describe, test, expect, beforeEach, afterEach, vi} from "vitest"; + +// Control the descriptor/pid primitives the helper delegates to. +vi.mock("../../src/lib/wallet/sessionDescriptor", () => ({ + descriptorPath: vi.fn(() => "/tmp/wallet-session.json"), + readDescriptor: vi.fn(), + isPidAlive: vi.fn(), +})); + +import {BaseAction} from "../../src/lib/actions/BaseAction"; +import {readDescriptor, isPidAlive} from "../../src/lib/wallet/sessionDescriptor"; + +class TestAction extends BaseAction { + publicHasLiveSession() { + return (this as any).hasLiveWalletSession(); + } +} + +describe("BaseAction.hasLiveWalletSession", () => { + let action: TestAction; + + beforeEach(() => { + action = new TestAction(); + vi.mocked(readDescriptor).mockReset(); + vi.mocked(isPidAlive).mockReset(); + }); + afterEach(() => vi.restoreAllMocks()); + + test("no descriptor → false (pid never checked)", () => { + vi.mocked(readDescriptor).mockReturnValue(null); + expect(action.publicHasLiveSession()).toBe(false); + expect(isPidAlive).not.toHaveBeenCalled(); + }); + + test("descriptor present + pid alive → true", () => { + vi.mocked(readDescriptor).mockReturnValue({pid: 4242} as any); + vi.mocked(isPidAlive).mockReturnValue(true); + expect(action.publicHasLiveSession()).toBe(true); + expect(isPidAlive).toHaveBeenCalledWith(4242); + }); + + test("descriptor present but pid dead → false", () => { + vi.mocked(readDescriptor).mockReturnValue({pid: 4242} as any); + vi.mocked(isPidAlive).mockReturnValue(false); + expect(action.publicHasLiveSession()).toBe(false); + }); + + test("a throwing descriptor read reads as no session (never throws)", () => { + vi.mocked(readDescriptor).mockImplementation(() => { + throw new Error("locked file"); + }); + expect(() => action.publicHasLiveSession()).not.toThrow(); + expect(action.publicHasLiveSession()).toBe(false); + }); +}); diff --git a/tests/actions/show.test.ts b/tests/actions/show.test.ts new file mode 100644 index 00000000..48f5b74c --- /dev/null +++ b/tests/actions/show.test.ts @@ -0,0 +1,94 @@ +import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import {createClient} from "genlayer-js"; +import {testnetBradbury} from "genlayer-js/chains"; +import {ShowAccountAction} from "../../src/commands/account/show"; + +// Keep genlayer-js real except createClient (no network I/O in the balance query). +vi.mock("genlayer-js", async importOriginal => { + const actual = await importOriginal(); + return {...actual, createClient: vi.fn()}; +}); + +describe("ShowAccountAction network label", () => { + let tempHome: string; + let action: ShowAccountAction; + let succeedSpy: any; + let failSpy: any; + + const address = "0x1234567890123456789012345678901234567890"; + const keystoreJson = JSON.stringify({ + address, + crypto: { + cipher: "aes-128-ctr", + ciphertext: "x", + cipherparams: {iv: "x"}, + kdf: "scrypt", + kdfparams: {}, + mac: "x", + }, + version: 3, + }); + const mockClient = {getBalance: vi.fn()}; + + beforeEach(() => { + vi.clearAllMocks(); + // Own hermetic home so real config/keystore reads stay isolated. + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "gl-cli-show-")); + vi.spyOn(os, "homedir").mockReturnValue(tempHome); + + mockClient.getBalance.mockResolvedValue(0n); + vi.mocked(createClient).mockReturnValue(mockClient as any); + + action = new ShowAccountAction(); + fs.writeFileSync(action.getKeystorePath("default"), keystoreJson); + + succeedSpy = vi.spyOn(action as any, "succeedSpinner").mockImplementation(() => {}); + failSpy = vi.spyOn(action as any, "failSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "startSpinner").mockImplementation(() => {}); + vi.spyOn((action as any).keychainManager, "isAccountUnlocked").mockResolvedValue(false); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tempHome, {recursive: true, force: true}); + }); + + test("custom active network shows its alias and real chainId, not the base chain name", async () => { + action.writeConfig("customNetworks", { + myclarke: {base: "testnet-bradbury", overrides: {chainId: 4221, rpcUrl: "http://localhost:9999"}}, + }); + action.writeConfig("network", "myclarke"); + action.setActiveAccount("default"); + + await action.execute({}); + + expect(failSpy).not.toHaveBeenCalled(); + const result = succeedSpy.mock.calls[0][1]; + expect(result.network).toBe("myclarke"); + expect(result.chainId).toBe(4221); + // The old bug printed chain.name, which for a custom net is its base's name. + expect(result.network).not.toBe(testnetBradbury.name); + }); + + test("--network overrides the active config network for label, chainId and balance query", async () => { + action.writeConfig("customNetworks", { + myclarke: {base: "testnet-bradbury", overrides: {chainId: 4221}}, + }); + action.writeConfig("network", "localnet"); + action.setActiveAccount("default"); + + await action.execute({network: "myclarke"}); + + expect(failSpy).not.toHaveBeenCalled(); + const result = succeedSpy.mock.calls[0][1]; + expect(result.network).toBe("myclarke"); + expect(result.chainId).toBe(4221); + // The balance query must use the override network, not the config one. + expect(createClient).toHaveBeenCalledWith( + expect.objectContaining({chain: expect.objectContaining({id: 4221})}), + ); + }); +}); diff --git a/tests/actions/staking.test.ts b/tests/actions/staking.test.ts index 3d314c5c..a67ce704 100644 --- a/tests/actions/staking.test.ts +++ b/tests/actions/staking.test.ts @@ -6,6 +6,7 @@ import {ValidatorClaimAction} from "../../src/commands/staking/validatorClaim"; import {DelegatorJoinAction} from "../../src/commands/staking/delegatorJoin"; import {DelegatorExitAction} from "../../src/commands/staking/delegatorExit"; import {DelegatorClaimAction} from "../../src/commands/staking/delegatorClaim"; +import {SetOperatorAction} from "../../src/commands/staking/setOperator"; import {StakingInfoAction} from "../../src/commands/staking/stakingInfo"; // Mock genlayer-js @@ -21,14 +22,38 @@ vi.mock("genlayer-js", () => ({ }), abi: { STAKING_ABI: [], + VALIDATOR_WALLET_ABI: [], }, })); +// buildTx is used by the browser-wallet paths of ValidatorDeposit/SetOperator/ +// DelegatorClaim. The genlayer-js mock stubs the ABIs with [], so mock the pure +// tx-builder helper too (real behavior covered in tests/libs/txBuilders.test.ts). +vi.mock("../../src/lib/wallet/txBuilders", () => ({ + buildTx: vi.fn(() => ({to: "0xTarget", data: "0xdata"})), +})); + vi.mock("genlayer-js/chains", () => ({ localnet: {id: 1, name: "localnet", rpcUrls: {default: {http: ["http://localhost:8545"]}}}, studionet: {id: 2, name: "studionet", rpcUrls: {default: {http: ["https://studionet.genlayer.com"]}}}, - testnetAsimov: {id: 3, name: "testnet-asimov", rpcUrls: {default: {http: ["https://testnet.genlayer.com"]}}}, - testnetBradbury: {id: 4, name: "testnet-bradbury", rpcUrls: {default: {http: ["https://testnet.genlayer.com"]}}}, + testnetAsimov: { + id: 3, + name: "testnet-asimov", + rpcUrls: {default: {http: ["https://testnet.genlayer.com"]}}, + }, + testnetBradbury: { + id: 4, + name: "testnet-bradbury", + rpcUrls: {default: {http: ["https://testnet.genlayer.com"]}}, + }, +})); + +// The genlayer-js mock above stubs `abi` with an empty ABI, so mock the pure +// tx-builder module (its real behavior is covered in tests/libs/stakingTx.test.ts). +vi.mock("../../src/lib/wallet/stakingTx", () => ({ + buildValidatorJoinTx: vi.fn(() => ({to: "0xStaking", data: "0xdata"})), + buildSetIdentityTx: vi.fn(() => ({to: "0xValidatorWallet", data: "0xidentity"})), + extractValidatorWallet: vi.fn(() => "0xValidatorWalletFromEvent"), })); const mockTxResult = { @@ -101,7 +126,10 @@ describe("ValidatorJoinAction", () => { amount: expect.any(BigInt), operator: undefined, }); - expect(action["succeedSpinner"]).toHaveBeenCalledWith("Validator created successfully!", expect.any(Object)); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Validator created successfully!", + expect.any(Object), + ); }); test("joins as validator with operator", async () => { @@ -143,7 +171,10 @@ describe("DelegatorJoinAction", () => { validator: "0xValidator", amount: expect.any(BigInt), }); - expect(action["succeedSpinner"]).toHaveBeenCalledWith("Successfully joined as delegator!", expect.any(Object)); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Successfully joined as delegator!", + expect.any(Object), + ); }); }); @@ -179,7 +210,10 @@ describe("DelegatorClaimAction", () => { test("claims successfully", async () => { await action.execute({validator: "0xValidator", delegator: "0xDelegator", stakingAddress: "0xStaking"}); - expect(mockClient.delegatorClaim).toHaveBeenCalledWith({validator: "0xValidator", delegator: "0xDelegator"}); + expect(mockClient.delegatorClaim).toHaveBeenCalledWith({ + validator: "0xValidator", + delegator: "0xDelegator", + }); expect(action["succeedSpinner"]).toHaveBeenCalledWith("Claim successful!", expect.any(Object)); }); }); @@ -278,3 +312,275 @@ describe("StakingInfoAction", () => { }); }); }); + +describe("ValidatorJoinAction --wallet browser", () => { + let action: ValidatorJoinAction; + const mockReceipt = { + transactionHash: "0xBrowserHash", + blockNumber: 456n, + gasUsed: 30000n, + status: "success", + }; + + beforeEach(() => { + vi.clearAllMocks(); + action = new ValidatorJoinAction(); + vi.spyOn(action as any, "startSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "setSpinnerText").mockImplementation(() => {}); + vi.spyOn(action as any, "succeedSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "failSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("routes through the browser session and never touches the keystore", async () => { + const getStakingClientSpy = vi.spyOn(action as any, "getStakingClient"); + const getSignerAddressSpy = vi.spyOn(action as any, "getSignerAddress"); + // The command's finally calls session.close() (no-op for remote daemon + // sessions, full close for an own bridge) — not session.bridge.close(). + const close = vi.fn().mockResolvedValue(undefined); + const sendTransaction = vi.fn().mockResolvedValue(mockReceipt); + vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue({ + bridge: {close: vi.fn()}, + close, + stakingAddress: "0xStaking", + signerAddress: "0xBrowserOwner", + sendTransaction, + }); + + await action.execute({amount: "42000gen", wallet: "browser", stakingAddress: "0xStaking"}); + + expect((action as any).getBrowserWalletSession).toHaveBeenCalledWith( + expect.any(Object), + "validator-join", + ); + expect(sendTransaction).toHaveBeenCalledOnce(); + expect(getStakingClientSpy).not.toHaveBeenCalled(); + expect(getSignerAddressSpy).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledOnce(); + + // Output shape matches the keystore path. + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Validator created successfully!", + expect.objectContaining({ + transactionHash: "0xBrowserHash", + validatorWallet: "0xValidatorWalletFromEvent", + operator: "0xBrowserOwner", + blockNumber: "456", + gasUsed: "30000", + }), + ); + }); + + test("closes the session even when the send fails", async () => { + const close = vi.fn().mockResolvedValue(undefined); + vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue({ + bridge: {close: vi.fn()}, + close, + stakingAddress: "0xStaking", + signerAddress: "0xBrowserOwner", + sendTransaction: vi.fn().mockRejectedValue(new Error("Transaction rejected in wallet")), + }); + + await action.execute({amount: "42000gen", wallet: "browser", stakingAddress: "0xStaking"}); + + expect(action["failSpinner"]).toHaveBeenCalledWith( + "Failed to create validator", + "Transaction rejected in wallet", + ); + expect(close).toHaveBeenCalledOnce(); + }); + + test("rejects --wallet browser combined with --password", async () => { + await action.execute({amount: "42000gen", wallet: "browser", password: "hunter2"}); + + expect(action["failSpinner"]).toHaveBeenCalledWith( + "Failed to create validator", + "--password cannot be used with --wallet browser", + ); + }); + + test("rejects --wallet browser combined with --account", async () => { + await action.execute({amount: "42000gen", wallet: "browser", account: "owner"}); + + expect(action["failSpinner"]).toHaveBeenCalledWith( + "Failed to create validator", + "--account selects a keystore; not applicable with --wallet browser", + ); + }); +}); + +// Shared factory for a staking browser-wallet session. These commands call +// `session.close()` (not session.bridge.close()) in their finally block. +function makeBrowserSession(overrides: Record = {}) { + return { + bridge: {close: vi.fn()}, + stakingAddress: "0xStaking", + signerAddress: "0xBrowserOwner", + sendTransaction: vi.fn().mockResolvedValue({ + transactionHash: "0xBH", + blockNumber: 5n, + gasUsed: 6n, + status: "success", + }), + close: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function setupBrowserActionMocks(action: any) { + vi.spyOn(action, "startSpinner").mockImplementation(() => {}); + vi.spyOn(action, "setSpinnerText").mockImplementation(() => {}); + vi.spyOn(action, "succeedSpinner").mockImplementation(() => {}); + vi.spyOn(action, "failSpinner").mockImplementation(() => {}); + vi.spyOn(action, "log").mockImplementation(() => {}); +} + +describe("ValidatorDepositAction --wallet browser", () => { + let action: ValidatorDepositAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new ValidatorDepositAction(); + setupBrowserActionMocks(action); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("routes through the browser session, skips keystore, closes session", async () => { + const getStakingClientSpy = vi.spyOn(action as any, "getStakingClient"); + const getReadOnlyStakingClientSpy = vi.spyOn(action as any, "getReadOnlyStakingClient"); + const getSignerAddressSpy = vi.spyOn(action as any, "getSignerAddress"); + const session = makeBrowserSession(); + vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue(session); + + await action.execute({validator: "0xVW", amount: "1gen", wallet: "browser"}); + + expect(session.sendTransaction).toHaveBeenCalledOnce(); + expect(getStakingClientSpy).not.toHaveBeenCalled(); + expect(getReadOnlyStakingClientSpy).not.toHaveBeenCalled(); + expect(getSignerAddressSpy).not.toHaveBeenCalled(); + expect(session.close).toHaveBeenCalledOnce(); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Deposit successful!", + expect.objectContaining({ + transactionHash: "0xBH", + validator: "0xVW", + amount: expect.any(String), + blockNumber: "5", + gasUsed: "6", + }), + ); + }); + + test("closes the session even when the send fails", async () => { + const session = makeBrowserSession({ + sendTransaction: vi.fn().mockRejectedValue(new Error("Rejected in wallet")), + }); + vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue(session); + + await action.execute({validator: "0xVW", amount: "1gen", wallet: "browser"}); + + expect(action["failSpinner"]).toHaveBeenCalledWith("Failed to make deposit", "Rejected in wallet"); + expect(session.close).toHaveBeenCalledOnce(); + }); + + test("rejects --wallet browser combined with --password", async () => { + vi.spyOn(action as any, "getBrowserWalletSession").mockRejectedValue( + new Error("--password cannot be used with --wallet browser"), + ); + + await action.execute({validator: "0xVW", amount: "1gen", wallet: "browser", password: "x"}); + + expect(action["failSpinner"]).toHaveBeenCalledWith( + "Failed to make deposit", + expect.stringContaining("--password cannot be used with --wallet browser"), + ); + }); +}); + +describe("SetOperatorAction --wallet browser", () => { + let action: SetOperatorAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new SetOperatorAction(); + setupBrowserActionMocks(action); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("routes through the browser session, skips keystore, closes session", async () => { + const getStakingClientSpy = vi.spyOn(action as any, "getStakingClient"); + const getReadOnlyStakingClientSpy = vi.spyOn(action as any, "getReadOnlyStakingClient"); + const getSignerAddressSpy = vi.spyOn(action as any, "getSignerAddress"); + const session = makeBrowserSession(); + vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue(session); + + await action.execute({validator: "0xVW", operator: "0xOp", wallet: "browser"}); + + expect(session.sendTransaction).toHaveBeenCalledOnce(); + expect(getStakingClientSpy).not.toHaveBeenCalled(); + expect(getReadOnlyStakingClientSpy).not.toHaveBeenCalled(); + expect(getSignerAddressSpy).not.toHaveBeenCalled(); + expect(session.close).toHaveBeenCalledOnce(); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Operator updated!", + expect.objectContaining({ + transactionHash: "0xBH", + validator: "0xVW", + newOperator: "0xOp", + blockNumber: "5", + gasUsed: "6", + }), + ); + }); +}); + +describe("DelegatorClaimAction --wallet browser", () => { + let action: DelegatorClaimAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new DelegatorClaimAction(); + setupBrowserActionMocks(action); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("routes through the browser session, defaults delegator to session signer", async () => { + const getStakingClientSpy = vi.spyOn(action as any, "getStakingClient"); + const getReadOnlyStakingClientSpy = vi.spyOn(action as any, "getReadOnlyStakingClient"); + const getSignerAddressSpy = vi.spyOn(action as any, "getSignerAddress"); + const session = makeBrowserSession(); + vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue(session); + + await action.execute({validator: "0xVal", wallet: "browser"}); + + expect(session.sendTransaction).toHaveBeenCalledOnce(); + expect(getStakingClientSpy).not.toHaveBeenCalled(); + expect(getReadOnlyStakingClientSpy).not.toHaveBeenCalled(); + // Browser mode reads the connected wallet from the session, never the keystore. + expect(getSignerAddressSpy).not.toHaveBeenCalled(); + expect(session.close).toHaveBeenCalledOnce(); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Claim successful!", + expect.objectContaining({ + transactionHash: "0xBH", + delegator: "0xBrowserOwner", + validator: "0xVal", + blockNumber: "5", + gasUsed: "6", + }), + ); + }); +}); diff --git a/tests/actions/stakingWizard.test.ts b/tests/actions/stakingWizard.test.ts new file mode 100644 index 00000000..3831f1d6 --- /dev/null +++ b/tests/actions/stakingWizard.test.ts @@ -0,0 +1,417 @@ +import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; +import inquirer from "inquirer"; +import {ValidatorWizardAction} from "../../src/commands/staking/wizard"; +import {CreateAccountAction} from "../../src/commands/account/create"; +import {ExportAccountAction} from "../../src/commands/account/export"; +import {buildTx} from "../../src/lib/wallet/txBuilders"; + +vi.mock("inquirer"); +vi.mock("../../src/commands/account/create"); +vi.mock("../../src/commands/account/export"); + +const fsMock = vi.hoisted(() => ({ + readFileSync: vi.fn(() => JSON.stringify({address: "0xOperatorAddr"})), + existsSync: vi.fn(() => false), +})); +vi.mock("fs", async importOriginal => { + const actual = await importOriginal(); + const merged = {...actual, ...fsMock}; + return {...merged, default: merged}; +}); + +// genlayer-js: the wizard balance-check uses createClient(...).getBalance/getEpochInfo. +// The vesting funding source additionally reads getBeneficiaryVestings / +// getVestingState / getValidatorWallets off the same (account-less) client. +// NOTE: implementations are passed to vi.fn() (not via a later .mockResolvedValue) +// so afterEach's vi.restoreAllMocks() reverts to THESE defaults rather than to an +// empty mock — the same reason `createClient: vi.fn(() => mockGlClient)` survives. +const mockGlClient = { + getBalance: vi.fn(async () => 1000n * 10n ** 18n), + getEpochInfo: vi.fn(async () => ({ + validatorMinStakeRaw: 42n * 10n ** 18n, + validatorMinStake: "42 GEN", + currentEpoch: 5n, + })), + getBeneficiaryVestings: vi.fn(async (_beneficiary?: string) => ["0xVesting"]), + getVestingState: vi.fn(async () => ({ + totalAmountRaw: 100n * 10n ** 18n, + totalWithdrawnRaw: 0n, + })), + getValidatorWallets: vi.fn(async () => ["0xVWallet"]), +}; + +vi.mock("genlayer-js", () => ({ + createClient: vi.fn(() => mockGlClient), + createAccount: vi.fn(() => ({address: "0xMockedAddress"})), + formatStakingAmount: vi.fn((val: bigint) => `${Number(val) / 1e18} GEN`), + parseStakingAmount: vi.fn((val: string) => { + const cleaned = val.toLowerCase().replace(/gen|eth/g, ""); + return BigInt(Math.floor(parseFloat(cleaned) * 1e18)); + }), + abi: {STAKING_ABI: [], VESTING_ABI: []}, +})); + +// Pure tx-builders (real behavior covered in tests/libs/stakingTx.test.ts). +vi.mock("../../src/lib/wallet/stakingTx", () => ({ + buildValidatorJoinTx: vi.fn(() => ({to: "0xStaking", data: "0xjoin"})), + buildSetIdentityTx: vi.fn(() => ({to: "0xValidatorWallet", data: "0xidentity"})), + extractValidatorWallet: vi.fn(() => "0xValidatorWalletFromEvent"), +})); + +// Generic vesting calldata builder (real behavior covered in tests/libs/txBuilders.test.ts). +vi.mock("../../src/lib/wallet/txBuilders", () => ({ + buildTx: vi.fn(() => ({to: "0xVesting", data: "0xvestingjoin"})), +})); + +describe("ValidatorWizardAction --wallet browser (owner)", () => { + let action: ValidatorWizardAction; + let sendTransaction: ReturnType; + let bridgeClose: ReturnType; + let getBrowserWalletSessionSpy: any; + let getStakingClientSpy: any; + + beforeEach(() => { + vi.clearAllMocks(); + action = new ValidatorWizardAction(); + + // Silence spinners/logs. + for (const m of [ + "startSpinner", + "setSpinnerText", + "succeedSpinner", + "failSpinner", + "stopSpinner", + "logInfo", + "logWarning", + "logError", + "log", + ]) { + vi.spyOn(action as any, m).mockImplementation(() => {}); + } + + // Network resolution -> a minimal chain with a staking contract. + vi.spyOn(action as any, "getCustomNetworks").mockReturnValue({}); + vi.spyOn(action as any, "getConfigByKey").mockReturnValue("testnet-bradbury"); + vi.spyOn(action as any, "writeConfig").mockImplementation(() => {}); + + // Browser session seam. + sendTransaction = vi.fn().mockResolvedValue({ + transactionHash: "0xJoinHash", + blockNumber: 10n, + gasUsed: 1000n, + status: "success", + }); + bridgeClose = vi.fn().mockResolvedValue(undefined); + getBrowserWalletSessionSpy = vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue({ + bridge: {close: bridgeClose}, + kind: "local", + sessionUrl: "http://127.0.0.1:1/#s=t", + stakingAddress: "0xStaking", + signerAddress: "0xBrowserOwner", + sendTransaction, + // The wizard finally block now calls session.close() (no-op for remote, + // full close for own bridge). Delegate to bridgeClose so the assertion holds. + close: bridgeClose, + }); + + // Ensure the keystore staking path is never exercised. + getStakingClientSpy = vi.spyOn(action as any, "getStakingClient"); + + // CreateAccount / ExportAccount are mocked classes. + vi.mocked(CreateAccountAction.prototype.execute).mockResolvedValue(undefined as any); + vi.mocked(ExportAccountAction.prototype.execute).mockResolvedValue(undefined as any); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("routes join through the bridge, keeps operator export, never uses keystore", async () => { + // Prompt sequence: + // funding source -> wallet (default; original flow unchanged) + // step4 useOperator -> true; operatorChoice -> create; operatorName -> "op"; + // export filename -> default; export password x2 + // step5 stakeAmount -> "42gen"; confirm -> true + // step7 setupIdentity -> false (skip identity prompts) + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({stakeSource: "wallet"}) // funding source + .mockResolvedValueOnce({useOperator: true}) // step4 + .mockResolvedValueOnce({operatorChoice: "create"}) + .mockResolvedValueOnce({operatorName: "op"}) + .mockResolvedValueOnce({outputFilename: "op-keystore.json"}) + .mockResolvedValueOnce({exportPassword: "password123"}) + .mockResolvedValueOnce({confirmPassword: "password123"}) + .mockResolvedValueOnce({stakeAmount: "42gen"}) // step5 + .mockResolvedValueOnce({confirm: true}) + .mockResolvedValueOnce({setupIdentity: false}); // step7 + + // listAccounts is used inside step4 (create operator uniqueness check). + vi.spyOn(action as any, "listAccounts").mockReturnValue([]); + + await action.execute({amount: "", wallet: "browser", network: "testnet-bradbury"} as any); + + // Bridge was started once (via ensureBrowserSession) and closed in finally. + expect(getBrowserWalletSessionSpy).toHaveBeenCalledWith( + expect.objectContaining({network: "testnet-bradbury"}), + "wizard", + ); + expect(bridgeClose).toHaveBeenCalled(); + + // Join tx went through the bridge, not the keystore staking client. + expect(sendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + to: "0xStaking", + data: "0xjoin", + label: expect.stringContaining("Join as validator"), + }), + ); + expect(getStakingClientSpy).not.toHaveBeenCalled(); + + // Operator keystore export still happened (step 4 unchanged). + expect(ExportAccountAction.prototype.execute).toHaveBeenCalled(); + + // Validator created spinner fired with the decoded wallet. + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Validator created successfully!", + expect.objectContaining({validatorWallet: "0xValidatorWalletFromEvent"}), + ); + }); + + test("vesting source: browser owner sends vestingValidatorJoin through the same session", async () => { + mockGlClient.getBeneficiaryVestings.mockResolvedValue(["0xVesting"]); + + // funding source -> vesting (one contract, no pick prompt) + // step4 useOperator -> true; operatorChoice -> existing; operatorAddress + // step5 stakeAmount -> "42gen"; confirm -> true + // (identity is skipped for vesting-backed validators) + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({stakeSource: "vesting"}) + .mockResolvedValueOnce({useOperator: true}) + .mockResolvedValueOnce({operatorChoice: "existing"}) + .mockResolvedValueOnce({operatorAddress: "0xOperatorAddr"}) + .mockResolvedValueOnce({stakeAmount: "42gen"}) + .mockResolvedValueOnce({confirm: true}); + + vi.spyOn(action as any, "listAccounts").mockReturnValue([]); + + await action.execute({amount: "", wallet: "browser", network: "testnet-bradbury"} as any); + + // Beneficiary lookup used the connected browser address. + expect(mockGlClient.getBeneficiaryVestings).toHaveBeenCalledWith("0xBrowserOwner"); + + // Calldata was built for vestingValidatorJoin with the chosen operator + amount. + expect(vi.mocked(buildTx)).toHaveBeenCalledWith([], "0xVesting", "vestingValidatorJoin", [ + "0xOperatorAddr", + 42n * 10n ** 18n, + ]); + + // Join went through the SAME browser session, no msg.value, never the keystore. + expect(sendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + to: "0xVesting", + data: "0xvestingjoin", + label: expect.stringContaining("Create vesting validator"), + }), + ); + expect(sendTransaction.mock.calls[0][0].value).toBeUndefined(); + expect(getStakingClientSpy).not.toHaveBeenCalled(); + + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Vesting-backed validator created successfully!", + expect.objectContaining({vesting: "0xVesting", validatorWallet: "0xVWallet"}), + ); + }); + + test("keystore path is untouched: --account + --wallet browser is rejected up-front", async () => { + await expect(action.execute({amount: "", wallet: "browser", account: "owner"} as any)).rejects.toThrow( + /--account cannot be used with --wallet browser/, + ); + expect(getBrowserWalletSessionSpy).not.toHaveBeenCalled(); + }); +}); + +describe("ValidatorWizardAction stake source (keystore owner)", () => { + let action: ValidatorWizardAction; + let validatorJoin: ReturnType; + let vestingValidatorJoin: ReturnType; + let getStakingClientSpy: any; + let getBrowserWalletSessionSpy: any; + + beforeEach(() => { + vi.clearAllMocks(); + action = new ValidatorWizardAction(); + + for (const m of [ + "startSpinner", + "setSpinnerText", + "succeedSpinner", + "failSpinner", + "stopSpinner", + "logInfo", + "logWarning", + "logError", + "log", + ]) { + vi.spyOn(action as any, m).mockImplementation(() => {}); + } + + vi.spyOn(action as any, "getCustomNetworks").mockReturnValue({}); + vi.spyOn(action as any, "getConfigByKey").mockReturnValue("testnet-bradbury"); + vi.spyOn(action as any, "writeConfig").mockImplementation(() => {}); + + // Keystore owner "owner" -> 0xOwner (short-circuits account-selection prompts). + vi.spyOn(action as any, "accountExists").mockReturnValue(true); + vi.spyOn(action as any, "getKeystorePath").mockReturnValue("/tmp/owner-keystore.json"); + vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xOwner"); + vi.spyOn(action as any, "listAccounts").mockReturnValue([]); + + // The browser bridge must never start in keystore mode. + getBrowserWalletSessionSpy = vi.spyOn(action as any, "getBrowserWalletSession").mockImplementation(() => { + throw new Error("browser session must not start in keystore mode"); + }); + + // Signing client exposes both the wallet (validatorJoin) and the vesting + // (vestingValidatorJoin) create methods; each test asserts which one ran. + validatorJoin = vi.fn().mockResolvedValue({ + validatorWallet: "0xWalletFromJoin", + transactionHash: "0xJoinTx", + amount: "42 GEN", + operator: "0xOwner", + blockNumber: 11n, + }); + vestingValidatorJoin = vi.fn().mockResolvedValue({ + validatorWallet: "0xVWalletCreated", + transactionHash: "0xVJoinTx", + operator: "0xOwner", + amount: "42 GEN", + blockNumber: 12n, + gasUsed: 100n, + }); + getStakingClientSpy = vi.spyOn(action as any, "getStakingClient").mockResolvedValue({ + validatorJoin, + vestingValidatorJoin, + getValidatorWallets: vi.fn().mockResolvedValue(["0xVWalletCreated"]), + } as any); + + vi.mocked(CreateAccountAction.prototype.execute).mockResolvedValue(undefined as any); + vi.mocked(ExportAccountAction.prototype.execute).mockResolvedValue(undefined as any); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const run = (extra: Record = {}) => + action.execute({ + amount: "", + account: "owner", + wallet: "keystore", + network: "testnet-bradbury", + ...extra, + } as any); + + test("(a) wallet source keeps the original flow — no vesting lookup, uses validatorJoin", async () => { + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({stakeSource: "wallet"}) + .mockResolvedValueOnce({useOperator: false}) + .mockResolvedValueOnce({stakeAmount: "42gen"}) + .mockResolvedValueOnce({confirm: true}) + .mockResolvedValueOnce({setupIdentity: false}); + + await run(); + + expect(validatorJoin).toHaveBeenCalledWith({amount: 42n * 10n ** 18n, operator: "0xOwner"}); + expect(vestingValidatorJoin).not.toHaveBeenCalled(); + expect(mockGlClient.getBeneficiaryVestings).not.toHaveBeenCalled(); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Validator created successfully!", + expect.objectContaining({validatorWallet: "0xWalletFromJoin"}), + ); + }); + + test("(b) vesting source, one contract: vestingValidatorJoin with the chosen operator + amount", async () => { + mockGlClient.getBeneficiaryVestings.mockResolvedValue(["0xVesting"]); + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({stakeSource: "vesting"}) + .mockResolvedValueOnce({useOperator: true}) + .mockResolvedValueOnce({operatorChoice: "existing"}) + .mockResolvedValueOnce({operatorAddress: "0xOperatorAddr"}) + .mockResolvedValueOnce({stakeAmount: "42gen"}) + .mockResolvedValueOnce({confirm: true}); + + await run(); + + expect(mockGlClient.getBeneficiaryVestings).toHaveBeenCalledWith("0xOwner"); + expect(vestingValidatorJoin).toHaveBeenCalledWith({ + vesting: "0xVesting", + operator: "0xOperatorAddr", + amount: 42n * 10n ** 18n, + }); + expect(validatorJoin).not.toHaveBeenCalled(); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Vesting-backed validator created successfully!", + expect.objectContaining({vesting: "0xVesting", validatorWallet: "0xVWalletCreated"}), + ); + }); + + test("(d) vesting source with no contracts warns and loops back to wallet — no crash", async () => { + mockGlClient.getBeneficiaryVestings.mockResolvedValue([]); + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({stakeSource: "vesting"}) // none found -> warn + loop + .mockResolvedValueOnce({stakeSource: "wallet"}) // recover onto wallet flow + .mockResolvedValueOnce({useOperator: false}) + .mockResolvedValueOnce({stakeAmount: "42gen"}) + .mockResolvedValueOnce({confirm: true}) + .mockResolvedValueOnce({setupIdentity: false}); + + await run(); + + expect(mockGlClient.getBeneficiaryVestings).toHaveBeenCalledWith("0xOwner"); + expect(action["logWarning"]).toHaveBeenCalledWith(expect.stringMatching(/No vesting contracts found/)); + expect(validatorJoin).toHaveBeenCalledOnce(); + expect(vestingValidatorJoin).not.toHaveBeenCalled(); + }); + + test("(e) multiple vesting contracts: prompts to pick and uses the chosen one", async () => { + mockGlClient.getBeneficiaryVestings.mockResolvedValue(["0xV1", "0xV2"]); + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({stakeSource: "vesting"}) + .mockResolvedValueOnce({selectedVesting: "0xV2"}) + .mockResolvedValueOnce({useOperator: false}) + .mockResolvedValueOnce({stakeAmount: "42gen"}) + .mockResolvedValueOnce({confirm: true}); + + await run(); + + const promptCalls = vi.mocked(inquirer.prompt).mock.calls; + const askedToPick = promptCalls.some((c: any) => c[0]?.[0]?.name === "selectedVesting"); + expect(askedToPick).toBe(true); + + expect(vestingValidatorJoin).toHaveBeenCalledWith({ + vesting: "0xV2", + operator: "0xOwner", + amount: 42n * 10n ** 18n, + }); + expect(validatorJoin).not.toHaveBeenCalled(); + }); + + test("(f) revoked vesting contract is blocked: wizard aborts before joining", async () => { + mockGlClient.getBeneficiaryVestings.mockResolvedValue(["0xVesting"]); + // A revoked contract can no longer stake on-chain (Vesting.sol blocks every + // stake path), so the balance-check step must bail out cleanly. + mockGlClient.getVestingState.mockResolvedValueOnce({ + revoked: true, + totalAmountRaw: 100n * 10n ** 18n, + totalWithdrawnRaw: 0n, + }); + vi.mocked(inquirer.prompt).mockResolvedValueOnce({stakeSource: "vesting"}); + + await run(); + + expect(mockGlClient.getBeneficiaryVestings).toHaveBeenCalledWith("0xOwner"); + expect(action["logError"]).toHaveBeenCalledWith(expect.stringMatching(/revoked/i)); + // Neither join path runs — the wizard aborted at the balance check. + expect(vestingValidatorJoin).not.toHaveBeenCalled(); + expect(validatorJoin).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/actions/vesting.test.ts b/tests/actions/vesting.test.ts new file mode 100644 index 00000000..b7aa2dd0 --- /dev/null +++ b/tests/actions/vesting.test.ts @@ -0,0 +1,160 @@ +import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; +import {VestingDelegateAction} from "../../src/commands/vesting/delegate"; +import {VestingWithdrawAction} from "../../src/commands/vesting/withdraw"; + +// Mock genlayer-js. The browser-wallet path builds calldata via the mocked +// txBuilders helper below, so stubbed ABIs are enough here. +vi.mock("genlayer-js", () => ({ + createClient: vi.fn(), + createAccount: vi.fn(() => ({address: "0xMockedAddress"})), + formatStakingAmount: vi.fn((val: bigint) => `${Number(val) / 1e18} GEN`), + parseStakingAmount: vi.fn((val: string) => { + if (val.toLowerCase().endsWith("gen") || val.toLowerCase().endsWith("eth")) { + return BigInt(parseFloat(val.slice(0, -3)) * 1e18); + } + return BigInt(val); + }), + abi: { + VESTING_ABI: [], + }, +})); + +vi.mock("genlayer-js/chains", () => ({ + localnet: {id: 1, name: "localnet", rpcUrls: {default: {http: ["http://localhost:8545"]}}}, + studionet: {id: 2, name: "studionet", rpcUrls: {default: {http: ["https://studionet.genlayer.com"]}}}, + testnetAsimov: { + id: 3, + name: "testnet-asimov", + rpcUrls: {default: {http: ["https://testnet.genlayer.com"]}}, + }, + testnetBradbury: { + id: 4, + name: "testnet-bradbury", + rpcUrls: {default: {http: ["https://testnet.genlayer.com"]}}, + }, +})); + +// The genlayer-js mock stubs `abi` with empty ABIs, so mock the pure tx-builder +// module (real behavior is covered in tests/libs/txBuilders.test.ts). +vi.mock("../../src/lib/wallet/txBuilders", () => ({ + buildTx: vi.fn(() => ({to: "0xVesting", data: "0xdata"})), +})); + +// Shared vesting browser-wallet session factory. Commands call `session.close()` +// in their finally block. +function makeVestingSession(overrides: Record = {}) { + return { + signerAddress: "0xBen", + sendTransaction: vi.fn().mockResolvedValue({ + transactionHash: "0xVH", + blockNumber: 9n, + gasUsed: 8n, + }), + close: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function setupVestingBrowserMocks(action: any) { + vi.spyOn(action, "startSpinner").mockImplementation(() => {}); + vi.spyOn(action, "setSpinnerText").mockImplementation(() => {}); + vi.spyOn(action, "succeedSpinner").mockImplementation(() => {}); + vi.spyOn(action, "failSpinner").mockImplementation(() => {}); + vi.spyOn(action, "log").mockImplementation(() => {}); + // Read-only client used to resolve the beneficiary vesting; account-less. + vi.spyOn(action, "getReadOnlyVestingClient").mockResolvedValue({ + getBeneficiaryVestings: vi.fn().mockResolvedValue(["0xVesting"]), + }); + // Simplest resolution: the vesting address is fixed. + vi.spyOn(action, "resolveBeneficiaryVesting").mockResolvedValue("0xVesting"); +} + +describe("VestingDelegateAction --wallet browser", () => { + let action: VestingDelegateAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new VestingDelegateAction(); + setupVestingBrowserMocks(action); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("routes through the browser session, skips keystore client, closes session", async () => { + const getVestingClientSpy = vi.spyOn(action as any, "getVestingClient"); + const session = makeVestingSession(); + vi.spyOn(action as any, "getVestingBrowserSession").mockResolvedValue(session); + + await action.execute({validator: "0xVal", amount: "1gen", wallet: "browser"}); + + expect(session.sendTransaction).toHaveBeenCalledOnce(); + expect(getVestingClientSpy).not.toHaveBeenCalled(); + expect(session.close).toHaveBeenCalledOnce(); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Vesting delegation successful!", + expect.objectContaining({ + transactionHash: "0xVH", + vesting: "0xVesting", + validator: "0xVal", + beneficiary: "0xBen", + amount: expect.any(String), + blockNumber: "9", + gasUsed: "8", + }), + ); + }); + + test("closes the session even when the send fails", async () => { + const session = makeVestingSession({ + sendTransaction: vi.fn().mockRejectedValue(new Error("Rejected in wallet")), + }); + vi.spyOn(action as any, "getVestingBrowserSession").mockResolvedValue(session); + + await action.execute({validator: "0xVal", amount: "1gen", wallet: "browser"}); + + expect(action["failSpinner"]).toHaveBeenCalledWith( + "Failed to delegate vesting tokens", + "Rejected in wallet", + ); + expect(session.close).toHaveBeenCalledOnce(); + }); +}); + +describe("VestingWithdrawAction --wallet browser", () => { + let action: VestingWithdrawAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new VestingWithdrawAction(); + setupVestingBrowserMocks(action); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("routes through the browser session, skips keystore client, closes session", async () => { + const getVestingClientSpy = vi.spyOn(action as any, "getVestingClient"); + const session = makeVestingSession(); + vi.spyOn(action as any, "getVestingBrowserSession").mockResolvedValue(session); + + await action.execute({amount: "1gen", wallet: "browser"}); + + expect(session.sendTransaction).toHaveBeenCalledOnce(); + expect(getVestingClientSpy).not.toHaveBeenCalled(); + expect(session.close).toHaveBeenCalledOnce(); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Vesting withdrawal successful!", + expect.objectContaining({ + transactionHash: "0xVH", + vesting: "0xVesting", + beneficiary: "0xBen", + amount: expect.any(String), + blockNumber: "9", + gasUsed: "8", + }), + ); + }); +}); diff --git a/tests/actions/walletConnect.test.ts b/tests/actions/walletConnect.test.ts new file mode 100644 index 00000000..ed5daecb --- /dev/null +++ b/tests/actions/walletConnect.test.ts @@ -0,0 +1,161 @@ +import {describe, test, expect, beforeEach, afterEach, vi} from "vitest"; + +// Hermetic: no real daemon, tab, descriptor file, or spawn. Control the +// descriptor/pid primitives, the daemon spawn, and the session client. +vi.mock("../../src/lib/wallet/sessionDescriptor", () => ({ + descriptorPath: vi.fn(() => "/tmp/wallet-session.json"), + readDescriptor: vi.fn(), + removeDescriptor: vi.fn(), + isPidAlive: vi.fn(), +})); +vi.mock("../../src/lib/wallet/spawnDaemon", () => ({ + spawnWalletDaemon: vi.fn(), + waitForDaemonReady: vi.fn(), +})); +vi.mock("../../src/lib/wallet/sessionClient", () => ({ + WalletSessionClient: vi.fn(), +})); + +import {WalletAction} from "../../src/commands/wallet/WalletAction"; +import {readDescriptor, isPidAlive} from "../../src/lib/wallet/sessionDescriptor"; +import {spawnWalletDaemon, waitForDaemonReady} from "../../src/lib/wallet/spawnDaemon"; +import {WalletSessionClient} from "../../src/lib/wallet/sessionClient"; +import {HEARTBEAT_DEAD_MS} from "../../src/lib/wallet/sessionConstants"; + +const ADDRESS = "0xConnected0000000000000000000000000000001"; +const CHAIN: any = {id: 4221, name: "Genlayer Bradbury Testnet"}; + +const DESCRIPTOR: any = { + version: 1, + pid: 4242, + port: 7, + token: "tok", + address: ADDRESS, + chainId: 4221, + network: "testnet-bradbury", + rpcUrl: "https://rpc.example", + createdAt: Date.now(), + lastUsed: Date.now(), +}; + +describe("WalletAction.connect — tab-dead recovery", () => { + let action: WalletAction; + let logInfo: any; + let logSuccess: any; + let succeedSpinner: any; + + beforeEach(() => { + vi.mocked(readDescriptor).mockReset(); + vi.mocked(isPidAlive).mockReset(); + vi.mocked(spawnWalletDaemon).mockReset(); + vi.mocked(waitForDaemonReady).mockReset(); + vi.mocked(WalletSessionClient).mockReset(); + + action = new WalletAction(); + // Avoid config/network + FS + spinner side effects. + vi.spyOn(action as any, "resolveChain").mockReturnValue(CHAIN); + vi.spyOn(action as any, "networkAlias").mockReturnValue("testnet-bradbury"); + vi.spyOn(action as any, "getFilePath").mockReturnValue("/tmp/wallet-daemon.log"); + logInfo = vi.spyOn(action as any, "logInfo").mockImplementation(() => {}); + logSuccess = vi.spyOn(action as any, "logSuccess").mockImplementation(() => {}); + vi.spyOn(action as any, "logWarning").mockImplementation(() => {}); + vi.spyOn(action as any, "startSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "stopSpinner").mockImplementation(() => {}); + succeedSpinner = vi.spyOn(action as any, "succeedSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "failSpinner").mockImplementation(() => {}); + }); + afterEach(() => vi.restoreAllMocks()); + + test("healthy session (fresh heartbeat, connected) → 'Already connected', no respawn", async () => { + vi.mocked(readDescriptor).mockReturnValue(DESCRIPTOR); + vi.mocked(isPidAlive).mockReturnValue(true); + const client = { + ping: vi.fn().mockResolvedValue(true), + state: vi.fn().mockResolvedValue({ + connected: true, + address: ADDRESS, + chainId: 4221, + lastPagePollAt: Date.now(), + }), + shutdown: vi.fn().mockResolvedValue(undefined), + }; + vi.mocked(WalletSessionClient).mockReturnValue(client as any); + + await action.connect({}); + + expect(logSuccess).toHaveBeenCalledWith(expect.stringMatching(/Already connected/)); + expect(client.shutdown).not.toHaveBeenCalled(); + expect(spawnWalletDaemon).not.toHaveBeenCalled(); + }); + + test("tab-dead session (alive + pinging + connected but STALE heartbeat) → tears down and respawns", async () => { + const stale = Date.now() - (HEARTBEAT_DEAD_MS + 30_000); + // First read discovers the stale daemon; waitForDescriptorGone then sees it gone. + vi.mocked(readDescriptor).mockReturnValueOnce(DESCRIPTOR).mockReturnValue(null); + vi.mocked(isPidAlive).mockReturnValue(true); + + const staleClient = { + ping: vi.fn().mockResolvedValue(true), + state: vi.fn().mockResolvedValue({ + connected: true, + address: ADDRESS, + chainId: 4221, + lastPagePollAt: stale, + }), + shutdown: vi.fn().mockResolvedValue(undefined), + }; + const freshClient = { + state: vi.fn().mockResolvedValue({url: "http://127.0.0.1:7/gl-wallet#s=tok"}), + waitForConnection: vi.fn().mockResolvedValue(ADDRESS), + }; + vi.mocked(WalletSessionClient) + .mockImplementationOnce(() => staleClient as any) + .mockImplementationOnce(() => freshClient as any); + vi.mocked(waitForDaemonReady).mockResolvedValue({...DESCRIPTOR} as any); + + await action.connect({}); + + // The stale daemon was torn down and a brand-new one spawned. + expect(staleClient.shutdown).toHaveBeenCalledTimes(1); + expect(spawnWalletDaemon).toHaveBeenCalledTimes(1); + expect(freshClient.waitForConnection).toHaveBeenCalled(); + expect(succeedSpinner).toHaveBeenCalledWith(expect.stringMatching(/Connected as/)); + // Must NOT short-circuit with "Already connected" on the dead tab. + expect(logSuccess).not.toHaveBeenCalledWith(expect.stringMatching(/Already connected/)); + // The recovery is announced. + expect(logInfo).toHaveBeenCalledWith(expect.stringMatching(/tab was closed/i)); + }); + + test("different chain still switches (shutdown + respawn), unaffected by the heartbeat check", async () => { + vi.mocked(readDescriptor) + .mockReturnValueOnce({...DESCRIPTOR, chainId: 9999}) + .mockReturnValue(null); + vi.mocked(isPidAlive).mockReturnValue(true); + + const oldClient = { + ping: vi.fn().mockResolvedValue(true), + state: vi.fn().mockResolvedValue({ + connected: true, + address: ADDRESS, + chainId: 9999, + lastPagePollAt: Date.now(), + }), + shutdown: vi.fn().mockResolvedValue(undefined), + }; + const freshClient = { + state: vi.fn().mockResolvedValue({url: "http://127.0.0.1:7/gl-wallet#s=tok"}), + waitForConnection: vi.fn().mockResolvedValue(ADDRESS), + }; + vi.mocked(WalletSessionClient) + .mockImplementationOnce(() => oldClient as any) + .mockImplementationOnce(() => freshClient as any); + vi.mocked(waitForDaemonReady).mockResolvedValue({...DESCRIPTOR} as any); + + await action.connect({network: "localnet"}); + + expect(logInfo).toHaveBeenCalledWith(expect.stringMatching(/Switching wallet session/)); + expect(oldClient.shutdown).toHaveBeenCalledTimes(1); + expect(spawnWalletDaemon).toHaveBeenCalledTimes(1); + expect(logSuccess).not.toHaveBeenCalledWith(expect.stringMatching(/Already connected/)); + }); +}); diff --git a/tests/actions/walletSession.test.ts b/tests/actions/walletSession.test.ts new file mode 100644 index 00000000..8346fd8e --- /dev/null +++ b/tests/actions/walletSession.test.ts @@ -0,0 +1,86 @@ +import {describe, test, expect, beforeEach, afterEach, vi} from "vitest"; +import {BaseAction} from "../../src/lib/actions/BaseAction"; + +/** Minimal concrete subclass exposing the protected resolution helpers. */ +class TestAction extends BaseAction { + publicResolveMode(flag?: string) { + return (this as any).resolveWalletMode(flag); + } + publicIsBrowser(config: {wallet?: string}) { + return (this as any).isBrowserWallet(config); + } +} + +describe("BaseAction wallet-mode resolution (config default)", () => { + let action: TestAction; + let configValue: any; + let warnSpy: any; + let sessionSpy: any; + + beforeEach(() => { + action = new TestAction(); + configValue = null; + vi.spyOn(action as any, "getConfigByKey").mockImplementation((...args: any[]) => + args[0] === "walletMode" ? configValue : null, + ); + warnSpy = vi.spyOn(action as any, "logWarning").mockImplementation(() => {}); + // Default: no live session, so config tests stay hermetic (not swayed by a + // descriptor on the machine running the suite). Session-rung tests flip it. + sessionSpy = vi.spyOn(action as any, "hasLiveWalletSession").mockReturnValue(false); + }); + afterEach(() => vi.restoreAllMocks()); + + test("no flag + no config + no session → keystore", () => { + expect(action.publicResolveMode(undefined)).toBe("keystore"); + expect(action.publicIsBrowser({})).toBe(false); + }); + + test("no flag + no config + live session → browser (connect-once)", () => { + sessionSpy.mockReturnValue(true); + expect(action.publicResolveMode(undefined)).toBe("browser"); + expect(action.publicIsBrowser({})).toBe(true); + }); + + test("explicit --wallet keystore overrides a live session", () => { + sessionSpy.mockReturnValue(true); + expect(action.publicResolveMode("keystore")).toBe("keystore"); + }); + + test("walletMode=keystore config overrides a live session", () => { + sessionSpy.mockReturnValue(true); + configValue = "keystore"; + expect(action.publicResolveMode(undefined)).toBe("keystore"); + }); + + test("live session is not consulted when config already decides (browser)", () => { + configValue = "browser"; + expect(action.publicResolveMode(undefined)).toBe("browser"); + expect(sessionSpy).not.toHaveBeenCalled(); + }); + + test("no flag + walletMode=browser config → browser", () => { + configValue = "browser"; + expect(action.publicResolveMode(undefined)).toBe("browser"); + expect(action.publicIsBrowser({})).toBe(true); + }); + + test("explicit --wallet keystore overrides walletMode=browser config", () => { + configValue = "browser"; + expect(action.publicResolveMode("keystore")).toBe("keystore"); + expect(action.publicIsBrowser({wallet: "keystore"})).toBe(false); + }); + + test("explicit --wallet browser works with no config", () => { + expect(action.publicResolveMode("browser")).toBe("browser"); + }); + + test("invalid config value warns and falls back to keystore", () => { + configValue = "hardware"; + expect(action.publicResolveMode(undefined)).toBe("keystore"); + expect(warnSpy).toHaveBeenCalled(); + }); + + test("invalid flag throws", () => { + expect(() => action.publicResolveMode("ledger")).toThrow(/Invalid --wallet value 'ledger'/); + }); +}); diff --git a/tests/actions/write.test.ts b/tests/actions/write.test.ts index 22938c05..b5a9bbab 100644 --- a/tests/actions/write.test.ts +++ b/tests/actions/write.test.ts @@ -371,4 +371,47 @@ describe("WriteAction", () => { consensusStatus: "ACCEPTED", }); }); + + describe("WriteAction --wallet browser", () => { + test("wires the browser provider into the client and never touches the keystore", async () => { + const session = { + signerAddress: "0xBrowser", + eip1193Provider: {request: vi.fn()}, + setNextLabel: vi.fn(), + close: vi.fn().mockResolvedValue(undefined), + }; + // Lane B: getClient (BaseAction) builds the client itself. Stub the browser + // session opener so the real getClient runs, then assert the wiring. + const getBrowserSessionSpy = vi + .spyOn(writeAction as any, "getBrowserSession") + .mockResolvedValue(session); + const getAccountSpy = vi.spyOn(writeAction as any, "getAccount"); + + const mockHash = "0xMockedTransactionHash"; + const mockReceipt = {statusName: "ACCEPTED", txExecutionResultName: "FINISHED_WITH_RETURN"}; + vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); + + await writeAction.write({ + contractAddress: "0xC", + method: "m", + args: [], + wallet: "browser", + }); + + expect(getBrowserSessionSpy).toHaveBeenCalled(); + expect(createClient).toHaveBeenCalledWith( + expect.objectContaining({ + account: "0xBrowser", + provider: session.eip1193Provider, + }), + ); + // No keystore/keychain/password path in browser mode. + expect(getAccountSpy).not.toHaveBeenCalled(); + expect(writeAction["succeedSpinner"]).toHaveBeenCalledWith( + "Write operation successfully executed", + expect.objectContaining({consensusStatus: "ACCEPTED"}), + ); + }); + }); }); diff --git a/tests/commands/balances.test.ts b/tests/commands/balances.test.ts new file mode 100644 index 00000000..a4dbc0d6 --- /dev/null +++ b/tests/commands/balances.test.ts @@ -0,0 +1,73 @@ +import {Command} from "commander"; +import {vi, describe, beforeEach, afterEach, test, expect} from "vitest"; +import {initializeBalancesCommands} from "../../src/commands/balances"; +import {VestingAction} from "../../src/commands/vesting/VestingAction"; +import {BalancesAction} from "../../src/commands/balances/BalancesAction"; + +vi.mock("genlayer-js", () => ({ + createClient: vi.fn(), + createAccount: vi.fn(() => ({address: "0xBeneficiary"})), + formatStakingAmount: vi.fn((value: bigint) => `${Number(value) / 1e18} GEN`), + parseStakingAmount: vi.fn((value: string) => BigInt(value)), +})); + +vi.mock("genlayer-js/chains", () => ({ + localnet: {id: 1, name: "localnet", rpcUrls: {default: {http: ["http://localhost:8545"]}}}, + studionet: {id: 2, name: "studionet", rpcUrls: {default: {http: ["https://studio.genlayer.com"]}}}, + testnetAsimov: {id: 3, name: "testnet-asimov", rpcUrls: {default: {http: ["https://testnet.genlayer.com"]}}}, + testnetBradbury: {id: 4, name: "testnet-bradbury", rpcUrls: {default: {http: ["https://testnet.genlayer.com"]}}}, +})); + +const mockClient = { + getBalance: vi.fn(), + getBeneficiaryVestings: vi.fn(), + getVestingState: vi.fn(), + getValidatorWallets: vi.fn(), + validatorDeposited: vi.fn(), + getActiveValidators: vi.fn(), + vestingDepositedPerValidator: vi.fn(), +}; + +describe("balances command", () => { + let program: Command; + let consoleLogSpy: any; + + beforeEach(() => { + vi.clearAllMocks(); + + mockClient.getBalance.mockResolvedValue(0n); + mockClient.getBeneficiaryVestings.mockResolvedValue([]); + mockClient.getActiveValidators.mockResolvedValue([]); + + vi.spyOn(VestingAction.prototype as any, "getReadOnlyVestingClient").mockResolvedValue(mockClient); + vi.spyOn(VestingAction.prototype as any, "getSignerAddress").mockResolvedValue("0xBeneficiary"); + vi.spyOn(BalancesAction.prototype as any, "startSpinner").mockImplementation(() => {}); + vi.spyOn(BalancesAction.prototype as any, "setSpinnerText").mockImplementation(() => {}); + vi.spyOn(BalancesAction.prototype as any, "stopSpinner").mockImplementation(() => {}); + vi.spyOn(BalancesAction.prototype as any, "failSpinner").mockImplementation(() => {}); + + consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + program = new Command(); + initializeBalancesCommands(program); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("--beneficiary queries that address and renders output", async () => { + await program.parseAsync(["node", "test", "balances", "--beneficiary", "0xExplicit"]); + + expect(mockClient.getBalance).toHaveBeenCalledWith({address: "0xExplicit"}); + expect(mockClient.getBeneficiaryVestings).toHaveBeenCalledWith("0xExplicit", undefined); + expect(consoleLogSpy).toHaveBeenCalled(); + }); + + test("defaults to the active account address without unlocking", async () => { + await program.parseAsync(["node", "test", "balances"]); + + expect(mockClient.getBalance).toHaveBeenCalledWith({address: "0xBeneficiary"}); + expect(mockClient.getBeneficiaryVestings).toHaveBeenCalledWith("0xBeneficiary", undefined); + }); +}); diff --git a/tests/commands/staking.test.ts b/tests/commands/staking.test.ts index 3866529e..4107a4b2 100644 --- a/tests/commands/staking.test.ts +++ b/tests/commands/staking.test.ts @@ -13,7 +13,9 @@ import {DelegatorExitAction} from "../../src/commands/staking/delegatorExit"; import {DelegatorClaimAction} from "../../src/commands/staking/delegatorClaim"; import {StakingInfoAction} from "../../src/commands/staking/stakingInfo"; import {ValidatorsAction} from "../../src/commands/staking/validators"; +import {ValidatorWizardAction} from "../../src/commands/staking/wizard"; +vi.mock("../../src/commands/staking/wizard"); vi.mock("../../src/commands/staking/validatorJoin"); vi.mock("../../src/commands/staking/validatorDeposit"); vi.mock("../../src/commands/staking/validatorExit"); @@ -50,6 +52,31 @@ describe("staking commands", () => { }); }); + test("leaves --wallet unset when omitted", async () => { + program.parse(["node", "test", "staking", "validator-join", "--amount", "42000gen"]); + + // No commander default: the flag key is absent (keystore is resolved later). + const arg = (ValidatorJoinAction.prototype.execute as any).mock.calls[0][0]; + expect(arg.wallet).toBeUndefined(); + }); + + test("parses --wallet browser", async () => { + program.parse([ + "node", + "test", + "staking", + "validator-join", + "--amount", + "42000gen", + "--wallet", + "browser", + ]); + + expect(ValidatorJoinAction.prototype.execute).toHaveBeenCalledWith( + expect.objectContaining({wallet: "browser"}), + ); + }); + test("calls ValidatorJoinAction.execute with operator", async () => { program.parse([ "node", @@ -86,9 +113,36 @@ describe("staking commands", () => { }); }); + describe("wizard", () => { + test("leaves --wallet unset when omitted", async () => { + program.parse(["node", "test", "staking", "wizard"]); + + expect(ValidatorWizardAction).toHaveBeenCalledTimes(1); + const arg = (ValidatorWizardAction.prototype.execute as any).mock.calls[0][0]; + expect(arg.wallet).toBeUndefined(); + }); + + test("parses --wallet browser", async () => { + program.parse(["node", "test", "staking", "wizard", "--wallet", "browser"]); + + expect(ValidatorWizardAction.prototype.execute).toHaveBeenCalledWith( + expect.objectContaining({wallet: "browser"}), + ); + }); + }); + describe("validator-deposit", () => { test("calls ValidatorDepositAction.execute", async () => { - program.parse(["node", "test", "staking", "validator-deposit", "--validator", "0x1234567890123456789012345678901234567890", "--amount", "1000gen"]); + program.parse([ + "node", + "test", + "staking", + "validator-deposit", + "--validator", + "0x1234567890123456789012345678901234567890", + "--amount", + "1000gen", + ]); expect(ValidatorDepositAction).toHaveBeenCalledTimes(1); expect(ValidatorDepositAction.prototype.execute).toHaveBeenCalledWith({ @@ -100,7 +154,16 @@ describe("staking commands", () => { describe("validator-exit", () => { test("calls ValidatorExitAction.execute", async () => { - program.parse(["node", "test", "staking", "validator-exit", "--validator", "0x1234567890123456789012345678901234567890", "--shares", "100"]); + program.parse([ + "node", + "test", + "staking", + "validator-exit", + "--validator", + "0x1234567890123456789012345678901234567890", + "--shares", + "100", + ]); expect(ValidatorExitAction).toHaveBeenCalledTimes(1); expect(ValidatorExitAction.prototype.execute).toHaveBeenCalledWith({ @@ -324,14 +387,7 @@ describe("staking commands", () => { describe("delegation-info", () => { test("calls StakingInfoAction.getStakeInfo", async () => { - program.parse([ - "node", - "test", - "staking", - "delegation-info", - "--validator", - "0xValidator", - ]); + program.parse(["node", "test", "staking", "delegation-info", "--validator", "0xValidator"]); expect(StakingInfoAction).toHaveBeenCalledTimes(1); expect(StakingInfoAction.prototype.getStakeInfo).toHaveBeenCalledWith({ diff --git a/tests/commands/vesting.test.ts b/tests/commands/vesting.test.ts index 88f0ac38..33851bfb 100644 --- a/tests/commands/vesting.test.ts +++ b/tests/commands/vesting.test.ts @@ -2,6 +2,8 @@ import {Command} from "commander"; import {vi, describe, beforeEach, afterEach, test, expect} from "vitest"; import {initializeVestingCommands} from "../../src/commands/vesting"; import {VestingAction} from "../../src/commands/vesting/VestingAction"; +import {VestingDelegateAction} from "../../src/commands/vesting/delegate"; +import {VestingValidatorDepositAction} from "../../src/commands/vesting/validatorDeposit"; vi.mock("genlayer-js", () => ({ createClient: vi.fn(), @@ -490,4 +492,64 @@ describe("vesting commands", () => { expect(mockClient.getBeneficiaryVestings).toHaveBeenCalledWith("0xBeneficiary", undefined); expect(mockClient.getValidatorWallets).toHaveBeenCalledWith("0xVesting"); }); + + test("delegate parses --wallet browser into the signing mode", async () => { + // Spy execute directly so parsing is asserted without opening a real browser + // session (the browser path is unit-tested in tests/actions/vesting.test.ts). + const executeSpy = vi + .spyOn(VestingDelegateAction.prototype as any, "execute") + .mockResolvedValue(undefined); + + await program.parseAsync([ + "node", + "test", + "vesting", + "delegate", + "0xValidator", + "--amount", + "42gen", + "--wallet", + "browser", + ]); + + expect(executeSpy).toHaveBeenCalledWith( + expect.objectContaining({wallet: "browser", validator: "0xValidator"}), + ); + }); + + test("delegate leaves --wallet unset when omitted (keystore resolved in the action)", async () => { + const executeSpy = vi + .spyOn(VestingDelegateAction.prototype as any, "execute") + .mockResolvedValue(undefined); + + await program.parseAsync(["node", "test", "vesting", "delegate", "0xValidator", "--amount", "42gen"]); + + // No commander default: the omitted flag is undefined; the effective mode + // (keystore, unless walletMode=browser config) is resolved in resolveWalletMode. + expect((executeSpy.mock.calls[0][0] as any).wallet).toBeUndefined(); + }); + + test("validator deposit routes the deprecated --validator-wallet flag to walletAddress", async () => { + const executeSpy = vi + .spyOn(VestingValidatorDepositAction.prototype as any, "execute") + .mockResolvedValue(undefined); + + await program.parseAsync([ + "node", + "test", + "vesting", + "validator", + "deposit", + "--validator-wallet", + "0xWallet", + "--amount", + "1gen", + ]); + + // The deprecated --validator-wallet flag supplies the address; --wallet + // (signing mode) must not be interpreted as the wallet address. + const depositArg = executeSpy.mock.calls[0][0] as any; + expect(depositArg.walletAddress).toBe("0xWallet"); + expect(depositArg.wallet).toBeUndefined(); + }); }); diff --git a/tests/commands/wallet.test.ts b/tests/commands/wallet.test.ts new file mode 100644 index 00000000..019a5d6d --- /dev/null +++ b/tests/commands/wallet.test.ts @@ -0,0 +1,64 @@ +import {Command} from "commander"; +import {vi, describe, beforeEach, afterEach, test, expect} from "vitest"; +import {initializeWalletCommands} from "../../src/commands/wallet"; +import {WalletAction} from "../../src/commands/wallet/WalletAction"; + +vi.mock("../../src/commands/wallet/WalletAction"); + +describe("wallet commands", () => { + let program: Command; + + beforeEach(() => { + program = new Command(); + initializeWalletCommands(program); + vi.clearAllMocks(); + }); + + afterEach(() => vi.restoreAllMocks()); + + test("connect passes --network and --rpc", async () => { + program.parse([ + "node", + "test", + "wallet", + "connect", + "--network", + "testnet-bradbury", + "--rpc", + "https://r", + ]); + expect(WalletAction.prototype.connect).toHaveBeenCalledWith( + expect.objectContaining({network: "testnet-bradbury", rpc: "https://r"}), + ); + }); + + test("connect works with no flags", async () => { + program.parse(["node", "test", "wallet", "connect"]); + expect(WalletAction.prototype.connect).toHaveBeenCalledTimes(1); + }); + + test("status is dispatched", async () => { + program.parse(["node", "test", "wallet", "status"]); + expect(WalletAction.prototype.status).toHaveBeenCalledTimes(1); + }); + + test("disconnect is dispatched", async () => { + program.parse(["node", "test", "wallet", "disconnect"]); + expect(WalletAction.prototype.disconnect).toHaveBeenCalledTimes(1); + }); + + test("daemon subcommand exists (hidden) and dispatches", async () => { + program.parse(["node", "test", "wallet", "daemon", "--network", "localnet"]); + expect(WalletAction.prototype.daemon).toHaveBeenCalledWith( + expect.objectContaining({network: "localnet"}), + ); + }); + + test("daemon is hidden from the wallet help/command listing", () => { + const wallet = program.commands.find(c => c.name() === "wallet")!; + const daemon = wallet.commands.find(c => c.name() === "daemon")!; + // Present but hidden. + expect(daemon).toBeDefined(); + expect((daemon as any)._hidden).toBe(true); + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index a5d99f68..815ce0b8 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -53,6 +53,14 @@ vi.mock("../src/commands/vesting", () => ({ initializeVestingCommands: vi.fn(), })); +vi.mock("../src/commands/wallet", () => ({ + initializeWalletCommands: vi.fn(), +})); + +vi.mock("../src/commands/balances", () => ({ + initializeBalancesCommands: vi.fn(), +})); + describe("CLI", () => { it("should initialize CLI", () => { expect(initializeCLI).not.toThrow(); diff --git a/tests/libs/browserBridge.test.ts b/tests/libs/browserBridge.test.ts new file mode 100644 index 00000000..45ca25ec --- /dev/null +++ b/tests/libs/browserBridge.test.ts @@ -0,0 +1,412 @@ +import {describe, test, expect, vi, beforeEach, afterEach} from "vitest"; +import {BrowserWalletBridge, type BridgeChainParams} from "../../src/lib/wallet/browserBridge"; + +const CHAIN: BridgeChainParams = { + chainId: 4221, + chainName: "Genlayer Bradbury Testnet", + rpcUrls: ["https://rpc.example"], + nativeCurrency: {name: "GEN Token", symbol: "GEN", decimals: 18}, + blockExplorerUrls: ["https://explorer.example"], +}; + +const ADDRESS = "0xConnectedAddress0000000000000000000000000" as `0x${string}`; + +/** Parse origin + token out of the bridge URL (http://127.0.0.1:/#s=). */ +function parse(url: string): {origin: string; token: string} { + const u = new URL(url); + const origin = `${u.protocol}//${u.host}`; + const token = new URLSearchParams(u.hash.slice(1)).get("s")!; + return {origin, token}; +} + +const activeBridges: BrowserWalletBridge[] = []; + +function makeBridge(overrides: Partial[0]> = {}) { + const openUrl = vi.fn().mockResolvedValue(undefined); + const bridge = new BrowserWalletBridge({ + chain: CHAIN, + openUrl, + handleSigint: false, + ...overrides, + }); + activeBridges.push(bridge); + return {bridge, openUrl}; +} + +describe("BrowserWalletBridge", () => { + let bridge: BrowserWalletBridge; + let origin: string; + let token: string; + let openUrl: ReturnType; + + beforeEach(async () => { + activeBridges.length = 0; + ({bridge, openUrl} = makeBridge()); + const {url} = await bridge.start(); + ({origin, token} = parse(url)); + }); + + afterEach(async () => { + // Close every bridge a test created so no server / pending promise leaks. + await Promise.all(activeBridges.map(b => b.close().catch(() => {}))); + activeBridges.length = 0; + }); + + const authGet = (path: string) => fetch(`${origin}${path}`, {headers: {"X-Bridge-Token": token}}); + const authPost = (path: string, body: unknown) => + fetch(`${origin}${path}`, { + method: "POST", + headers: {"X-Bridge-Token": token, "Content-Type": "application/json", Origin: origin}, + body: JSON.stringify(body), + }); + + test("start() binds loopback, opens the URL, and serves the page", async () => { + expect(origin).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + expect(openUrl).toHaveBeenCalledOnce(); + const res = await fetch(`${origin}/`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("GenLayer CLI"); + expect(res.headers.get("cache-control")).toBe("no-store"); + }); + + test("/api/session returns chain params incl. chainIdHex", async () => { + const res = await authGet("/api/session"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.chain.chainId).toBe(4221); + expect(body.chain.chainIdHex).toBe("0x107d"); + expect(body.chain.chainName).toBe(CHAIN.chainName); + }); + + test("rejects API calls with a missing or wrong token (403)", async () => { + const noToken = await fetch(`${origin}/api/session`); + expect(noToken.status).toBe(403); + const badToken = await fetch(`${origin}/api/session`, {headers: {"X-Bridge-Token": "nope"}}); + expect(badToken.status).toBe(403); + }); + + test("rejects a POST with a wrong Origin header (403)", async () => { + const res = await fetch(`${origin}/api/connected`, { + method: "POST", + headers: {"X-Bridge-Token": token, "Content-Type": "application/json", Origin: "http://evil.example"}, + body: JSON.stringify({address: ADDRESS}), + }); + expect(res.status).toBe(403); + }); + + test("connected handshake resolves waitForConnection()", async () => { + const connectionPromise = bridge.waitForConnection(); + await authPost("/api/connected", {address: ADDRESS}); + await expect(connectionPromise).resolves.toBe(ADDRESS); + }); + + test("full happy path: connect -> next(tx) -> result(sent) -> hash", async () => { + await authPost("/api/connected", {address: ADDRESS}); + await bridge.waitForConnection(); + + const sendPromise = bridge.sendTransaction({ + to: "0xStaking00000000000000000000000000000000" as `0x${string}`, + data: "0xabcdef" as `0x${string}`, + value: 100n, + label: "Join as validator", + }); + + const nextRes = await authGet("/api/next"); + const next = await nextRes.json(); + expect(next.type).toBe("tx"); + expect(next.tx.to).toBe("0xStaking00000000000000000000000000000000"); + expect(next.tx.value).toBe("0x64"); // 100 in hex + expect(next.tx.chainId).toBe("0x107d"); + expect(next.tx.label).toBe("Join as validator"); + + await authPost("/api/result", {id: next.tx.id, status: "sent", txHash: "0xhash", from: ADDRESS}); + await expect(sendPromise).resolves.toBe("0xhash"); + }); + + test("serializes gas/type/nonce pass-through as hex quantities when present", async () => { + await authPost("/api/connected", {address: ADDRESS}); + const sendPromise = bridge.sendTransaction({ + to: "0xConsensus" as any, + data: "0xdead" as `0x${string}`, + value: 100n, + gas: 21000n, + gasPrice: 1n, + nonce: 2, + type: "0x0", + label: "IC write", + }); + const next = await (await authGet("/api/next")).json(); + expect(next.tx.gas).toBe("0x5208"); // 21000 + expect(next.tx.gasPrice).toBe("0x1"); + expect(next.tx.nonce).toBe("0x2"); + expect(next.tx.type).toBe("0x0"); + await authPost("/api/result", {id: next.tx.id, status: "sent", txHash: "0xh"}); + await expect(sendPromise).resolves.toBe("0xh"); + }); + + test("omits gas/type/nonce when absent (backward compatible)", async () => { + await authPost("/api/connected", {address: ADDRESS}); + const sendPromise = bridge.sendTransaction({to: "0xA" as any, data: "0x01", label: "bare"}); + const next = await (await authGet("/api/next")).json(); + expect(next.tx.gas).toBeUndefined(); + expect(next.tx.type).toBeUndefined(); + expect(next.tx.nonce).toBeUndefined(); + await authPost("/api/result", {id: next.tx.id, status: "sent", txHash: "0xh2"}); + await expect(sendPromise).resolves.toBe("0xh2"); + }); + + test("multi-tx sequential: two sends delivered and resolved in order", async () => { + await authPost("/api/connected", {address: ADDRESS}); + + const p1 = bridge.sendTransaction({to: "0xA" as any, data: "0x01", label: "tx1"}); + const n1 = await (await authGet("/api/next")).json(); + expect(n1.tx.label).toBe("tx1"); + await authPost("/api/result", {id: n1.tx.id, status: "sent", txHash: "0xhash1"}); + await expect(p1).resolves.toBe("0xhash1"); + + const p2 = bridge.sendTransaction({to: "0xB" as any, data: "0x02", label: "tx2"}); + const n2 = await (await authGet("/api/next")).json(); + expect(n2.tx.label).toBe("tx2"); + await authPost("/api/result", {id: n2.tx.id, status: "sent", txHash: "0xhash2"}); + await expect(p2).resolves.toBe("0xhash2"); + }); + + test("long-poll returns {type:'none'} then a tx when queued (deliver via waiter)", async () => { + ({bridge, openUrl} = makeBridge({txTimeoutMs: 2000})); + const {url} = await bridge.start(); + ({origin, token} = parse(url)); + + // Start a poll BEFORE any tx is queued — it should be held. + const pollPromise = authGet("/api/next").then(r => r.json()); + // Queue a tx; the held waiter must be resolved with it. + const sendPromise = bridge.sendTransaction({to: "0xC" as any, data: "0x03", label: "held-tx"}); + const held = await pollPromise; + expect(held.type).toBe("tx"); + expect(held.tx.label).toBe("held-tx"); + await authPost("/api/result", {id: held.tx.id, status: "sent", txHash: "0xheld"}); + await expect(sendPromise).resolves.toBe("0xheld"); + }); + + test("rejected result rejects sendTransaction with a clear message", async () => { + await authPost("/api/connected", {address: ADDRESS}); + const sendPromise = bridge.sendTransaction({to: "0xD" as any, data: "0x04", label: "reject-me"}); + const assertion = expect(sendPromise).rejects.toThrow(/rejected in wallet/i); + const next = await (await authGet("/api/next")).json(); + await authPost("/api/result", {id: next.tx.id, status: "rejected"}); + await assertion; + }); + + test("error result rejects with the provided message", async () => { + await authPost("/api/connected", {address: ADDRESS}); + const sendPromise = bridge.sendTransaction({to: "0xE" as any, data: "0x05", label: "err-me"}); + const assertion = expect(sendPromise).rejects.toThrow(/insufficient funds/); + const next = await (await authGet("/api/next")).json(); + await authPost("/api/result", {id: next.tx.id, status: "error", message: "insufficient funds"}); + await assertion; + }); + + test("sendTransaction times out when the wallet never signs", async () => { + ({bridge} = makeBridge({txTimeoutMs: 40})); + await bridge.start(); + const sendPromise = bridge.sendTransaction({to: "0xF" as any, data: "0x06", label: "slow"}); + await expect(sendPromise).rejects.toThrow(/Timed out waiting for the wallet to sign/); + }); + + test("waitForConnection times out when nobody connects", async () => { + ({bridge} = makeBridge({connectTimeoutMs: 40})); + await bridge.start(); + await expect(bridge.waitForConnection()).rejects.toThrow(/Timed out waiting for the browser wallet/); + }); + + test("done/abort messages are delivered to a polling page", async () => { + // done + { + const {bridge: b} = makeBridge(); + const {url} = await b.start(); + const p = parse(url); + const poll = fetch(`${b.getUrl().split("#")[0]}api/next`, {headers: {"X-Bridge-Token": p.token}}).then( + r => r.json(), + ); + await b.close("wrapped up"); + const msg = await poll; + expect(msg.type).toBe("done"); + expect(msg.message).toBe("wrapped up"); + } + }); + + test("server is closed after close() (subsequent fetch fails)", async () => { + const url = bridge.getUrl(); + const base = url.split("#")[0]; + await bridge.close(); + await expect(fetch(base)).rejects.toBeTruthy(); + }); + + test("close() rejects a pending sendTransaction", async () => { + await authPost("/api/connected", {address: ADDRESS}); + const sendPromise = bridge.sendTransaction({to: "0x1" as any, data: "0x07", label: "pending"}); + // Attach the rejection expectation BEFORE close() so the rejection is never + // momentarily unhandled. + const assertion = expect(sendPromise).rejects.toThrow(/Bridge closed/); + await bridge.close(); + await assertion; + }); +}); + +describe("BrowserWalletBridge — persistent (daemon) mode", () => { + let bridge: BrowserWalletBridge; + let origin: string; + let token: string; + + const authGet = (path: string) => fetch(`${origin}${path}`, {headers: {"X-Bridge-Token": token}}); + const clientPost = (path: string, body: unknown) => + // No Origin header — mimics a Node CLI client (must be exempt from the check). + fetch(`${origin}${path}`, { + method: "POST", + headers: {"X-Bridge-Token": token, "Content-Type": "application/json"}, + body: JSON.stringify(body), + }); + const pagePost = (path: string, body: unknown) => + fetch(`${origin}${path}`, { + method: "POST", + headers: {"X-Bridge-Token": token, "Content-Type": "application/json", Origin: origin}, + body: JSON.stringify(body), + }); + + beforeEach(async () => { + activeBridges.length = 0; + ({bridge} = makeBridge({persistent: true, txTimeoutMs: 5000})); + const {url} = await bridge.start(); + ({origin, token} = parse(url)); + }); + + afterEach(async () => { + await Promise.all(activeBridges.map(b => b.close().catch(() => {}))); + activeBridges.length = 0; + }); + + test("/api/ping and /api/state require the token (403 without)", async () => { + expect((await fetch(`${origin}/api/ping`)).status).toBe(403); + expect((await fetch(`${origin}/api/state`)).status).toBe(403); + expect((await authGet("/api/ping")).status).toBe(200); + }); + + test("/api/session advertises persistent:true", async () => { + const body = await (await authGet("/api/session")).json(); + expect(body.persistent).toBe(true); + }); + + test("enqueue → page next → result → /api/tx reports done/sent/hash", async () => { + await pagePost("/api/connected", {address: ADDRESS}); + // Prime the heartbeat with one poll so the tab is considered alive. + // (a page just after connect polls immediately) + const enq = await (await clientPost("/api/enqueue", {to: "0xTo", data: "0xabcd", value: "0x64", label: "L"})).json(); + expect(typeof enq.id).toBe("string"); + + // Page fetches the tx and reports the result. + const next = await (await authGet("/api/next")).json(); + expect(next.type).toBe("tx"); + expect(next.tx.to).toBe("0xTo"); + expect(next.tx.value).toBe("0x64"); + + // Before result, /api/tx should be delivered (or pending). + const mid = await (await authGet(`/api/tx?id=${enq.id}`)).json(); + expect(["pending", "delivered"]).toContain(mid.state); + + await pagePost("/api/result", {id: next.tx.id, status: "sent", txHash: "0xhash", from: ADDRESS}); + const done = await (await authGet(`/api/tx?id=${enq.id}`)).json(); + expect(done.state).toBe("done"); + expect(done.status).toBe("sent"); + expect(done.txHash).toBe("0xhash"); + expect(done.from).toBe(ADDRESS); + }); + + test("enqueue → rejected result surfaces status:rejected", async () => { + await pagePost("/api/connected", {address: ADDRESS}); + const enq = await (await clientPost("/api/enqueue", {to: "0xA", data: "0x01", label: "r"})).json(); + const next = await (await authGet("/api/next")).json(); + await pagePost("/api/result", {id: next.tx.id, status: "rejected"}); + const done = await (await authGet(`/api/tx?id=${enq.id}`)).json(); + expect(done.state).toBe("done"); + expect(done.status).toBe("rejected"); + expect(done.message).toMatch(/rejected in wallet/i); + }); + + test("enqueue → error result surfaces status:error with message", async () => { + await pagePost("/api/connected", {address: ADDRESS}); + const enq = await (await clientPost("/api/enqueue", {to: "0xA", data: "0x01", label: "e"})).json(); + const next = await (await authGet("/api/next")).json(); + await pagePost("/api/result", {id: next.tx.id, status: "error", message: "insufficient funds"}); + const done = await (await authGet(`/api/tx?id=${enq.id}`)).json(); + expect(done.status).toBe("error"); + expect(done.message).toBe("insufficient funds"); + }); + + test("enqueue exempt from Origin check; page POST with bad Origin still 403", async () => { + await pagePost("/api/connected", {address: ADDRESS}); + // client POST with NO origin succeeds (200). + const ok = await clientPost("/api/enqueue", {to: "0xA", data: "0x01", label: "x"}); + expect(ok.status).toBe(200); + // page route with wrong origin is still rejected. + const bad = await fetch(`${origin}/api/connected`, { + method: "POST", + headers: {"X-Bridge-Token": token, "Content-Type": "application/json", Origin: "http://evil.example"}, + body: JSON.stringify({address: ADDRESS}), + }); + expect(bad.status).toBe(403); + }); + + test("enqueue returns 409 wallet-not-connected before connect", async () => { + const res = await clientPost("/api/enqueue", {to: "0xA", data: "0x01", label: "x"}); + expect(res.status).toBe(409); + expect((await res.json()).error).toBe("wallet-not-connected"); + }); + + test("heartbeat: lastPagePollAt advances on /api/next", async () => { + const before = bridge.getState().lastPagePollAt; + // Fire a poll but do NOT await it: with nothing queued the server long-polls + // (holds ~25s). The heartbeat is stamped synchronously at handler entry. + void authGet("/api/next").catch(() => {}); + await new Promise(r => setTimeout(r, 50)); + const after = bridge.getState().lastPagePollAt; + expect(after).toBeGreaterThan(before); + expect(after).toBeGreaterThan(0); + }); + + test("two concurrent enqueues deliver strictly FIFO with independent results", async () => { + await pagePost("/api/connected", {address: ADDRESS}); + const a = await (await clientPost("/api/enqueue", {to: "0xA", data: "0x01", label: "first"})).json(); + const b = await (await clientPost("/api/enqueue", {to: "0xB", data: "0x02", label: "second"})).json(); + + const n1 = await (await authGet("/api/next")).json(); + expect(n1.tx.label).toBe("first"); + await pagePost("/api/result", {id: n1.tx.id, status: "sent", txHash: "0xh1"}); + + const n2 = await (await authGet("/api/next")).json(); + expect(n2.tx.label).toBe("second"); + await pagePost("/api/result", {id: n2.tx.id, status: "sent", txHash: "0xh2"}); + + expect((await (await authGet(`/api/tx?id=${a.id}`)).json()).txHash).toBe("0xh1"); + expect((await (await authGet(`/api/tx?id=${b.id}`)).json()).txHash).toBe("0xh2"); + }); + + test("/api/shutdown responds ok then closes the server", async () => { + const res = await clientPost("/api/shutdown", {}); + expect(res.status).toBe(200); + // Server should be closing/closed shortly after. + await new Promise(r => setTimeout(r, 120)); + await expect(fetch(`${origin}/`)).rejects.toBeTruthy(); + }); + + test("enqueue is 404 on a non-persistent bridge", async () => { + const {bridge: b} = makeBridge({persistent: false}); + const {url} = await b.start(); + const p = parse(url); + const res = await fetch(`${p.origin}/api/enqueue`, { + method: "POST", + headers: {"X-Bridge-Token": p.token, "Content-Type": "application/json"}, + body: JSON.stringify({to: "0xA", data: "0x01", label: "x"}), + }); + expect(res.status).toBe(404); + }); +}); diff --git a/tests/libs/browserSend.test.ts b/tests/libs/browserSend.test.ts new file mode 100644 index 00000000..1f6cd223 --- /dev/null +++ b/tests/libs/browserSend.test.ts @@ -0,0 +1,183 @@ +import {describe, test, expect, vi, beforeEach} from "vitest"; + +// Mock the bridge so no real HTTP server is started. +const bridgeSend = vi.fn(); +const bridgeClose = vi.fn().mockResolvedValue(undefined); +const bridgeStart = vi.fn().mockResolvedValue({url: "http://127.0.0.1:12345/#s=tok"}); +const bridgeWaitForConnection = vi.fn().mockResolvedValue("0xConnected0000000000000000000000000000001"); + +vi.mock("../../src/lib/wallet/browserBridge", () => ({ + BrowserWalletBridge: vi.fn().mockImplementation(() => ({ + start: bridgeStart, + waitForConnection: bridgeWaitForConnection, + sendTransaction: bridgeSend, + close: bridgeClose, + getUrl: () => "http://127.0.0.1:12345/#s=tok", + })), +})); + +// Mock viem's publicClient (preflight + receipt). +const publicCall = vi.fn().mockResolvedValue({data: "0x"}); +const waitForReceipt = vi.fn(); +vi.mock("viem", async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + createPublicClient: vi.fn(() => ({call: publicCall, waitForTransactionReceipt: waitForReceipt})), + }; +}); + +import {openBrowserWalletSession} from "../../src/lib/wallet/browserSend"; + +const CHAIN: any = { + id: 4221, + name: "Genlayer Bradbury Testnet", + rpcUrls: {default: {http: ["https://rpc.example"]}}, + nativeCurrency: {name: "GEN Token", symbol: "GEN", decimals: 18}, + blockExplorers: {default: {url: "https://explorer.example"}}, +}; + +async function makeSession() { + return openBrowserWalletSession({chain: CHAIN, rpcUrl: "https://rpc.example"}); +} + +describe("openBrowserWalletSession", () => { + beforeEach(() => { + vi.clearAllMocks(); + bridgeStart.mockResolvedValue({url: "http://127.0.0.1:12345/#s=tok"}); + bridgeWaitForConnection.mockResolvedValue("0xConnected0000000000000000000000000000001"); + publicCall.mockResolvedValue({data: "0x"}); + }); + + test("starts the bridge and resolves the connected signer address", async () => { + const session = await makeSession(); + expect(bridgeStart).toHaveBeenCalledOnce(); + expect(session.signerAddress).toBe("0xConnected0000000000000000000000000000001"); + }); + + describe("sendTransaction (Lane A)", () => { + test("preflights, queues to the bridge, waits the receipt, returns it", async () => { + bridgeSend.mockResolvedValue("0xhash"); + waitForReceipt.mockResolvedValue({ + status: "success", + transactionHash: "0xhash", + blockNumber: 1n, + gasUsed: 2n, + }); + const session = await makeSession(); + + const receipt = await session.sendTransaction({ + to: "0xTo000000000000000000000000000000000000001", + data: "0xabcd", + value: 100n, + label: "Test", + }); + + expect(publicCall).toHaveBeenCalledOnce(); + expect(bridgeSend).toHaveBeenCalledWith( + expect.objectContaining({ + to: "0xTo000000000000000000000000000000000000001", + data: "0xabcd", + value: 100n, + }), + ); + expect(receipt.transactionHash).toBe("0xhash"); + }); + + test("aborts + throws when the preflight predicts a revert", async () => { + publicCall.mockRejectedValue({shortMessage: "execution reverted"}); + const session = await makeSession(); + await expect(session.sendTransaction({to: "0xTo", data: "0x", label: "x"} as any)).rejects.toThrow( + /would revert \(preflight\): execution reverted/, + ); + expect(bridgeClose).toHaveBeenCalled(); + expect(bridgeSend).not.toHaveBeenCalled(); + }); + + test("throws on an on-chain revert receipt", async () => { + bridgeSend.mockResolvedValue("0xhash"); + waitForReceipt.mockResolvedValue({status: "reverted", transactionHash: "0xhash"}); + const session = await makeSession(); + await expect(session.sendTransaction({to: "0xTo", data: "0x", label: "x"} as any)).rejects.toThrow( + /reverted/, + ); + }); + }); + + describe("eip1193Provider (Lane B shim)", () => { + test("eth_chainId is answered locally as the configured chain id hex", async () => { + const session = await makeSession(); + await expect(session.eip1193Provider.request({method: "eth_chainId"})).resolves.toBe("0x107d"); + }); + + test("eth_accounts / eth_requestAccounts return the connected signer", async () => { + const session = await makeSession(); + await expect(session.eip1193Provider.request({method: "eth_accounts"})).resolves.toEqual([ + session.signerAddress, + ]); + await expect(session.eip1193Provider.request({method: "eth_requestAccounts"})).resolves.toEqual([ + session.signerAddress, + ]); + }); + + test("eth_sendTransaction forwards hex fields to the bridge and resolves the hash", async () => { + bridgeSend.mockResolvedValue("0xhash"); + const session = await makeSession(); + const hash = await session.eip1193Provider.request({ + method: "eth_sendTransaction", + params: [ + { + from: session.signerAddress, + to: "0xConsensus000000000000000000000000000000001", + data: "0xdeadbeef", + value: "0x64", + gas: "0x5208", + gasPrice: "0x1", + nonce: "0x2", + type: "0x0", + }, + ], + }); + expect(hash).toBe("0xhash"); + expect(bridgeSend).toHaveBeenCalledWith( + expect.objectContaining({ + to: "0xConsensus000000000000000000000000000000001", + data: "0xdeadbeef", + value: 100n, + gas: 21000n, + gasPrice: 1n, + nonce: 2, + type: "0x0", + }), + ); + // Does NOT wait for the receipt (the SDK does that). + expect(waitForReceipt).not.toHaveBeenCalled(); + }); + + test("setNextLabel is consumed for exactly the next send, then reset to default", async () => { + bridgeSend.mockResolvedValue("0xhash"); + const session = await makeSession(); + session.setNextLabel("Deploy Counter.py"); + await session.eip1193Provider.request({ + method: "eth_sendTransaction", + params: [{to: "0xA", data: "0x"}], + }); + expect(bridgeSend).toHaveBeenLastCalledWith(expect.objectContaining({label: "Deploy Counter.py"})); + + await session.eip1193Provider.request({ + method: "eth_sendTransaction", + params: [{to: "0xB", data: "0x"}], + }); + expect(bridgeSend).toHaveBeenLastCalledWith(expect.objectContaining({label: "GenLayer transaction"})); + }); + + test("unsupported methods throw a clear error", async () => { + const session = await makeSession(); + for (const method of ["personal_sign", "eth_signTypedData_v4", "eth_signTransaction"]) { + await expect(session.eip1193Provider.request({method})).rejects.toThrow( + new RegExp(`Method ${method} is not supported`), + ); + } + }); + }); +}); diff --git a/tests/libs/sessionClient.test.ts b/tests/libs/sessionClient.test.ts new file mode 100644 index 00000000..e988a1ba --- /dev/null +++ b/tests/libs/sessionClient.test.ts @@ -0,0 +1,155 @@ +import {describe, test, expect, beforeEach, afterEach} from "vitest"; +import { + BrowserWalletBridge, + serializeBridgeTx, + type BridgeChainParams, +} from "../../src/lib/wallet/browserBridge"; +import {WalletSessionClient} from "../../src/lib/wallet/sessionClient"; +import type {WalletSessionDescriptor} from "../../src/lib/wallet/sessionDescriptor"; + +const CHAIN: BridgeChainParams = { + chainId: 4221, + chainName: "Genlayer Bradbury Testnet", + rpcUrls: ["https://rpc.example"], + nativeCurrency: {name: "GEN Token", symbol: "GEN", decimals: 18}, + blockExplorerUrls: ["https://explorer.example"], +}; +const ADDRESS = "0xConnected0000000000000000000000000000001" as `0x${string}`; + +function parse(url: string) { + const u = new URL(url); + return { + origin: `${u.protocol}//${u.host}`, + token: new URLSearchParams(u.hash.slice(1)).get("s")!, + port: Number(u.port), + }; +} + +/** A page simulator: connects, then polls /api/next and reports a fixed result. */ +function drivePage(origin: string, token: string, opts: {status: string; txHash?: string; message?: string}) { + const authGet = (p: string) => fetch(`${origin}${p}`, {headers: {"X-Bridge-Token": token}}); + const pagePost = (p: string, b: unknown) => + fetch(`${origin}${p}`, { + method: "POST", + headers: {"X-Bridge-Token": token, "Content-Type": "application/json", Origin: origin}, + body: JSON.stringify(b), + }); + let stopped = false; + (async () => { + await pagePost("/api/connected", {address: ADDRESS}); + while (!stopped) { + const next = await authGet("/api/next") + .then(r => r.json()) + .catch(() => ({type: "stop"})); + if (next.type === "tx") { + await pagePost("/api/result", {id: next.tx.id, ...opts, from: ADDRESS}); + } else if (next.type === "done" || next.type === "stop") { + break; + } + } + })(); + return () => { + stopped = true; + }; +} + +describe("WalletSessionClient", () => { + let bridge: BrowserWalletBridge; + let descriptor: WalletSessionDescriptor; + let stopPage: (() => void) | null = null; + + beforeEach(async () => { + // openUrl MUST be mocked — an unmocked bridge would call the real `open` + // package and pop (then orphan) a browser tab. In-process fake page only. + bridge = new BrowserWalletBridge({ + chain: CHAIN, + handleSigint: false, + persistent: true, + txTimeoutMs: 5000, + openUrl: async () => undefined, + }); + const {url} = await bridge.start(); + const {port, token} = parse(url); + descriptor = { + version: 1, + pid: process.pid, + port, + token, + address: null, + chainId: 4221, + network: "testnet-bradbury", + rpcUrl: "https://rpc.example", + createdAt: Date.now(), + lastUsed: Date.now(), + }; + }); + + afterEach(async () => { + stopPage?.(); + await bridge.close().catch(() => {}); + }); + + test("ping() true against a live daemon, false on a dead port", async () => { + const client = new WalletSessionClient(descriptor, {pollIntervalMs: 20}); + expect(await client.ping()).toBe(true); + const dead = new WalletSessionClient({...descriptor, port: 1}, {pollIntervalMs: 20}); + expect(await dead.ping()).toBe(false); + }); + + test("waitForConnection resolves the connected signer", async () => { + const {origin, token} = parse(bridge.getUrl()); + stopPage = drivePage(origin, token, {status: "sent", txHash: "0xhash"}); + const client = new WalletSessionClient(descriptor, {pollIntervalMs: 20}); + await expect(client.waitForConnection(3000)).resolves.toBe(ADDRESS); + }); + + test("enqueueTx + waitForTxResult round-trips a sent hash", async () => { + const {origin, token} = parse(bridge.getUrl()); + stopPage = drivePage(origin, token, {status: "sent", txHash: "0xdeadbeef"}); + const client = new WalletSessionClient(descriptor, {pollIntervalMs: 20}); + await client.waitForConnection(3000); + const id = await client.enqueueTx({to: "0xTo" as any, data: "0x01", value: 100n, label: "L"}); + await expect(client.waitForTxResult(id, 3000)).resolves.toBe("0xdeadbeef"); + }); + + test("waitForTxResult throws on a rejected tx", async () => { + const {origin, token} = parse(bridge.getUrl()); + stopPage = drivePage(origin, token, {status: "rejected"}); + const client = new WalletSessionClient(descriptor, {pollIntervalMs: 20}); + await client.waitForConnection(3000); + const id = await client.enqueueTx({to: "0xTo" as any, data: "0x01", label: "L"}); + await expect(client.waitForTxResult(id, 3000)).rejects.toThrow(/rejected in wallet/i); + }); + + test("enqueueTx maps 409 wallet-not-connected to a clear error", async () => { + // No page connected → daemon returns 409. + const client = new WalletSessionClient(descriptor, {pollIntervalMs: 20}); + await expect(client.enqueueTx({to: "0xA" as any, data: "0x01", label: "x"})).rejects.toThrow( + /not connected/i, + ); + }); + + test("bigint → hex round-trip equals serializeBridgeTx output", () => { + const tx = {to: "0xA" as any, data: "0x01" as const, value: 255n, gas: 21000n, label: "L"}; + const s = serializeBridgeTx(tx); + expect(s.value).toBe("0xff"); + expect(s.gas).toBe("0x5208"); + }); + + test("waitForTxResult fails fast when the tab heartbeat is stale", async () => { + const {origin, token} = parse(bridge.getUrl()); + // Connect but do NOT poll /api/next again; then force the heartbeat stale. + await fetch(`${origin}/api/connected`, { + method: "POST", + headers: {"X-Bridge-Token": token, "Content-Type": "application/json", Origin: origin}, + body: JSON.stringify({address: ADDRESS}), + }); + const client = new WalletSessionClient(descriptor, {pollIntervalMs: 20}); + const id = await client.enqueueTx({to: "0xA" as any, data: "0x01", label: "x"}); + // Poke a poll so lastPagePollAt becomes non-zero, then rewind it far into the past. + void fetch(`${origin}/api/next`, {headers: {"X-Bridge-Token": token}}).catch(() => {}); + await new Promise(r => setTimeout(r, 30)); + (bridge as any).lastPagePollAt = Date.now() - 10 * 60_000; + await expect(client.waitForTxResult(id, 3000)).rejects.toThrow(/tab appears to be closed/i); + }); +}); diff --git a/tests/libs/sessionDaemon.test.ts b/tests/libs/sessionDaemon.test.ts new file mode 100644 index 00000000..773a675d --- /dev/null +++ b/tests/libs/sessionDaemon.test.ts @@ -0,0 +1,141 @@ +import {describe, test, expect, beforeEach, afterEach, vi} from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import {ConfigFileManager} from "../../src/lib/config/ConfigFileManager"; +import {runWalletSessionDaemon, type DaemonHandle} from "../../src/lib/wallet/sessionDaemon"; +import {descriptorPath, readDescriptor} from "../../src/lib/wallet/sessionDescriptor"; + +const ADDRESS = "0xConnected0000000000000000000000000000001" as `0x${string}`; + +describe("runWalletSessionDaemon", () => { + let dir: string; + let cfg: ConfigFileManager; + let openUrl: ReturnType; + let handles: DaemonHandle[]; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "gl-daemon-")); + // ConfigFileManager resolves baseFolder against os.homedir(), but an + // absolute path wins (path.resolve semantics), so the temp dir is used as-is. + cfg = new ConfigFileManager(dir); + cfg.writeConfig("network", "localnet"); + openUrl = vi.fn().mockResolvedValue(undefined); + handles = []; + }); + + afterEach(async () => { + await Promise.all(handles.map(h => h.dispose().catch(() => {}))); + fs.rmSync(dir, {recursive: true, force: true}); + }); + + async function run(overrides: Partial[0]> = {}) { + const handle = await runWalletSessionDaemon({ + configManager: cfg, + openUrl, + onExit: () => {}, // never kill the test runner + log: () => {}, + ...overrides, + }); + handles.push(handle); + return handle; + } + + test("writes a descriptor only after listening; opens the tab", async () => { + const h = await run(); + const dpath = descriptorPath(cfg); + const d = readDescriptor(dpath); + expect(d).not.toBeNull(); + expect(d!.pid).toBe(process.pid); + expect(d!.port).toBeGreaterThan(0); + expect(d!.address).toBeNull(); + expect(openUrl).toHaveBeenCalledOnce(); + }); + + test("onConnected rewrites the descriptor address", async () => { + const h = await run(); + const {origin, token} = parse(h.bridge.getUrl()); + await fetch(`${origin}/api/connected`, { + method: "POST", + headers: {"X-Bridge-Token": token, "Content-Type": "application/json", Origin: origin}, + body: JSON.stringify({address: ADDRESS}), + }); + // onConnected is synchronous inside the handler; give the microtask a beat. + await new Promise(r => setTimeout(r, 20)); + expect(readDescriptor(descriptorPath(cfg))!.address).toBe(ADDRESS); + }); + + test("idle TTL exit removes the descriptor", async () => { + const exit = vi.fn(); + const h = await run({idleTtlMs: 1, onExit: exit}); + await new Promise(r => setTimeout(r, 5)); + h.tick(); + await new Promise(r => setTimeout(r, 50)); // cleanupAndExit is async (awaits bridge.close) + expect(exit).toHaveBeenCalledWith(0); + expect(readDescriptor(descriptorPath(cfg))).toBeNull(); + }); + + test("tab-dead exit after connecting then going silent", async () => { + const exit = vi.fn(); + const h = await run({tabDeadGraceMs: 1, connectTimeoutMs: 60_000, idleTtlMs: 60_000, onExit: exit}); + const {origin, token} = parse(h.bridge.getUrl()); + await fetch(`${origin}/api/connected`, { + method: "POST", + headers: {"X-Bridge-Token": token, "Content-Type": "application/json", Origin: origin}, + body: JSON.stringify({address: ADDRESS}), + }); + // One poll to set a heartbeat, then rewind it. + void fetch(`${origin}/api/next`, {headers: {"X-Bridge-Token": token}}).catch(() => {}); + await new Promise(r => setTimeout(r, 20)); + (h.bridge as any).lastPagePollAt = Date.now() - 10 * 60_000; + h.tick(); + await new Promise(r => setTimeout(r, 50)); + expect(exit).toHaveBeenCalledWith(0); + expect(readDescriptor(descriptorPath(cfg))).toBeNull(); + }); + + test("connect timeout exit when nobody connects", async () => { + const exit = vi.fn(); + const h = await run({connectTimeoutMs: 1, idleTtlMs: 60_000, onExit: exit}); + await new Promise(r => setTimeout(r, 5)); + h.tick(); + await new Promise(r => setTimeout(r, 50)); + expect(exit).toHaveBeenCalledWith(0); + expect(readDescriptor(descriptorPath(cfg))).toBeNull(); + }); + + test("second daemon defers to a live one (singleton guard)", async () => { + await run(); // first daemon writes a live descriptor + const exit = vi.fn(); + await expect( + runWalletSessionDaemon({configManager: cfg, openUrl, onExit: exit, log: () => {}}), + ).rejects.toThrow(/already-running/); + expect(exit).toHaveBeenCalledWith(0); + }); + + test("/api/shutdown → descriptor removed + exit(0) (deterministic, no unref polling)", async () => { + const exit = vi.fn(); + const h = await run({idleTtlMs: 60_000, connectTimeoutMs: 60_000, onExit: exit}); + expect(readDescriptor(descriptorPath(cfg))).not.toBeNull(); + + const {origin, token} = parse(h.bridge.getUrl()); + // POST /api/shutdown as a CLI client would (no Origin header). + const res = await fetch(`${origin}/api/shutdown`, { + method: "POST", + headers: {"X-Bridge-Token": token, "Content-Type": "application/json"}, + }); + expect(res.status).toBe(200); + + // onShutdown → cleanupAndExit runs the ordered teardown synchronously enough; + // give the awaited bridge.close a beat. + await new Promise(r => setTimeout(r, 100)); + // The invariant: daemon exited AND the descriptor is gone. + expect(exit).toHaveBeenCalledWith(0); + expect(readDescriptor(descriptorPath(cfg))).toBeNull(); + }); + + function parse(url: string) { + const u = new URL(url); + return {origin: `${u.protocol}//${u.host}`, token: new URLSearchParams(u.hash.slice(1)).get("s")!}; + } +}); diff --git a/tests/libs/sessionDescriptor.test.ts b/tests/libs/sessionDescriptor.test.ts new file mode 100644 index 00000000..de58a48e --- /dev/null +++ b/tests/libs/sessionDescriptor.test.ts @@ -0,0 +1,93 @@ +import {describe, test, expect, beforeEach, afterEach} from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + writeDescriptor, + readDescriptor, + removeDescriptor, + isPidAlive, + descriptorPath, + type WalletSessionDescriptor, +} from "../../src/lib/wallet/sessionDescriptor"; + +function makeDescriptor(overrides: Partial = {}): WalletSessionDescriptor { + return { + version: 1, + pid: process.pid, + port: 51234, + token: "tok-123", + address: null, + chainId: 4221, + network: "testnet-bradbury", + rpcUrl: "https://rpc.example", + createdAt: 1000, + lastUsed: 1000, + ...overrides, + }; +} + +describe("sessionDescriptor", () => { + let dir: string; + let file: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "gl-session-")); + file = path.join(dir, "wallet-session.json"); + }); + + afterEach(() => { + fs.rmSync(dir, {recursive: true, force: true}); + }); + + test("descriptorPath resolves against the config manager folder", () => { + const fake = {getFilePath: (n: string) => path.join("/home/x/.genlayer", n)} as any; + expect(descriptorPath(fake)).toBe("/home/x/.genlayer/wallet-session.json"); + }); + + test("writeDescriptor writes 0600 and readDescriptor round-trips", () => { + const d = makeDescriptor(); + writeDescriptor(file, d); + const mode = fs.statSync(file).mode & 0o777; + expect(mode).toBe(0o600); + expect(readDescriptor(file)).toEqual(d); + // No leftover temp file. + expect(fs.existsSync(`${file}.tmp`)).toBe(false); + }); + + test("writeDescriptor overwrites atomically (existing file replaced)", () => { + writeDescriptor(file, makeDescriptor({port: 1})); + writeDescriptor(file, makeDescriptor({port: 2})); + expect(readDescriptor(file)!.port).toBe(2); + }); + + test("readDescriptor returns null for a missing file", () => { + expect(readDescriptor(path.join(dir, "nope.json"))).toBeNull(); + }); + + test("readDescriptor returns null for garbage JSON", () => { + fs.writeFileSync(file, "not json{"); + expect(readDescriptor(file)).toBeNull(); + }); + + test("readDescriptor returns null for wrong version / bad shape", () => { + fs.writeFileSync(file, JSON.stringify({version: 2, pid: 1})); + expect(readDescriptor(file)).toBeNull(); + fs.writeFileSync(file, JSON.stringify({...makeDescriptor(), token: 123})); + expect(readDescriptor(file)).toBeNull(); + }); + + test("removeDescriptor deletes and is idempotent", () => { + writeDescriptor(file, makeDescriptor()); + removeDescriptor(file); + expect(fs.existsSync(file)).toBe(false); + // No throw on a second removal. + expect(() => removeDescriptor(file)).not.toThrow(); + }); + + test("isPidAlive: own pid alive, an impossibly-high pid dead", () => { + expect(isPidAlive(process.pid)).toBe(true); + // 2^30-ish pid will not exist on any real system. + expect(isPidAlive(0x3fffffff)).toBe(false); + }); +}); diff --git a/tests/libs/sessionResolver.test.ts b/tests/libs/sessionResolver.test.ts new file mode 100644 index 00000000..ff5b6d5f --- /dev/null +++ b/tests/libs/sessionResolver.test.ts @@ -0,0 +1,283 @@ +import {describe, test, expect, beforeEach, afterEach, vi} from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import {ConfigFileManager} from "../../src/lib/config/ConfigFileManager"; +import {BrowserWalletBridge} from "../../src/lib/wallet/browserBridge"; +import {resolveBrowserWalletSession} from "../../src/lib/wallet/sessionResolver"; +import {openRemoteWalletSession} from "../../src/lib/wallet/browserSend"; +import type {SessionState} from "../../src/lib/wallet/sessionClient"; +import {HEARTBEAT_DEAD_MS, TAB_CLOSED_MESSAGE} from "../../src/lib/wallet/sessionConstants"; +import { + descriptorPath, + readDescriptor, + writeDescriptor, + type WalletSessionDescriptor, +} from "../../src/lib/wallet/sessionDescriptor"; + +// Partial mock: keep the real bridge/session builders, but wrap +// openRemoteWalletSession so tests can assert whether acquire ever reached it. +vi.mock("../../src/lib/wallet/browserSend", async importActual => { + const actual = await importActual(); + return {...actual, openRemoteWalletSession: vi.fn(actual.openRemoteWalletSession)}; +}); + +const ADDRESS = "0xConnected0000000000000000000000000000001" as `0x${string}`; + +/** + * A fetch stub answering /api/ping + /api/state for a discovered daemon, so a + * test can pin lastPagePollAt precisely (a real in-process bridge never polls, + * so its lastPagePollAt stays 0). Any other route returns {}. + */ +function fetchStub(overrides: Partial): typeof fetch { + const state: SessionState = { + connected: true, + address: ADDRESS, + chainId: 4221, + chainIdHex: "0x107d", + chainName: "Genlayer Bradbury Testnet", + url: "http://127.0.0.1:1/gl-wallet#s=tok", + lastPagePollAt: 0, + queuedCount: 0, + createdAt: Date.now(), + ...overrides, + }; + const fn = vi.fn(async (input: any) => { + const url = typeof input === "string" ? input : String(input?.url ?? input); + if (url.endsWith("/api/ping")) return new Response(JSON.stringify({status: "ok"}), {status: 200}); + if (url.endsWith("/api/state")) return new Response(JSON.stringify(state), {status: 200}); + return new Response("{}", {status: 200}); + }); + return fn as unknown as typeof fetch; +} + +const CHAIN: any = { + id: 4221, + name: "Genlayer Bradbury Testnet", + rpcUrls: {default: {http: ["https://rpc.example"]}}, + nativeCurrency: {name: "GEN Token", symbol: "GEN", decimals: 18}, + blockExplorers: {default: {url: "https://explorer.example"}}, +}; + +function parse(url: string) { + const u = new URL(url); + return { + origin: `${u.protocol}//${u.host}`, + token: new URLSearchParams(u.hash.slice(1)).get("s")!, + port: Number(u.port), + }; +} + +describe("resolveBrowserWalletSession", () => { + let dir: string; + let cfg: ConfigFileManager; + let bridge: BrowserWalletBridge | null; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "gl-resolver-")); + cfg = new ConfigFileManager(dir); + bridge = null; + vi.mocked(openRemoteWalletSession).mockClear(); + }); + + /** Write a descriptor for a daemon that is pid-alive (this process) on chain 4221. */ + function writeLiveDescriptor(chainId = 4221): void { + writeDescriptor(descriptorPath(cfg), { + version: 1, + pid: process.pid, + port: 1, + token: "tok", + address: ADDRESS, + chainId, + network: "testnet-bradbury", + rpcUrl: "https://rpc.example", + createdAt: Date.now(), + lastUsed: Date.now(), + }); + } + afterEach(async () => { + await bridge?.close().catch(() => {}); + fs.rmSync(dir, {recursive: true, force: true}); + }); + + /** Start a persistent bridge, write its descriptor, connect the wallet. */ + async function liveDaemon(chainId = 4221): Promise { + bridge = new BrowserWalletBridge({ + chain: { + chainId, + chainName: "c", + rpcUrls: ["r"], + nativeCurrency: {name: "n", symbol: "s", decimals: 18}, + }, + handleSigint: false, + persistent: true, + // Mocked — never call the real `open` (would pop/orphan a browser tab). + openUrl: async () => undefined, + }); + const {url} = await bridge.start(); + const {origin, token, port} = parse(url); + await fetch(`${origin}/api/connected`, { + method: "POST", + headers: {"X-Bridge-Token": token, "Content-Type": "application/json", Origin: origin}, + body: JSON.stringify({address: ADDRESS}), + }); + const d: WalletSessionDescriptor = { + version: 1, + pid: process.pid, + port, + token, + address: ADDRESS, + chainId, + network: "testnet-bradbury", + rpcUrl: "https://rpc.example", + createdAt: Date.now(), + lastUsed: Date.now(), + }; + writeDescriptor(descriptorPath(cfg), d); + return d; + } + + test("live session → remote session", async () => { + await liveDaemon(); + const session = await resolveBrowserWalletSession({ + chain: CHAIN, + rpcUrl: "https://rpc.example", + configManager: cfg, + fallback: "error", + pollIntervalMs: 20, + } as any); + expect(session.kind).toBe("remote"); + expect(session.signerAddress).toBe(ADDRESS); + }); + + test("stale page heartbeat (tab closed) → rejects with reconnect message, no session", async () => { + writeLiveDescriptor(); + const stale = Date.now() - (HEARTBEAT_DEAD_MS + 30_000); + await expect( + resolveBrowserWalletSession({ + chain: CHAIN, + rpcUrl: "https://rpc.example", + configManager: cfg, + fallback: "error", + fetchFn: fetchStub({connected: true, address: ADDRESS, lastPagePollAt: stale}), + }), + ).rejects.toThrow(TAB_CLOSED_MESSAGE); + // Fail fast at acquire: never bound a session to the dead tab. + expect(openRemoteWalletSession).not.toHaveBeenCalled(); + // Descriptor left in place — the daemon self-manages its tab-dead shutdown. + expect(readDescriptor(descriptorPath(cfg))).not.toBeNull(); + }); + + test("fresh page heartbeat (recent poll) → resolves to a remote session", async () => { + writeLiveDescriptor(); + const session = await resolveBrowserWalletSession({ + chain: CHAIN, + rpcUrl: "https://rpc.example", + configManager: cfg, + fallback: "error", + fetchFn: fetchStub({connected: true, address: ADDRESS, lastPagePollAt: Date.now()}), + }); + expect(session.kind).toBe("remote"); + expect(session.signerAddress).toBe(ADDRESS); + expect(openRemoteWalletSession).toHaveBeenCalledTimes(1); + }); + + test("never-polled page (lastPagePollAt === 0) is treated as fresh → resolves", async () => { + writeLiveDescriptor(); + const session = await resolveBrowserWalletSession({ + chain: CHAIN, + rpcUrl: "https://rpc.example", + configManager: cfg, + fallback: "error", + fetchFn: fetchStub({connected: true, address: ADDRESS, lastPagePollAt: 0}), + }); + expect(session.kind).toBe("remote"); + expect(openRemoteWalletSession).toHaveBeenCalledTimes(1); + }); + + test("stale descriptor → cleaned up → error fallback throws", async () => { + // Descriptor with a dead pid / dead port. + writeDescriptor(descriptorPath(cfg), { + version: 1, + pid: 0x3fffffff, + port: 1, + token: "x", + address: null, + chainId: 4221, + network: "testnet-bradbury", + rpcUrl: "https://rpc.example", + createdAt: Date.now(), + lastUsed: Date.now(), + }); + await expect( + resolveBrowserWalletSession({ + chain: CHAIN, + rpcUrl: "https://rpc.example", + configManager: cfg, + fallback: "error", + }), + ).rejects.toThrow(/wallet connect/i); + // Stale descriptor removed. + expect(readDescriptor(descriptorPath(cfg))).toBeNull(); + }); + + test("chain mismatch throws the exact switch error", async () => { + await liveDaemon(9999); // different chain than CHAIN.id (4221) + await expect( + resolveBrowserWalletSession({ + chain: CHAIN, + rpcUrl: "https://rpc.example", + configManager: cfg, + fallback: "error", + }), + ).rejects.toThrow(/connected to .* but this command targets .* Run 'genlayer wallet connect/s); + }); + + test("auto-start degrades to own-bridge when spawn fails", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + const logWarning = vi.fn(); + // spawnFn that "succeeds" but no daemon ever appears → waitForDaemonReady times out. + const spawnFn = vi.fn().mockReturnValue({pid: 4242, unref: vi.fn()}); + + const resolvePromise = resolveBrowserWalletSession({ + chain: CHAIN, + rpcUrl: "https://rpc.example", + configManager: cfg, + fallback: "auto-start", + openUrl, + handleSigint: false, + spawnFn, + logWarning, + readyTimeoutMs: 300, + }); + + // The own-bridge fallback opens a real bridge and waits for connection. + // Give the daemon-ready poll a moment to time out, then satisfy the bridge. + // Shorten by connecting via the opened bridge URL. + // We can't easily reach the bridge URL here, so just assert it degrades by + // resolving the promise after we connect through openUrl's captured URL. + await vi.waitFor(() => expect(openUrl).toHaveBeenCalled(), {timeout: 15000}); + const url = openUrl.mock.calls[0][0] as string; + const {origin, token} = parse(url); + await fetch(`${origin}/api/connected`, { + method: "POST", + headers: {"X-Bridge-Token": token, "Content-Type": "application/json", Origin: origin}, + body: JSON.stringify({address: ADDRESS}), + }); + const session = await resolvePromise; + expect(session.kind).toBe("local"); + expect(logWarning).toHaveBeenCalled(); + await session.close(); + }, 20000); + + test("error mode with no descriptor throws the connect-first message", async () => { + await expect( + resolveBrowserWalletSession({ + chain: CHAIN, + rpcUrl: "https://rpc.example", + configManager: cfg, + fallback: "error", + }), + ).rejects.toThrow(/Run 'genlayer wallet connect' first/); + }); +}); diff --git a/tests/libs/spawnDaemon.test.ts b/tests/libs/spawnDaemon.test.ts new file mode 100644 index 00000000..41942e10 --- /dev/null +++ b/tests/libs/spawnDaemon.test.ts @@ -0,0 +1,117 @@ +import {describe, test, expect, beforeEach, afterEach, vi} from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import {spawnWalletDaemon, waitForDaemonReady} from "../../src/lib/wallet/spawnDaemon"; +import {BrowserWalletBridge} from "../../src/lib/wallet/browserBridge"; +import {writeDescriptor, type WalletSessionDescriptor} from "../../src/lib/wallet/sessionDescriptor"; + +describe("spawnWalletDaemon", () => { + let dir: string; + let logPath: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "gl-spawn-")); + logPath = path.join(dir, "wallet-daemon.log"); + }); + afterEach(() => fs.rmSync(dir, {recursive: true, force: true})); + + test("re-execs execPath + argv[1] with 'wallet daemon' and network, detached + unref", () => { + const unref = vi.fn(); + const spawnFn = vi.fn().mockReturnValue({pid: 4242, unref}); + const pid = spawnWalletDaemon({ + network: "testnet-bradbury", + rpc: "https://rpc.example", + cliPath: "/x/dist/index.js", + execPath: "/usr/bin/node", + logPath, + spawnFn, + }); + expect(pid).toBe(4242); + expect(unref).toHaveBeenCalledOnce(); + const [cmd, args, options] = spawnFn.mock.calls[0]; + expect(cmd).toBe("/usr/bin/node"); + expect(args).toEqual([ + "/x/dist/index.js", + "wallet", + "daemon", + "--network", + "testnet-bradbury", + "--rpc", + "https://rpc.example", + ]); + expect(options.detached).toBe(true); + expect(options.windowsHide).toBe(true); + }); + + test("throws when spawn returns no pid", () => { + const spawnFn = vi.fn().mockReturnValue({pid: undefined, unref: vi.fn()}); + expect(() => spawnWalletDaemon({logPath, spawnFn})).toThrow(/no pid/i); + }); +}); + +describe("waitForDaemonReady", () => { + let dir: string; + let dpath: string; + let bridge: BrowserWalletBridge; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "gl-ready-")); + dpath = path.join(dir, "wallet-session.json"); + }); + afterEach(async () => { + await bridge?.close().catch(() => {}); + fs.rmSync(dir, {recursive: true, force: true}); + }); + + test("resolves once the descriptor is live and pings", async () => { + bridge = new BrowserWalletBridge({ + chain: { + chainId: 1, + chainName: "x", + rpcUrls: ["r"], + nativeCurrency: {name: "n", symbol: "s", decimals: 18}, + }, + handleSigint: false, + persistent: true, + // Mocked — never call the real `open` (would pop/orphan a browser tab). + openUrl: async () => undefined, + }); + const {url} = await bridge.start(); + const u = new URL(url); + const d: WalletSessionDescriptor = { + version: 1, + pid: process.pid, + port: Number(u.port), + token: new URLSearchParams(u.hash.slice(1)).get("s")!, + address: null, + chainId: 1, + network: "localnet", + rpcUrl: "r", + createdAt: Date.now(), + lastUsed: Date.now(), + }; + writeDescriptor(dpath, d); + await expect(waitForDaemonReady(dpath, {timeoutMs: 2000, intervalMs: 20})).resolves.toMatchObject({ + port: d.port, + }); + }); + + test("times out with a log tail when nothing becomes ready", async () => { + fs.writeFileSync(path.join(dir, "wallet-daemon.log"), "line1\nboom: failed to bind\n"); + await expect( + waitForDaemonReady(dpath, { + timeoutMs: 150, + intervalMs: 30, + logPath: path.join(dir, "wallet-daemon.log"), + }), + ).rejects.toThrow(/did not become ready[\s\S]*boom: failed to bind/); + }); +}); + +// NOTE: There is deliberately NO real-process / real-browser end-to-end test +// here. Automated tests must never spawn a detached daemon or call the real +// openUrl (that would pop a browser and can orphan tabs/processes). The spawn +// wiring is covered above with an injected spawnFn; the daemon runtime and its +// shutdown-cleanup invariant are covered in-process (fetch-driven fake page +// against an ephemeral listen(0) bridge) in tests/libs/sessionDaemon.test.ts. diff --git a/tests/libs/stakingTx.test.ts b/tests/libs/stakingTx.test.ts new file mode 100644 index 00000000..0c213d05 --- /dev/null +++ b/tests/libs/stakingTx.test.ts @@ -0,0 +1,116 @@ +import {describe, test, expect} from "vitest"; +import {encodeFunctionData, encodeEventTopics, encodeAbiParameters, type TransactionReceipt} from "viem"; +import {abi} from "genlayer-js"; +import { + buildValidatorJoinTx, + buildSetIdentityTx, + extractValidatorWallet, +} from "../../src/lib/wallet/stakingTx"; + +const STAKING = "0x4A4449E617F8D10FDeD0b461CadEf83939E821A5"; +const OPERATOR = "0x1111111111111111111111111111111111111111"; +const VALIDATOR_WALLET = "0x2222222222222222222222222222222222222222"; + +describe("buildValidatorJoinTx", () => { + test("encodes the no-operator overload (bare validatorJoin())", () => { + const {to, data} = buildValidatorJoinTx(STAKING); + const expected = encodeFunctionData({abi: abi.STAKING_ABI, functionName: "validatorJoin"}); + expect(to).toBe(STAKING); + expect(data).toBe(expected); + }); + + test("encodes the operator overload with the operator arg", () => { + const {data} = buildValidatorJoinTx(STAKING, OPERATOR); + const expected = encodeFunctionData({ + abi: abi.STAKING_ABI, + functionName: "validatorJoin", + args: [OPERATOR as `0x${string}`], + }); + expect(data).toBe(expected); + }); + + test("selectors differ between the two overloads", () => { + const bare = buildValidatorJoinTx(STAKING).data; + const withOp = buildValidatorJoinTx(STAKING, OPERATOR).data; + expect(bare.slice(0, 10)).not.toBe(withOp.slice(0, 10)); + }); + + test("prefixes a bare (0x-less) staking address", () => { + const {to} = buildValidatorJoinTx(STAKING.slice(2)); + expect(to).toBe(STAKING); + }); +}); + +describe("buildSetIdentityTx", () => { + test("encodes moniker with empty optional fields and 0x extraCid", () => { + const {to, data} = buildSetIdentityTx(VALIDATOR_WALLET, {moniker: "MyValidator"}); + const expected = encodeFunctionData({ + abi: abi.VALIDATOR_WALLET_ABI, + functionName: "setIdentity", + args: ["MyValidator", "", "", "", "", "", "", "", "0x"], + }); + expect(to).toBe(VALIDATOR_WALLET); + expect(data).toBe(expected); + }); + + test("hex extraCid is passed through verbatim", () => { + const {data} = buildSetIdentityTx(VALIDATOR_WALLET, {moniker: "V", extraCid: "0xdeadbeef"}); + expect(data).toBe( + encodeFunctionData({ + abi: abi.VALIDATOR_WALLET_ABI, + functionName: "setIdentity", + args: ["V", "", "", "", "", "", "", "", "0xdeadbeef"], + }), + ); + }); + + test("non-hex extraCid is UTF-8 hex-encoded", () => { + const {data} = buildSetIdentityTx(VALIDATOR_WALLET, {moniker: "V", extraCid: "cid"}); + const cidHex = ("0x" + Buffer.from("cid", "utf-8").toString("hex")) as `0x${string}`; + expect(data).toBe( + encodeFunctionData({ + abi: abi.VALIDATOR_WALLET_ABI, + functionName: "setIdentity", + args: ["V", "", "", "", "", "", "", "", cidHex], + }), + ); + }); +}); + +describe("extractValidatorWallet", () => { + function receiptWithJoinLog(validator: string): TransactionReceipt { + // ValidatorJoin(operator, validator, amount) — all non-indexed. + const topics = encodeEventTopics({abi: abi.STAKING_ABI, eventName: "ValidatorJoin"}); + const data = encodeAbiParameters( + [ + {name: "operator", type: "address"}, + {name: "validator", type: "address"}, + {name: "amount", type: "uint256"}, + ], + [OPERATOR as `0x${string}`, validator as `0x${string}`, 42000n * 10n ** 18n], + ); + return { + transactionHash: "0xabc" as `0x${string}`, + logs: [{data, topics}], + } as unknown as TransactionReceipt; + } + + test("returns the validator wallet address from the ValidatorJoin log", () => { + const receipt = receiptWithJoinLog(VALIDATOR_WALLET); + expect(extractValidatorWallet(receipt).toLowerCase()).toBe(VALIDATOR_WALLET.toLowerCase()); + }); + + test("ignores unrelated / undecodable logs and finds the join event", () => { + const receipt = receiptWithJoinLog(VALIDATOR_WALLET); + (receipt.logs as any).unshift({data: "0x", topics: ["0xdeadbeef"]}); + expect(extractValidatorWallet(receipt).toLowerCase()).toBe(VALIDATOR_WALLET.toLowerCase()); + }); + + test("throws a clear error when no ValidatorJoin event is present", () => { + const receipt = { + transactionHash: "0xdef" as `0x${string}`, + logs: [], + } as unknown as TransactionReceipt; + expect(() => extractValidatorWallet(receipt)).toThrow(/ValidatorJoin event not found/); + }); +}); diff --git a/tests/libs/txBuilders.test.ts b/tests/libs/txBuilders.test.ts new file mode 100644 index 00000000..862ffb27 --- /dev/null +++ b/tests/libs/txBuilders.test.ts @@ -0,0 +1,152 @@ +import {describe, test, expect} from "vitest"; +import {encodeFunctionData, type Abi} from "viem"; +import {abi} from "genlayer-js"; +import {buildTx, encodeExtraCid} from "../../src/lib/wallet/txBuilders"; + +const VALIDATOR_WALLET = "0x2222222222222222222222222222222222222222"; +const VESTING = "0x3333333333333333333333333333333333333333"; +const OPERATOR = "0x1111111111111111111111111111111111111111"; +const VALIDATOR = "0x4444444444444444444444444444444444444444"; + +describe("buildTx (generic calldata builder)", () => { + test("prefixes a bare (0x-less) address", () => { + const {to} = buildTx( + abi.VALIDATOR_WALLET_ABI as unknown as Abi, + VALIDATOR_WALLET.slice(2), + "validatorClaim", + ); + expect(to).toBe(VALIDATOR_WALLET); + }); + + test("no-arg call matches encodeFunctionData without args", () => { + const {data} = buildTx(abi.VALIDATOR_WALLET_ABI as unknown as Abi, VALIDATOR_WALLET, "validatorDeposit"); + expect(data).toBe(encodeFunctionData({abi: abi.VALIDATOR_WALLET_ABI, functionName: "validatorDeposit"})); + }); + + // --- Validator-wallet family --- + test("setOperator(address) encodes against VALIDATOR_WALLET_ABI", () => { + const {to, data} = buildTx(abi.VALIDATOR_WALLET_ABI as unknown as Abi, VALIDATOR_WALLET, "setOperator", [ + OPERATOR, + ]); + expect(to).toBe(VALIDATOR_WALLET); + expect(data).toBe( + encodeFunctionData({ + abi: abi.VALIDATOR_WALLET_ABI, + functionName: "setOperator", + args: [OPERATOR as `0x${string}`], + }), + ); + }); + + test("validatorExit(uint256) encodes shares arg", () => { + const {data} = buildTx(abi.VALIDATOR_WALLET_ABI as unknown as Abi, VALIDATOR_WALLET, "validatorExit", [ + 100n, + ]); + expect(data).toBe( + encodeFunctionData({abi: abi.VALIDATOR_WALLET_ABI, functionName: "validatorExit", args: [100n]}), + ); + }); + + // --- Staking-diamond family --- + test("delegatorClaim(delegator, validator) preserves ARG ORDER (delegator first)", () => { + const delegator = "0x5555555555555555555555555555555555555555"; + const {data} = buildTx(abi.STAKING_ABI as unknown as Abi, VESTING, "delegatorClaim", [ + delegator, + VALIDATOR, + ]); + expect(data).toBe( + encodeFunctionData({ + abi: abi.STAKING_ABI, + functionName: "delegatorClaim", + args: [delegator as `0x${string}`, VALIDATOR as `0x${string}`], + }), + ); + }); + + test("delegatorExit(validator, shares) arg order", () => { + const {data} = buildTx(abi.STAKING_ABI as unknown as Abi, VESTING, "delegatorExit", [VALIDATOR, 7n]); + expect(data).toBe( + encodeFunctionData({ + abi: abi.STAKING_ABI, + functionName: "delegatorExit", + args: [VALIDATOR as `0x${string}`, 7n], + }), + ); + }); + + // --- Vesting family --- + test("vestingDelegatorJoin(validator, amount) encodes against VESTING_ABI", () => { + const {to, data} = buildTx(abi.VESTING_ABI as unknown as Abi, VESTING, "vestingDelegatorJoin", [ + VALIDATOR, + 42n * 10n ** 18n, + ]); + expect(to).toBe(VESTING); + expect(data).toBe( + encodeFunctionData({ + abi: abi.VESTING_ABI, + functionName: "vestingDelegatorJoin", + args: [VALIDATOR as `0x${string}`, 42n * 10n ** 18n], + }), + ); + }); + + test("vestingWithdraw(amount) encodes a single uint arg", () => { + const {data} = buildTx(abi.VESTING_ABI as unknown as Abi, VESTING, "vestingWithdraw", [5n]); + expect(data).toBe( + encodeFunctionData({abi: abi.VESTING_ABI, functionName: "vestingWithdraw", args: [5n]}), + ); + }); + + test("vestingValidatorSetIdentity encodes 10 ordered args incl. extraCid hex + utf8", () => { + const utf8Cid = encodeExtraCid("cid"); + const {data} = buildTx(abi.VESTING_ABI as unknown as Abi, VESTING, "vestingValidatorSetIdentity", [ + VALIDATOR_WALLET, + "Moniker", + "", + "", + "", + "", + "", + "", + "", + utf8Cid, + ]); + expect(data).toBe( + encodeFunctionData({ + abi: abi.VESTING_ABI, + functionName: "vestingValidatorSetIdentity", + args: [VALIDATOR_WALLET as `0x${string}`, "Moniker", "", "", "", "", "", "", "", utf8Cid], + }), + ); + + const hexCid = encodeExtraCid("0xdeadbeef"); + const {data: hexData} = buildTx( + abi.VESTING_ABI as unknown as Abi, + VESTING, + "vestingValidatorSetIdentity", + [VALIDATOR_WALLET, "V", "", "", "", "", "", "", "", hexCid], + ); + expect(hexData).toBe( + encodeFunctionData({ + abi: abi.VESTING_ABI, + functionName: "vestingValidatorSetIdentity", + args: [VALIDATOR_WALLET as `0x${string}`, "V", "", "", "", "", "", "", "", "0xdeadbeef"], + }), + ); + }); +}); + +describe("encodeExtraCid", () => { + test("undefined / empty → 0x", () => { + expect(encodeExtraCid()).toBe("0x"); + expect(encodeExtraCid("")).toBe("0x"); + }); + + test("0x-prefixed passes through verbatim", () => { + expect(encodeExtraCid("0xdeadbeef")).toBe("0xdeadbeef"); + }); + + test("non-hex string is UTF-8 hex-encoded", () => { + expect(encodeExtraCid("cid")).toBe(("0x" + Buffer.from("cid", "utf-8").toString("hex")) as `0x${string}`); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 00000000..4b11f82a --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,33 @@ +import {vi} from "vitest"; + +/** + * Global test safety net: the `open` package launches a real browser. No + * automated test may ever pop a browser tab (it would also orphan the tab when + * the ephemeral bridge port dies). Every bridge/daemon test already injects a + * mocked openUrl; this guarantees that even a test that forgets cannot reach + * the real `open`. Files that specifically exercise openUrl (system.test.ts) + * declare their own file-level vi.mock("open"), which takes precedence there. + */ +vi.mock("open", () => ({default: vi.fn(async () => ({}) as any)})); + +/** + * Hermetic config dir. ConfigFileManager resolves ~/.genlayer against + * os.homedir(), and BaseAction.resolveWalletMode now reads the wallet-session + * descriptor from that dir. The human running the suite may have a live wallet + * session at their real ~/.genlayer/wallet-session.json — which would flip + * bare commands into browser mode and break otherwise-hermetic tests. + * + * Redirect os.homedir() to a throwaway per-worker temp dir so hasLiveWalletSession + * never sees a real descriptor unless a test opts in. Everything else on `os` + * (tmpdir, platform, ...) is preserved. Files that mock os themselves (bare + * vi.mock("os")) override this for their own scope; files that vi.spyOn(os, + * "homedir") layer on top of it. We intentionally derive the path from + * os.tmpdir() with plain string concat and let ConfigFileManager create the + * dir, so this stays independent of any file that mocks "fs"/"path". + */ +vi.mock("os", async importActual => { + const actual = await importActual(); + const home = `${actual.tmpdir()}/genlayer-cli-test-home-${process.pid}`; + const mocked = {...actual, homedir: () => home}; + return {...mocked, default: mocked}; +}); diff --git a/vitest.config.ts b/vitest.config.ts index 49863d8f..44466610 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ globals: true, environment: 'jsdom', testTimeout: 10000, + setupFiles: ['tests/setup.ts'], exclude: [...configDefaults.exclude, 'tests/smoke.test.ts'], coverage: { exclude: [...configDefaults.exclude, '*.js', 'tests/**/*.ts', 'src/types', 'scripts', 'templates'],