Initial Contractless Web3 wallet releases
|
|
@ -0,0 +1,4 @@
|
||||||
|
core/target/
|
||||||
|
extension/node_modules/
|
||||||
|
extension/web-ext-artifacts/
|
||||||
|
tests/storage/dual-signatures/
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
# Contractless Web3 Wallet
|
||||||
|
|
||||||
|
This repository contains the Contractless browser wallet, its browser-safe
|
||||||
|
cryptography and transaction codecs, and the JavaScript SDK used by websites
|
||||||
|
to communicate with the wallet.
|
||||||
|
|
||||||
|
## Repository Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
web3_wallet/
|
||||||
|
|-- core/ Rust and WebAssembly wallet cryptography and transaction codecs
|
||||||
|
|-- extension/ Browser extension source and distributable wallet interface
|
||||||
|
|-- sdk/ JavaScript library for website and application integration
|
||||||
|
|-- tests/ Integration tests and complete working SDK usage examples
|
||||||
|
`-- mockups/ Design references used while developing the wallet interface
|
||||||
|
```
|
||||||
|
|
||||||
|
The components remain separated internally because they have different
|
||||||
|
responsibilities:
|
||||||
|
|
||||||
|
- `core` handles deterministic hashing, keys, wallet encryption, transaction
|
||||||
|
inspection, encoding, signing, and message proofs.
|
||||||
|
- `extension` owns wallet storage, approvals, application sessions, API
|
||||||
|
communication, and the browser interface.
|
||||||
|
- `sdk` gives websites a stable interface for detecting, connecting to, and
|
||||||
|
requesting actions from the installed wallet.
|
||||||
|
- `tests` verifies wallet integration and provides complete working example
|
||||||
|
code for every supported SDK operation.
|
||||||
|
|
||||||
|
The SDK can be published independently later without separating the wallet's
|
||||||
|
source today.
|
||||||
|
|
||||||
|
## Build the Browser Extensions
|
||||||
|
|
||||||
|
Install Rust, `wasm-pack`, Node.js, and pnpm. From `extension`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pnpm install
|
||||||
|
pnpm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
The build compiles separate testnet and mainnet versions of `core` to
|
||||||
|
`extension/src/wasm/testnet` and `extension/src/wasm/mainnet`. Both cores are
|
||||||
|
bundled into each browser package so the wallet can enforce the selected
|
||||||
|
network's address format and transaction codecs locally. Vite then creates
|
||||||
|
separate unpacked packages:
|
||||||
|
|
||||||
|
```text
|
||||||
|
extension/dist/chrome/
|
||||||
|
extension/dist/firefox/
|
||||||
|
```
|
||||||
|
|
||||||
|
Build a single browser target with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pnpm run build:chrome
|
||||||
|
pnpm run build:firefox
|
||||||
|
```
|
||||||
|
|
||||||
|
The wallet stores testnet and mainnet API URLs separately. Testnet defaults to
|
||||||
|
`https://api.contractless.dev`; mainnet remains unconfigured until a mainnet
|
||||||
|
API is available. Wallets and active website sessions are isolated by network,
|
||||||
|
and changing networks locks the active wallet.
|
||||||
|
|
||||||
|
See `extension/README.md` for temporary installation, Firefox validation,
|
||||||
|
packaging, and Mozilla source-submission instructions.
|
||||||
|
|
||||||
|
## Development Checks
|
||||||
|
|
||||||
|
Run the Rust tests from `core`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the TypeScript check from `extension`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pnpm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Website Integration
|
||||||
|
|
||||||
|
Websites should use `sdk/contractless.js` instead of communicating with the
|
||||||
|
injected browser provider directly. See `sdk/README.md` for connection,
|
||||||
|
session, message-signing, and transaction-signing examples.
|
||||||
|
|
||||||
|
Private keys remain inside the extension. Websites receive only approved
|
||||||
|
addresses, signatures, signed transaction bytes, and session-scoped access
|
||||||
|
keys.
|
||||||
|
|
||||||
|
## Integration Test
|
||||||
|
|
||||||
|
The `tests/tests.html` page verifies that a website can detect the extension,
|
||||||
|
request a connection, retrieve the authorized wallet address, and disconnect
|
||||||
|
the temporary application session. It also contains editable, copyable,
|
||||||
|
end-to-end transaction examples for transfers, tokens, NFTs/RWAs, marketing,
|
||||||
|
burning, and vanity addresses. Successful requests display the
|
||||||
|
returned transaction ID, complete signed bytes, byte count, and API broadcast
|
||||||
|
reply.
|
||||||
|
|
||||||
|
The tests are also the complete example-code documentation for using the SDK.
|
||||||
|
Each available wallet action includes working source that developers can view,
|
||||||
|
copy, and adapt directly for their own applications. This covers wallet
|
||||||
|
detection, connection and session handling, address and balance requests,
|
||||||
|
message signing, single-signature transactions, data transactions, and the
|
||||||
|
complete dual-signature swap and loan process.
|
||||||
|
|
||||||
|
The dual-signature examples use the included PHP JSON storage endpoint. Serve
|
||||||
|
the repository from its root directory with PHP:
|
||||||
|
|
||||||
|
```text
|
||||||
|
php -S 127.0.0.1:8080 -t .
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:8080/tests/tests.html
|
||||||
|
```
|
||||||
|
|
||||||
|
PHP creates `tests/storage/dual-signatures/<transaction-hash>.json` when the
|
||||||
|
first wallet signs. The second wallet loads and updates that record, and the
|
||||||
|
broadcast example loads both signatures from it. The web-server user must be
|
||||||
|
able to write to `tests/storage`. JSON is used only to keep the example easy
|
||||||
|
to follow; applications can use any backend language or database.
|
||||||
|
|
||||||
|
The unpacked browser extension must be installed and the wallet must be
|
||||||
|
unlocked before approving the connection request. Opening the page through a
|
||||||
|
`file://` URL is intentionally unsupported because it has no trustworthy
|
||||||
|
website origin.
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
[package]
|
||||||
|
name = "contractless-browser-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Browser-compatible cryptography and transaction codecs for Contractless"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["testnet"]
|
||||||
|
mainnet = []
|
||||||
|
testnet = []
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
aes = "0.8"
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
base64 = "0.22"
|
||||||
|
cbc = { version = "0.1", features = ["alloc", "block-padding"] }
|
||||||
|
falcon-rs = "0.2.4"
|
||||||
|
fn-dsa = "0.3.0"
|
||||||
|
getrandom = { version = "0.2", features = ["js"] }
|
||||||
|
hex = "0.4"
|
||||||
|
hmac = "0.12"
|
||||||
|
image = { version = "0.23", default-features = false, features = ["png"] }
|
||||||
|
pbkdf2 = "0.12"
|
||||||
|
rand = "0.8"
|
||||||
|
ripemd = { version = "0.2.0-rc.0" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde-wasm-bindgen = "0.6"
|
||||||
|
serde_json = { version = "1", features = ["arbitrary_precision", "preserve_order"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
skein = "0.1.0"
|
||||||
|
thiserror = "2"
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
zeroize = { version = "1", features = ["zeroize_derive"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
wasm-bindgen-test = "0.3"
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
# Contractless Browser Core
|
||||||
|
|
||||||
|
The `core` directory contains the deterministic cryptography and transaction
|
||||||
|
codec layer for the Contractless Web3 wallet. Its Rust crate and generated
|
||||||
|
WASM package retain the name `contractless-browser-core`.
|
||||||
|
|
||||||
|
The crate is intentionally independent from the Contractless node runtime. It
|
||||||
|
does not connect to RPC servers, broadcast transactions, access PostgreSQL or
|
||||||
|
Sled, run Tokio tasks, or read files from the operating system.
|
||||||
|
|
||||||
|
The browser wallet is responsible for:
|
||||||
|
|
||||||
|
- obtaining wallet files through browser file APIs;
|
||||||
|
- keeping decrypted private keys inside the extension;
|
||||||
|
- presenting transaction details for user approval;
|
||||||
|
- calling this package to hash, sign, verify, and encode transactions; and
|
||||||
|
- sending signed transaction bytes to a configured Contractless PHP API.
|
||||||
|
|
||||||
|
The initial API exposes:
|
||||||
|
|
||||||
|
- Skein-128, Skein-256, and Skein-512 hashing;
|
||||||
|
- FN-DSA signing and verification;
|
||||||
|
- public-key to short-address derivation; and
|
||||||
|
- short-address byte encoding and decoding.
|
||||||
|
- signed transfer creation and fixed 750-byte transfer decoding.
|
||||||
|
- strict inspection and signing for every externally submitted transaction type;
|
||||||
|
- signer-role checks against the wallet public key;
|
||||||
|
- staged signing for swaps and loans; and
|
||||||
|
- exact fixed-width wire encoding for completed transactions.
|
||||||
|
- domain-separated website message signing and verification.
|
||||||
|
- browser-native wallet generation and AES-256-GCM encrypted storage;
|
||||||
|
- direct import from existing Contractless private-key images;
|
||||||
|
- direct import from hexadecimal FN-DSA private keys;
|
||||||
|
- export compatibility with existing Contractless wallet images; and
|
||||||
|
- wallet-registration payload creation.
|
||||||
|
|
||||||
|
Browser transfer inputs use decimal strings for `value` and `txfee`. This
|
||||||
|
prevents JavaScript's numeric precision limit from changing a signed `u64`
|
||||||
|
value.
|
||||||
|
|
||||||
|
Fixed transaction codecs will be added individually and tested against the
|
||||||
|
native Contractless implementation before they are used by the wallet.
|
||||||
|
|
||||||
|
## Wallet storage
|
||||||
|
|
||||||
|
New browser wallets encrypt their FN-DSA private key with AES-256-GCM. The key
|
||||||
|
is derived from the wallet password with PBKDF2-SHA256 and a unique random
|
||||||
|
salt. Only the encrypted wallet JSON is stored persistently by the extension.
|
||||||
|
|
||||||
|
Existing Contractless wallet files can be imported with their encryption key.
|
||||||
|
Browser wallets can also be exported back to the existing wallet image format
|
||||||
|
for use with the CLI or desktop wallet. The encrypted browser-wallet backup and
|
||||||
|
its password are both required for recovery.
|
||||||
|
|
||||||
|
## Transaction request flow
|
||||||
|
|
||||||
|
Websites submit unsigned transaction JSON to `inspect_transaction_request`.
|
||||||
|
The core rejects unsupported types, missing fields, extra fields, invalid
|
||||||
|
addresses, incorrect fixed-width values, and malformed numeric values. The
|
||||||
|
returned review object is the canonical data the wallet must show to the user.
|
||||||
|
|
||||||
|
After approval, `sign_transaction_request` receives the wallet private and
|
||||||
|
public keys. The public key determines the wallet address, and that address
|
||||||
|
must match the signer role stored in the transaction.
|
||||||
|
|
||||||
|
For swaps and loans, signer two must also provide signer one's registered
|
||||||
|
public key. The core verifies the first signature before adding the second.
|
||||||
|
Only a complete transaction receives `bytes_hex` suitable for broadcasting.
|
||||||
|
|
||||||
|
Genesis and mining reward transactions are intentionally excluded from the
|
||||||
|
signing registry because users and websites must never create them.
|
||||||
|
|
||||||
|
## Website message proofs
|
||||||
|
|
||||||
|
`sign_message_request` signs a short-lived website proof without creating a
|
||||||
|
transaction. The signed payload contains the requesting origin, wallet
|
||||||
|
address, message, random nonce, issue time, and expiration time.
|
||||||
|
|
||||||
|
Message proofs use a Contractless-specific domain prefix. Their signatures
|
||||||
|
cannot be reused as transaction signatures. Proofs are limited to 15 minutes,
|
||||||
|
and production origins must use HTTPS.
|
||||||
|
|
||||||
|
`verify_message_proof` verifies the public key, derived wallet address,
|
||||||
|
signature, expected origin, expected nonce, and current validity window.
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
use ripemd::{Digest, Ripemd160};
|
||||||
|
|
||||||
|
use crate::crypto::skein_256;
|
||||||
|
use crate::error::BrowserCoreError;
|
||||||
|
use crate::{PUBLIC_KEY_LENGTH, REQUIRED_PUBLIC_KEY_HASH_BYTE};
|
||||||
|
|
||||||
|
const SHORT_ADDRESS_HASH_BYTES: usize = 20;
|
||||||
|
const SHORT_ADDRESS_BYTES: usize = 22;
|
||||||
|
|
||||||
|
pub fn network_byte() -> u8 {
|
||||||
|
if cfg!(feature = "mainnet") {
|
||||||
|
0b0000_0001
|
||||||
|
} else {
|
||||||
|
0b0000_0010
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn network_suffix() -> &'static str {
|
||||||
|
if cfg!(feature = "mainnet") {
|
||||||
|
"clc"
|
||||||
|
} else {
|
||||||
|
"cltc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn valid_public_key(public_key: &[u8]) -> bool {
|
||||||
|
public_key.len() == PUBLIC_KEY_LENGTH
|
||||||
|
&& public_key.first() == Some(&9)
|
||||||
|
&& skein_256(public_key)
|
||||||
|
.first()
|
||||||
|
.is_some_and(|byte| *byte == REQUIRED_PUBLIC_KEY_HASH_BYTE)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn public_key_to_short_address(public_key: &[u8]) -> Result<String, BrowserCoreError> {
|
||||||
|
if !valid_public_key(public_key) {
|
||||||
|
return Err(BrowserCoreError::InvalidPublicKey);
|
||||||
|
}
|
||||||
|
let payload = Ripemd160::digest(skein_256(public_key));
|
||||||
|
Ok(format!("{}.{}", hex::encode(payload), network_suffix()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn short_address_to_bytes(address: &str) -> Result<Vec<u8>, BrowserCoreError> {
|
||||||
|
let (payload, suffix) = address
|
||||||
|
.rsplit_once('.')
|
||||||
|
.ok_or_else(|| BrowserCoreError::InvalidAddress(address.to_string()))?;
|
||||||
|
if !suffix.eq_ignore_ascii_case(network_suffix()) || payload.len() != 40 {
|
||||||
|
return Err(BrowserCoreError::InvalidAddress(address.to_string()));
|
||||||
|
}
|
||||||
|
let decoded =
|
||||||
|
hex::decode(payload).map_err(|_| BrowserCoreError::InvalidAddress(address.to_string()))?;
|
||||||
|
if decoded.len() != SHORT_ADDRESS_HASH_BYTES {
|
||||||
|
return Err(BrowserCoreError::InvalidAddress(address.to_string()));
|
||||||
|
}
|
||||||
|
let mut bytes = Vec::with_capacity(SHORT_ADDRESS_BYTES);
|
||||||
|
bytes.extend_from_slice(&decoded);
|
||||||
|
bytes.push(b'.');
|
||||||
|
bytes.push(network_byte());
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bytes_to_short_address(bytes: &[u8]) -> Result<String, BrowserCoreError> {
|
||||||
|
if bytes.len() != SHORT_ADDRESS_BYTES
|
||||||
|
|| bytes[SHORT_ADDRESS_HASH_BYTES] != b'.'
|
||||||
|
|| bytes[SHORT_ADDRESS_BYTES - 1] != network_byte()
|
||||||
|
{
|
||||||
|
return Err(BrowserCoreError::InvalidAddress(hex::encode(bytes)));
|
||||||
|
}
|
||||||
|
Ok(format!(
|
||||||
|
"{}.{}",
|
||||||
|
hex::encode(&bytes[..SHORT_ADDRESS_HASH_BYTES]),
|
||||||
|
network_suffix()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn vanity_address_to_bytes(address: &str) -> Result<Vec<u8>, BrowserCoreError> {
|
||||||
|
let normalized = normalize_vanity_address(address)?;
|
||||||
|
let (payload, _) = normalized
|
||||||
|
.rsplit_once('.')
|
||||||
|
.ok_or_else(|| BrowserCoreError::InvalidAddress(address.to_string()))?;
|
||||||
|
let mut bytes = payload.as_bytes().to_vec();
|
||||||
|
bytes.push(b'.');
|
||||||
|
bytes.push(network_byte());
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_vanity_address(address: &str) -> Result<String, BrowserCoreError> {
|
||||||
|
let (payload, suffix) = address
|
||||||
|
.rsplit_once('.')
|
||||||
|
.ok_or_else(|| BrowserCoreError::InvalidAddress(address.to_string()))?;
|
||||||
|
let visible = payload.trim();
|
||||||
|
if !suffix.eq_ignore_ascii_case(network_suffix())
|
||||||
|
|| visible.is_empty()
|
||||||
|
|| visible.len() > SHORT_ADDRESS_HASH_BYTES
|
||||||
|
|| !visible
|
||||||
|
.chars()
|
||||||
|
.all(|character| character.is_ascii_alphabetic())
|
||||||
|
{
|
||||||
|
return Err(BrowserCoreError::InvalidAddress(address.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(format!(
|
||||||
|
"{:>width$}.{}",
|
||||||
|
visible.to_ascii_lowercase(),
|
||||||
|
network_suffix(),
|
||||||
|
width = SHORT_ADDRESS_HASH_BYTES
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{network_suffix, normalize_vanity_address, vanity_address_to_bytes};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vanity_address_is_left_padded_and_lowercased() {
|
||||||
|
let input = format!("BruceBates.{}", network_suffix());
|
||||||
|
let normalized = normalize_vanity_address(&input).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
normalized,
|
||||||
|
format!(" brucebates.{}", network_suffix())
|
||||||
|
);
|
||||||
|
assert_eq!(vanity_address_to_bytes(&input).unwrap().len(), 22);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vanity_address_rejects_non_letters() {
|
||||||
|
let input = format!("bruce123.{}", network_suffix());
|
||||||
|
|
||||||
|
assert!(normalize_vanity_address(&input).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
use fn_dsa::{
|
||||||
|
SigningKey, SigningKeyStandard, VerifyingKey, VerifyingKeyStandard, DOMAIN_NONE, HASH_ID_RAW,
|
||||||
|
};
|
||||||
|
use rand::thread_rng;
|
||||||
|
use skein::digest::consts::{U32, U64};
|
||||||
|
use skein::digest::{Digest, Output};
|
||||||
|
use skein::{Skein256, Skein512};
|
||||||
|
|
||||||
|
use crate::error::BrowserCoreError;
|
||||||
|
use crate::SIGNATURE_LENGTH;
|
||||||
|
|
||||||
|
pub fn skein_128(data: &[u8]) -> Vec<u8> {
|
||||||
|
let mut hasher = Skein256::new();
|
||||||
|
hasher.update(data);
|
||||||
|
let result: Output<Skein256> = hasher.finalize();
|
||||||
|
result.iter().step_by(2).copied().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn skein_256(data: &[u8]) -> Vec<u8> {
|
||||||
|
let mut hasher = Skein256::<U32>::new();
|
||||||
|
hasher.update(data);
|
||||||
|
hasher.finalize().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn skein_512(data: &[u8]) -> Vec<u8> {
|
||||||
|
let mut hasher = Skein512::<U64>::new();
|
||||||
|
hasher.update(data);
|
||||||
|
hasher.finalize().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sign(message: &[u8], private_key: &[u8]) -> Result<Vec<u8>, BrowserCoreError> {
|
||||||
|
let mut signing_key =
|
||||||
|
SigningKeyStandard::decode(private_key).ok_or(BrowserCoreError::InvalidPrivateKey)?;
|
||||||
|
let mut signature = vec![0u8; SIGNATURE_LENGTH];
|
||||||
|
let mut rng = thread_rng();
|
||||||
|
signing_key.sign(
|
||||||
|
&mut rng,
|
||||||
|
&DOMAIN_NONE,
|
||||||
|
&HASH_ID_RAW,
|
||||||
|
message,
|
||||||
|
&mut signature,
|
||||||
|
);
|
||||||
|
Ok(signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify(message: &[u8], signature: &[u8], public_key: &[u8]) -> bool {
|
||||||
|
if signature.len() != SIGNATURE_LENGTH {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(verifying_key) = VerifyingKeyStandard::decode(public_key) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
verifying_key.verify(signature, &DOMAIN_NONE, &HASH_ID_RAW, message)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
use thiserror::Error;
|
||||||
|
use wasm_bindgen::JsValue;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum BrowserCoreError {
|
||||||
|
#[error("invalid hex value: {0}")]
|
||||||
|
InvalidHex(String),
|
||||||
|
#[error("invalid FN-DSA private key")]
|
||||||
|
InvalidPrivateKey,
|
||||||
|
#[error("invalid FN-DSA public key")]
|
||||||
|
InvalidPublicKey,
|
||||||
|
#[error("invalid FN-DSA signature")]
|
||||||
|
InvalidSignature,
|
||||||
|
#[error("invalid Contractless address: {0}")]
|
||||||
|
InvalidAddress(String),
|
||||||
|
#[error("invalid transfer transaction: {0}")]
|
||||||
|
InvalidTransfer(String),
|
||||||
|
#[error("JSON serialization failed: {0}")]
|
||||||
|
Json(String),
|
||||||
|
#[error("unsupported transaction type: {0}")]
|
||||||
|
UnsupportedTransaction(u8),
|
||||||
|
#[error("invalid transaction request: {0}")]
|
||||||
|
InvalidRequest(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<BrowserCoreError> for JsValue {
|
||||||
|
fn from(error: BrowserCoreError) -> Self {
|
||||||
|
JsValue::from_str(&error.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,264 @@
|
||||||
|
use aes::Aes128;
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
|
use cbc::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit};
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use image::{png::PngEncoder, ColorType, ImageBuffer, Rgba};
|
||||||
|
use sha2::Sha256;
|
||||||
|
|
||||||
|
use crate::error::BrowserCoreError;
|
||||||
|
|
||||||
|
type Aes128CbcEnc = cbc::Encryptor<Aes128>;
|
||||||
|
type Aes128CbcDec = cbc::Decryptor<Aes128>;
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
|
const WIDTH: u32 = 350;
|
||||||
|
const HEIGHT: u32 = 350;
|
||||||
|
const ANCHORS: [u32; 11] = [0, 35, 70, 105, 140, 175, 210, 245, 280, 315, 349];
|
||||||
|
const HEADER: usize = 4;
|
||||||
|
const CAPACITY: usize = ANCHORS.len() * WIDTH as usize - HEADER;
|
||||||
|
|
||||||
|
const COLORS: &[(char, (u8, u8, u8))] = &[
|
||||||
|
('a', (204, 180, 194)),
|
||||||
|
('A', (255, 255, 255)),
|
||||||
|
('b', (197, 186, 201)),
|
||||||
|
('B', (221, 206, 212)),
|
||||||
|
('c', (181, 185, 193)),
|
||||||
|
('C', (184, 201, 223)),
|
||||||
|
('d', (224, 218, 192)),
|
||||||
|
('D', (185, 191, 195)),
|
||||||
|
('e', (181, 197, 198)),
|
||||||
|
('E', (193, 206, 255)),
|
||||||
|
('f', (252, 193, 211)),
|
||||||
|
('F', (183, 192, 229)),
|
||||||
|
('g', (180, 191, 192)),
|
||||||
|
('G', (187, 219, 189)),
|
||||||
|
('h', (195, 187, 234)),
|
||||||
|
('H', (182, 216, 189)),
|
||||||
|
('i', (197, 183, 248)),
|
||||||
|
('I', (200, 182, 204)),
|
||||||
|
('j', (255, 235, 196)),
|
||||||
|
('J', (194, 186, 228)),
|
||||||
|
('k', (199, 238, 239)),
|
||||||
|
('K', (208, 247, 234)),
|
||||||
|
('l', (244, 214, 189)),
|
||||||
|
('L', (187, 243, 239)),
|
||||||
|
('m', (188, 231, 238)),
|
||||||
|
('M', (187, 197, 227)),
|
||||||
|
('n', (186, 240, 191)),
|
||||||
|
('N', (187, 198, 206)),
|
||||||
|
('o', (205, 193, 184)),
|
||||||
|
('O', (191, 187, 197)),
|
||||||
|
('p', (194, 200, 206)),
|
||||||
|
('P', (195, 183, 229)),
|
||||||
|
('q', (182, 219, 196)),
|
||||||
|
('Q', (238, 216, 184)),
|
||||||
|
('r', (199, 181, 208)),
|
||||||
|
('R', (239, 231, 198)),
|
||||||
|
('s', (189, 188, 230)),
|
||||||
|
('S', (242, 192, 230)),
|
||||||
|
('t', (199, 199, 199)),
|
||||||
|
('T', (188, 190, 230)),
|
||||||
|
('u', (230, 180, 253)),
|
||||||
|
('U', (241, 247, 247)),
|
||||||
|
('v', (242, 190, 199)),
|
||||||
|
('V', (230, 247, 234)),
|
||||||
|
('w', (197, 186, 249)),
|
||||||
|
('W', (194, 247, 249)),
|
||||||
|
('x', (242, 182, 246)),
|
||||||
|
('X', (188, 222, 193)),
|
||||||
|
('y', (188, 194, 183)),
|
||||||
|
('Y', (197, 195, 197)),
|
||||||
|
('z', (187, 249, 240)),
|
||||||
|
('Z', (233, 231, 242)),
|
||||||
|
('0', (195, 184, 218)),
|
||||||
|
('1', (232, 180, 196)),
|
||||||
|
('2', (191, 193, 196)),
|
||||||
|
('3', (185, 186, 186)),
|
||||||
|
('4', (191, 247, 180)),
|
||||||
|
('5', (187, 199, 248)),
|
||||||
|
('6', (248, 198, 184)),
|
||||||
|
('7', (243, 195, 184)),
|
||||||
|
('8', (232, 192, 208)),
|
||||||
|
('9', (239, 197, 183)),
|
||||||
|
('/', (199, 187, 241)),
|
||||||
|
('+', (195, 216, 223)),
|
||||||
|
('=', (193, 211, 184)),
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn decrypt_private_key(image_base64: &str, password: &str) -> Result<String, BrowserCoreError> {
|
||||||
|
let encrypted = decode_image(image_base64)?;
|
||||||
|
let packed = STANDARD.decode(encrypted).map_err(|_| {
|
||||||
|
BrowserCoreError::InvalidRequest("legacy wallet encryption is invalid".into())
|
||||||
|
})?;
|
||||||
|
if packed.len() < 49 {
|
||||||
|
return Err(BrowserCoreError::InvalidRequest(
|
||||||
|
"legacy wallet payload is too short".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let key = normalized_key(password);
|
||||||
|
let (iv, remainder) = packed.split_at(16);
|
||||||
|
let (expected_hmac, ciphertext) = remainder.split_at(32);
|
||||||
|
let mut mac = HmacSha256::new_from_slice(&key).unwrap();
|
||||||
|
mac.update(ciphertext);
|
||||||
|
mac.verify_slice(expected_hmac).map_err(|_| {
|
||||||
|
BrowserCoreError::InvalidRequest("wallet password or integrity check failed".into())
|
||||||
|
})?;
|
||||||
|
let plaintext = Aes128CbcDec::new_from_slices(&key, iv)
|
||||||
|
.unwrap()
|
||||||
|
.decrypt_padded_vec_mut::<Pkcs7>(ciphertext)
|
||||||
|
.map_err(|_| BrowserCoreError::InvalidRequest("wallet decryption failed".into()))?;
|
||||||
|
String::from_utf8(plaintext)
|
||||||
|
.map_err(|_| BrowserCoreError::InvalidRequest("wallet private key is not UTF-8".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encrypt_private_key(private_key: &str, password: &str) -> Result<String, BrowserCoreError> {
|
||||||
|
if private_key.len() < 10 {
|
||||||
|
return Err(BrowserCoreError::InvalidPrivateKey);
|
||||||
|
}
|
||||||
|
let key = normalized_key(password);
|
||||||
|
let iv_text = STANDARD.encode(&private_key.as_bytes()[..10]);
|
||||||
|
let iv = iv_text.as_bytes();
|
||||||
|
let ciphertext = Aes128CbcEnc::new_from_slices(&key, iv)
|
||||||
|
.unwrap()
|
||||||
|
.encrypt_padded_vec_mut::<Pkcs7>(private_key.as_bytes());
|
||||||
|
let mut mac = HmacSha256::new_from_slice(&key).unwrap();
|
||||||
|
mac.update(&ciphertext);
|
||||||
|
let mut packed = Vec::with_capacity(48 + ciphertext.len());
|
||||||
|
packed.extend_from_slice(iv);
|
||||||
|
packed.extend_from_slice(&mac.finalize().into_bytes());
|
||||||
|
packed.extend_from_slice(&ciphertext);
|
||||||
|
encode_image(&STANDARD.encode(packed))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_key(password: &str) -> [u8; 16] {
|
||||||
|
let mut key = [0u8; 16];
|
||||||
|
let bytes = password.as_bytes();
|
||||||
|
key[..bytes.len().min(16)].copy_from_slice(&bytes[..bytes.len().min(16)]);
|
||||||
|
key
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vivid((r, g, b): (u8, u8, u8)) -> (u8, u8, u8) {
|
||||||
|
let average = (r as f32 + g as f32 + b as f32) / 3.0;
|
||||||
|
let apply = |value: u8| {
|
||||||
|
let saturated = average + ((value as f32 - average) * 2.35);
|
||||||
|
(((saturated - 128.0) * 1.12) + 128.0 - 18.0)
|
||||||
|
.clamp(0.0, 255.0)
|
||||||
|
.round() as u8
|
||||||
|
};
|
||||||
|
(apply(r), apply(g), apply(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn color(character: char) -> Option<(u8, u8, u8)> {
|
||||||
|
COLORS
|
||||||
|
.iter()
|
||||||
|
.find(|(candidate, _)| *candidate == character)
|
||||||
|
.map(|(_, value)| vivid(*value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn character(rgb: (u8, u8, u8)) -> Option<char> {
|
||||||
|
COLORS
|
||||||
|
.iter()
|
||||||
|
.find(|(_, value)| vivid(*value) == rgb)
|
||||||
|
.map(|(character, _)| *character)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_image(image_base64: &str) -> Result<String, BrowserCoreError> {
|
||||||
|
let bytes = STANDARD
|
||||||
|
.decode(image_base64)
|
||||||
|
.map_err(|_| BrowserCoreError::InvalidRequest("wallet image is not valid base64".into()))?;
|
||||||
|
let image = image::load_from_memory_with_format(&bytes, image::ImageFormat::Png)
|
||||||
|
.map_err(|_| BrowserCoreError::InvalidRequest("wallet image is not a PNG".into()))?
|
||||||
|
.to_rgba8();
|
||||||
|
if image.width() != WIDTH || image.height() != HEIGHT {
|
||||||
|
return Err(BrowserCoreError::InvalidRequest(
|
||||||
|
"wallet image dimensions are invalid".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut text = String::with_capacity(ANCHORS.len() * WIDTH as usize);
|
||||||
|
for row in ANCHORS {
|
||||||
|
for x in 0..WIDTH {
|
||||||
|
let pixel = image.get_pixel(x, row).0;
|
||||||
|
text.push(character((pixel[0], pixel[1], pixel[2])).ok_or_else(|| {
|
||||||
|
BrowserCoreError::InvalidRequest(
|
||||||
|
"wallet image contains an unknown anchor color".into(),
|
||||||
|
)
|
||||||
|
})?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let length = text
|
||||||
|
.get(..HEADER)
|
||||||
|
.and_then(|value| value.parse::<usize>().ok())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
BrowserCoreError::InvalidRequest("wallet image length header is invalid".into())
|
||||||
|
})?;
|
||||||
|
text.get(HEADER..HEADER + length)
|
||||||
|
.map(str::to_owned)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
BrowserCoreError::InvalidRequest("wallet image payload is incomplete".into())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_image(ciphertext: &str) -> Result<String, BrowserCoreError> {
|
||||||
|
if ciphertext.len() > CAPACITY {
|
||||||
|
return Err(BrowserCoreError::InvalidRequest(
|
||||||
|
"encrypted wallet exceeds image capacity".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let payload = format!("{:04}{}", ciphertext.len(), ciphertext);
|
||||||
|
let chars: Vec<char> = payload.chars().collect();
|
||||||
|
let slot_count = ANCHORS.len() * WIDTH as usize;
|
||||||
|
let slots: Vec<char> = (0..slot_count)
|
||||||
|
.map(|index| chars[index % chars.len()])
|
||||||
|
.collect();
|
||||||
|
let mut image: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(WIDTH, HEIGHT);
|
||||||
|
for x in 0..WIDTH as usize {
|
||||||
|
for anchor in 0..ANCHORS.len() - 1 {
|
||||||
|
let upper_y = ANCHORS[anchor];
|
||||||
|
let lower_y = ANCHORS[anchor + 1];
|
||||||
|
let upper = color(slots[anchor * WIDTH as usize + x]).unwrap();
|
||||||
|
let lower = color(slots[(anchor + 1) * WIDTH as usize + x]).unwrap();
|
||||||
|
let distance = lower_y - upper_y;
|
||||||
|
for y in upper_y..=lower_y {
|
||||||
|
let ratio = (y - upper_y) as f32 / distance as f32;
|
||||||
|
let blend = |start: u8, end: u8| {
|
||||||
|
(start as f32 + ((end as f32 - start as f32) * ratio)).round() as u8
|
||||||
|
};
|
||||||
|
image.put_pixel(
|
||||||
|
x as u32,
|
||||||
|
y,
|
||||||
|
Rgba([
|
||||||
|
blend(upper.0, lower.0),
|
||||||
|
blend(upper.1, lower.1),
|
||||||
|
blend(upper.2, lower.2),
|
||||||
|
255,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut png = Vec::new();
|
||||||
|
PngEncoder::new(&mut png)
|
||||||
|
.encode(image.as_raw(), WIDTH, HEIGHT, ColorType::Rgba8)
|
||||||
|
.map_err(|_| BrowserCoreError::InvalidRequest("failed to encode wallet image".into()))?;
|
||||||
|
Ok(STANDARD.encode(png))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{decrypt_private_key, encrypt_private_key};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_private_key_image_round_trips() {
|
||||||
|
let private_key = "ab".repeat(1281);
|
||||||
|
let encrypted = encrypt_private_key(&private_key, "correct horse battery staple").unwrap();
|
||||||
|
let decrypted = decrypt_private_key(&encrypted, "correct horse battery staple").unwrap();
|
||||||
|
assert_eq!(decrypted, private_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_private_key_image_rejects_wrong_password() {
|
||||||
|
let private_key = "cd".repeat(1281);
|
||||||
|
let encrypted = encrypt_private_key(&private_key, "correct horse battery staple").unwrap();
|
||||||
|
assert!(decrypt_private_key(&encrypted, "this password is incorrect").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,347 @@
|
||||||
|
#[cfg(all(feature = "mainnet", feature = "testnet"))]
|
||||||
|
compile_error!("contractless-browser-core must be built for exactly one network");
|
||||||
|
|
||||||
|
#[cfg(not(any(feature = "mainnet", feature = "testnet")))]
|
||||||
|
compile_error!("contractless-browser-core requires either the mainnet or testnet feature");
|
||||||
|
|
||||||
|
mod address;
|
||||||
|
mod crypto;
|
||||||
|
mod error;
|
||||||
|
mod legacy_wallet;
|
||||||
|
mod message;
|
||||||
|
mod registry;
|
||||||
|
mod transfer;
|
||||||
|
mod wallet;
|
||||||
|
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
|
use error::BrowserCoreError;
|
||||||
|
|
||||||
|
pub const PUBLIC_KEY_LENGTH: usize = 897;
|
||||||
|
pub const PRIVATE_KEY_LENGTH: usize = 1281;
|
||||||
|
pub const SIGNATURE_LENGTH: usize = 666;
|
||||||
|
pub const REQUIRED_PUBLIC_KEY_HASH_BYTE: u8 = 239;
|
||||||
|
|
||||||
|
fn decode_hex(value: &str) -> Result<Vec<u8>, BrowserCoreError> {
|
||||||
|
hex::decode(value).map_err(|error| BrowserCoreError::InvalidHex(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn skein_128_hex(data: &[u8]) -> String {
|
||||||
|
hex::encode(crypto::skein_128(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn skein_256_hex(data: &[u8]) -> String {
|
||||||
|
hex::encode(crypto::skein_256(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn skein_512_hex(data: &[u8]) -> String {
|
||||||
|
hex::encode(crypto::skein_512(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn verify_bytes_hex(
|
||||||
|
message: &[u8],
|
||||||
|
signature_hex: &str,
|
||||||
|
public_key_hex: &str,
|
||||||
|
) -> Result<bool, JsValue> {
|
||||||
|
let signature = decode_hex(signature_hex)?;
|
||||||
|
let public_key = decode_hex(public_key_hex)?;
|
||||||
|
Ok(crypto::verify(message, &signature, &public_key))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn verify_hash_hex(
|
||||||
|
hash_hex: &str,
|
||||||
|
signature_hex: &str,
|
||||||
|
public_key_hex: &str,
|
||||||
|
) -> Result<bool, JsValue> {
|
||||||
|
let hash = decode_hex(hash_hex)?;
|
||||||
|
verify_bytes_hex(&hash, signature_hex, public_key_hex)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn public_key_to_short_address(public_key_hex: &str) -> Result<String, JsValue> {
|
||||||
|
let public_key = decode_hex(public_key_hex)?;
|
||||||
|
Ok(address::public_key_to_short_address(&public_key)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn short_address_to_bytes(address: &str) -> Result<Vec<u8>, JsValue> {
|
||||||
|
Ok(address::short_address_to_bytes(address)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn bytes_to_short_address(bytes: &[u8]) -> Result<String, JsValue> {
|
||||||
|
Ok(address::bytes_to_short_address(bytes)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn decode_transfer(transaction_hex: &str) -> Result<String, JsValue> {
|
||||||
|
let bytes = decode_hex(transaction_hex)?;
|
||||||
|
let transfer = transfer::SignedTransfer::from_bytes(&bytes)?;
|
||||||
|
serde_json::to_string(&transfer.browser_output()?)
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()).into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn decode_transaction(transaction_hex: &str) -> Result<String, JsValue> {
|
||||||
|
let bytes = decode_hex(transaction_hex)?;
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return Err(BrowserCoreError::InvalidRequest("transaction bytes were empty".into()).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded = match bytes[0] {
|
||||||
|
0 => {
|
||||||
|
if bytes.len() != 49 {
|
||||||
|
return Err(BrowserCoreError::InvalidRequest(
|
||||||
|
"genesis transaction must contain exactly 49 bytes".into(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let message = std::str::from_utf8(&bytes[1..])
|
||||||
|
.map_err(|_| BrowserCoreError::InvalidRequest("invalid genesis message".into()))?
|
||||||
|
.trim_end_matches(|character| character == '\0' || character == ' ');
|
||||||
|
serde_json::json!({
|
||||||
|
"txtype": 0,
|
||||||
|
"transaction_name": "Genesis",
|
||||||
|
"fields": {
|
||||||
|
"txtype": 0,
|
||||||
|
"message": message
|
||||||
|
},
|
||||||
|
"stored_hash": null,
|
||||||
|
"signature": null,
|
||||||
|
"signature1": null,
|
||||||
|
"signature2": null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
if bytes.len() != 13 {
|
||||||
|
return Err(BrowserCoreError::InvalidRequest(
|
||||||
|
"reward transaction must contain exactly 13 bytes".into(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let timestamp = u32::from_le_bytes(bytes[1..5].try_into().unwrap());
|
||||||
|
let value = u64::from_le_bytes(bytes[5..13].try_into().unwrap());
|
||||||
|
serde_json::json!({
|
||||||
|
"txtype": 1,
|
||||||
|
"transaction_name": "Mining Reward",
|
||||||
|
"fields": {
|
||||||
|
"txtype": 1,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"value": value.to_string()
|
||||||
|
},
|
||||||
|
"stored_hash": null,
|
||||||
|
"signature": null,
|
||||||
|
"signature1": null,
|
||||||
|
"signature2": null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => serde_json::to_value(registry::decode_transaction(&bytes)?)
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()))?,
|
||||||
|
};
|
||||||
|
|
||||||
|
serde_json::to_string(&decoded)
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()).into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn inspect_transaction_request(request_json: &str) -> Result<String, JsValue> {
|
||||||
|
let request: serde_json::Value = serde_json::from_str(request_json)
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()))?;
|
||||||
|
serde_json::to_string(®istry::inspect(&request)?)
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()).into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn sign_transaction_request(
|
||||||
|
request_json: &str,
|
||||||
|
private_key_hex: &str,
|
||||||
|
public_key_hex: &str,
|
||||||
|
signer_slot: u8,
|
||||||
|
) -> Result<String, JsValue> {
|
||||||
|
let request: serde_json::Value = serde_json::from_str(request_json)
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()))?;
|
||||||
|
let private_key = decode_hex(private_key_hex)?;
|
||||||
|
let public_key = decode_hex(public_key_hex)?;
|
||||||
|
serde_json::to_string(®istry::sign_request(
|
||||||
|
&request,
|
||||||
|
&private_key,
|
||||||
|
&public_key,
|
||||||
|
signer_slot,
|
||||||
|
)?)
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()).into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn complete_dual_transaction_request(
|
||||||
|
request_json: &str,
|
||||||
|
signature1_hex: &str,
|
||||||
|
public_key1_hex: &str,
|
||||||
|
signature2_hex: &str,
|
||||||
|
public_key2_hex: &str,
|
||||||
|
) -> Result<String, JsValue> {
|
||||||
|
let request: serde_json::Value = serde_json::from_str(request_json)
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()))?;
|
||||||
|
let public_key1 = decode_hex(public_key1_hex)?;
|
||||||
|
let public_key2 = decode_hex(public_key2_hex)?;
|
||||||
|
serde_json::to_string(®istry::complete_dual_request(
|
||||||
|
&request,
|
||||||
|
signature1_hex,
|
||||||
|
&public_key1,
|
||||||
|
signature2_hex,
|
||||||
|
&public_key2,
|
||||||
|
)?)
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()).into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn transaction_type_registry() -> Result<String, JsValue> {
|
||||||
|
serde_json::to_string(®istry::entries())
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()).into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn generate_browser_wallet(password: &str) -> Result<String, JsValue> {
|
||||||
|
serde_json::to_string_pretty(&wallet::generate(password)?)
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()).into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn unlock_browser_wallet(wallet_json: &str, password: &str) -> Result<String, JsValue> {
|
||||||
|
let wallet: wallet::BrowserWallet = serde_json::from_str(wallet_json)
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()))?;
|
||||||
|
serde_json::to_string(&wallet::unlock(&wallet, password)?)
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()).into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn import_legacy_wallet(
|
||||||
|
legacy_wallet_json: &str,
|
||||||
|
legacy_password: &str,
|
||||||
|
browser_password: &str,
|
||||||
|
) -> Result<String, JsValue> {
|
||||||
|
let legacy: wallet::LegacySavedWallet = serde_json::from_str(legacy_wallet_json)
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()))?;
|
||||||
|
serde_json::to_string_pretty(&wallet::import_legacy(
|
||||||
|
&legacy,
|
||||||
|
legacy_password,
|
||||||
|
browser_password,
|
||||||
|
)?)
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()).into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn import_private_key_image(
|
||||||
|
image_base64: &str,
|
||||||
|
image_password: &str,
|
||||||
|
browser_password: &str,
|
||||||
|
) -> Result<String, JsValue> {
|
||||||
|
serde_json::to_string_pretty(&wallet::import_private_key_image(
|
||||||
|
image_base64,
|
||||||
|
image_password,
|
||||||
|
browser_password,
|
||||||
|
)?)
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()).into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn import_private_key(
|
||||||
|
private_key_hex: &str,
|
||||||
|
browser_password: &str,
|
||||||
|
) -> Result<String, JsValue> {
|
||||||
|
serde_json::to_string_pretty(&wallet::import_private_key(
|
||||||
|
private_key_hex,
|
||||||
|
browser_password,
|
||||||
|
)?)
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()).into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn export_legacy_wallet(
|
||||||
|
browser_wallet_json: &str,
|
||||||
|
browser_password: &str,
|
||||||
|
legacy_password: &str,
|
||||||
|
) -> Result<String, JsValue> {
|
||||||
|
let wallet: wallet::BrowserWallet = serde_json::from_str(browser_wallet_json)
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()))?;
|
||||||
|
serde_json::to_string_pretty(&wallet::export_legacy(
|
||||||
|
&wallet,
|
||||||
|
browser_password,
|
||||||
|
legacy_password,
|
||||||
|
)?)
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()).into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn create_wallet_registration(
|
||||||
|
browser_wallet_json: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<String, JsValue> {
|
||||||
|
let wallet: wallet::BrowserWallet = serde_json::from_str(browser_wallet_json)
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()))?;
|
||||||
|
serde_json::to_string(&wallet::registration(&wallet, password)?)
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()).into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn create_wallet_registration_from_keys(
|
||||||
|
short_address: &str,
|
||||||
|
public_key_hex: &str,
|
||||||
|
private_key_hex: &str,
|
||||||
|
) -> Result<String, JsValue> {
|
||||||
|
serde_json::to_string(&wallet::registration_from_keys(
|
||||||
|
short_address,
|
||||||
|
public_key_hex,
|
||||||
|
private_key_hex,
|
||||||
|
)?)
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()).into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn create_handshake_proof(
|
||||||
|
public_key_hex: &str,
|
||||||
|
private_key_hex: &str,
|
||||||
|
) -> Result<String, JsValue> {
|
||||||
|
Ok(wallet::handshake_proof(public_key_hex, private_key_hex)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn sign_message_request(
|
||||||
|
request_json: &str,
|
||||||
|
private_key_hex: &str,
|
||||||
|
public_key_hex: &str,
|
||||||
|
) -> Result<String, JsValue> {
|
||||||
|
let request: message::MessageRequest = serde_json::from_str(request_json)
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()))?;
|
||||||
|
let private_key = decode_hex(private_key_hex)?;
|
||||||
|
let public_key = decode_hex(public_key_hex)?;
|
||||||
|
serde_json::to_string(&message::sign(request, &private_key, &public_key)?)
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()).into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn verify_message_proof(
|
||||||
|
proof_json: &str,
|
||||||
|
expected_origin: &str,
|
||||||
|
expected_nonce: &str,
|
||||||
|
current_time: &str,
|
||||||
|
) -> Result<String, JsValue> {
|
||||||
|
let proof: message::MessageProof = serde_json::from_str(proof_json)
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()))?;
|
||||||
|
let current_time = current_time.parse::<u64>().map_err(|_| {
|
||||||
|
BrowserCoreError::InvalidRequest(
|
||||||
|
"current_time must be an unsigned decimal timestamp".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
serde_json::to_string(&message::verify(
|
||||||
|
proof,
|
||||||
|
expected_origin,
|
||||||
|
expected_nonce,
|
||||||
|
current_time,
|
||||||
|
)?)
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()).into())
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,263 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::address;
|
||||||
|
use crate::crypto;
|
||||||
|
use crate::error::BrowserCoreError;
|
||||||
|
use crate::{PRIVATE_KEY_LENGTH, PUBLIC_KEY_LENGTH, SIGNATURE_LENGTH};
|
||||||
|
|
||||||
|
const MESSAGE_DOMAIN: &[u8] = b"CONTRACTLESS_WEB_MESSAGE_V1\0";
|
||||||
|
const MAX_MESSAGE_BYTES: usize = 4096;
|
||||||
|
const MAX_ORIGIN_BYTES: usize = 255;
|
||||||
|
const MIN_NONCE_BYTES: usize = 16;
|
||||||
|
const MAX_PROOF_LIFETIME_SECONDS: u64 = 15 * 60;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct MessageRequest {
|
||||||
|
pub origin: String,
|
||||||
|
pub message: String,
|
||||||
|
pub nonce: String,
|
||||||
|
pub issued_at: u64,
|
||||||
|
pub expires_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct MessagePayload {
|
||||||
|
pub version: u8,
|
||||||
|
pub origin: String,
|
||||||
|
pub address: String,
|
||||||
|
pub message: String,
|
||||||
|
pub nonce: String,
|
||||||
|
pub issued_at: u64,
|
||||||
|
pub expires_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct MessageProof {
|
||||||
|
pub payload: MessagePayload,
|
||||||
|
pub public_key: String,
|
||||||
|
pub hash: String,
|
||||||
|
pub signature: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct MessageVerification {
|
||||||
|
pub valid: bool,
|
||||||
|
pub address: String,
|
||||||
|
pub origin: String,
|
||||||
|
pub message: String,
|
||||||
|
pub nonce: String,
|
||||||
|
pub issued_at: u64,
|
||||||
|
pub expires_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sign(
|
||||||
|
request: MessageRequest,
|
||||||
|
private_key: &[u8],
|
||||||
|
public_key: &[u8],
|
||||||
|
) -> Result<MessageProof, BrowserCoreError> {
|
||||||
|
if private_key.len() != PRIVATE_KEY_LENGTH {
|
||||||
|
return Err(BrowserCoreError::InvalidPrivateKey);
|
||||||
|
}
|
||||||
|
if public_key.len() != PUBLIC_KEY_LENGTH {
|
||||||
|
return Err(BrowserCoreError::InvalidPublicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_request(&request)?;
|
||||||
|
let payload = MessagePayload {
|
||||||
|
version: 1,
|
||||||
|
origin: request.origin,
|
||||||
|
address: address::public_key_to_short_address(public_key)?,
|
||||||
|
message: request.message,
|
||||||
|
nonce: request.nonce.to_ascii_lowercase(),
|
||||||
|
issued_at: request.issued_at,
|
||||||
|
expires_at: request.expires_at,
|
||||||
|
};
|
||||||
|
let hash = payload_hash(&payload)?;
|
||||||
|
let signature = crypto::sign(&hash, private_key)?;
|
||||||
|
if !crypto::verify(&hash, &signature, public_key) {
|
||||||
|
return Err(BrowserCoreError::InvalidRequest(
|
||||||
|
"private key does not match the wallet public key".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(MessageProof {
|
||||||
|
payload,
|
||||||
|
public_key: hex::encode(public_key),
|
||||||
|
hash: hex::encode(hash),
|
||||||
|
signature: hex::encode(signature),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify(
|
||||||
|
proof: MessageProof,
|
||||||
|
expected_origin: &str,
|
||||||
|
expected_nonce: &str,
|
||||||
|
current_time: u64,
|
||||||
|
) -> Result<MessageVerification, BrowserCoreError> {
|
||||||
|
if proof.payload.version != 1 {
|
||||||
|
return Err(BrowserCoreError::InvalidRequest(
|
||||||
|
"unsupported message proof version".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
validate_request(&MessageRequest {
|
||||||
|
origin: proof.payload.origin.clone(),
|
||||||
|
message: proof.payload.message.clone(),
|
||||||
|
nonce: proof.payload.nonce.clone(),
|
||||||
|
issued_at: proof.payload.issued_at,
|
||||||
|
expires_at: proof.payload.expires_at,
|
||||||
|
})?;
|
||||||
|
let public_key =
|
||||||
|
hex::decode(&proof.public_key).map_err(|_| BrowserCoreError::InvalidPublicKey)?;
|
||||||
|
let derived_address = address::public_key_to_short_address(&public_key)?;
|
||||||
|
if derived_address != proof.payload.address {
|
||||||
|
return Err(BrowserCoreError::InvalidRequest(
|
||||||
|
"message address does not match its public key".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if proof.payload.origin != expected_origin {
|
||||||
|
return Err(BrowserCoreError::InvalidRequest(
|
||||||
|
"message proof was issued for a different origin".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !proof.payload.nonce.eq_ignore_ascii_case(expected_nonce) {
|
||||||
|
return Err(BrowserCoreError::InvalidRequest(
|
||||||
|
"message proof nonce does not match".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if current_time < proof.payload.issued_at || current_time > proof.payload.expires_at {
|
||||||
|
return Err(BrowserCoreError::InvalidRequest(
|
||||||
|
"message proof is not currently valid".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash = payload_hash(&proof.payload)?;
|
||||||
|
if proof.hash != hex::encode(&hash) {
|
||||||
|
return Err(BrowserCoreError::InvalidRequest(
|
||||||
|
"message proof hash does not match its payload".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let signature =
|
||||||
|
hex::decode(&proof.signature).map_err(|_| BrowserCoreError::InvalidSignature)?;
|
||||||
|
if signature.len() != SIGNATURE_LENGTH || !crypto::verify(&hash, &signature, &public_key) {
|
||||||
|
return Err(BrowserCoreError::InvalidSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(MessageVerification {
|
||||||
|
valid: true,
|
||||||
|
address: proof.payload.address,
|
||||||
|
origin: proof.payload.origin,
|
||||||
|
message: proof.payload.message,
|
||||||
|
nonce: proof.payload.nonce,
|
||||||
|
issued_at: proof.payload.issued_at,
|
||||||
|
expires_at: proof.payload.expires_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_request(request: &MessageRequest) -> Result<(), BrowserCoreError> {
|
||||||
|
let valid_origin = request
|
||||||
|
.origin
|
||||||
|
.strip_prefix("https://")
|
||||||
|
.is_some_and(valid_https_authority)
|
||||||
|
|| request
|
||||||
|
.origin
|
||||||
|
.strip_prefix("http://localhost")
|
||||||
|
.is_some_and(valid_local_port)
|
||||||
|
|| request
|
||||||
|
.origin
|
||||||
|
.strip_prefix("http://127.0.0.1")
|
||||||
|
.is_some_and(valid_local_port);
|
||||||
|
if request.origin.is_empty()
|
||||||
|
|| request.origin.len() > MAX_ORIGIN_BYTES
|
||||||
|
|| !request.origin.is_ascii()
|
||||||
|
|| request.origin.chars().any(char::is_whitespace)
|
||||||
|
|| !valid_origin
|
||||||
|
{
|
||||||
|
return Err(BrowserCoreError::InvalidRequest(
|
||||||
|
"origin must be HTTPS or a local development origin".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if request.message.is_empty() || request.message.as_bytes().len() > MAX_MESSAGE_BYTES {
|
||||||
|
return Err(BrowserCoreError::InvalidRequest(format!(
|
||||||
|
"message must contain 1 to {MAX_MESSAGE_BYTES} bytes"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let nonce = hex::decode(&request.nonce)
|
||||||
|
.map_err(|_| BrowserCoreError::InvalidRequest("nonce must be hexadecimal".into()))?;
|
||||||
|
if nonce.len() < MIN_NONCE_BYTES {
|
||||||
|
return Err(BrowserCoreError::InvalidRequest(format!(
|
||||||
|
"nonce must contain at least {MIN_NONCE_BYTES} random bytes"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if request.expires_at <= request.issued_at {
|
||||||
|
return Err(BrowserCoreError::InvalidRequest(
|
||||||
|
"expires_at must be after issued_at".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if request.expires_at - request.issued_at > MAX_PROOF_LIFETIME_SECONDS {
|
||||||
|
return Err(BrowserCoreError::InvalidRequest(format!(
|
||||||
|
"message proofs may remain valid for at most {MAX_PROOF_LIFETIME_SECONDS} seconds"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_https_authority(authority: &str) -> bool {
|
||||||
|
!authority.is_empty()
|
||||||
|
&& !authority.contains('/')
|
||||||
|
&& !authority.contains('?')
|
||||||
|
&& !authority.contains('#')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_local_port(suffix: &str) -> bool {
|
||||||
|
suffix.is_empty()
|
||||||
|
|| suffix.strip_prefix(':').is_some_and(|port| {
|
||||||
|
!port.is_empty() && port.chars().all(|character| character.is_ascii_digit())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payload_hash(payload: &MessagePayload) -> Result<Vec<u8>, BrowserCoreError> {
|
||||||
|
let json = serde_json::to_string(payload)
|
||||||
|
.map_err(|error| BrowserCoreError::Json(error.to_string()))?;
|
||||||
|
let mut domain_payload = Vec::with_capacity(MESSAGE_DOMAIN.len() + json.len());
|
||||||
|
domain_payload.extend_from_slice(MESSAGE_DOMAIN);
|
||||||
|
domain_payload.extend_from_slice(json.as_bytes());
|
||||||
|
Ok(crypto::skein_256(&domain_payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_domain_differs_from_transaction_hashing() {
|
||||||
|
let payload = MessagePayload {
|
||||||
|
version: 1,
|
||||||
|
origin: "https://example.com".into(),
|
||||||
|
address: "11".repeat(20) + ".cltc",
|
||||||
|
message: "Welcome to XYZ".into(),
|
||||||
|
nonce: "22".repeat(16),
|
||||||
|
issued_at: 100,
|
||||||
|
expires_at: 200,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&payload).unwrap();
|
||||||
|
assert_ne!(
|
||||||
|
payload_hash(&payload).unwrap(),
|
||||||
|
crypto::skein_256(json.as_bytes())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_requests_reject_long_lived_replay_windows() {
|
||||||
|
let request = MessageRequest {
|
||||||
|
origin: "https://example.com".into(),
|
||||||
|
message: "Welcome to XYZ".into(),
|
||||||
|
nonce: "22".repeat(16),
|
||||||
|
issued_at: 100,
|
||||||
|
expires_at: 100 + MAX_PROOF_LIFETIME_SECONDS + 1,
|
||||||
|
};
|
||||||
|
assert!(validate_request(&request).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::address;
|
||||||
|
use crate::crypto;
|
||||||
|
use crate::error::BrowserCoreError;
|
||||||
|
use crate::SIGNATURE_LENGTH;
|
||||||
|
|
||||||
|
pub const TRANSFER_TYPE: u8 = 2;
|
||||||
|
pub const COIN_BYTES: usize = 15;
|
||||||
|
pub const TRANSFER_BYTES: usize = 750;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct UnsignedTransfer {
|
||||||
|
pub txtype: u8,
|
||||||
|
pub time: u32,
|
||||||
|
pub value: u64,
|
||||||
|
pub coin: String,
|
||||||
|
pub nft_series: u32,
|
||||||
|
pub sender: String,
|
||||||
|
pub receiver: String,
|
||||||
|
pub txfee: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct SignedTransfer {
|
||||||
|
pub unsigned_transfer: UnsignedTransfer,
|
||||||
|
pub signature: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct BrowserTransferOutput {
|
||||||
|
pub txtype: u8,
|
||||||
|
pub time: u32,
|
||||||
|
pub value: String,
|
||||||
|
pub coin: String,
|
||||||
|
pub nft_series: u32,
|
||||||
|
pub sender: String,
|
||||||
|
pub receiver: String,
|
||||||
|
pub txfee: String,
|
||||||
|
pub hash: String,
|
||||||
|
pub signature: String,
|
||||||
|
pub bytes_hex: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UnsignedTransfer {
|
||||||
|
pub fn validate(&self) -> Result<(), BrowserCoreError> {
|
||||||
|
if self.txtype != TRANSFER_TYPE {
|
||||||
|
return Err(BrowserCoreError::InvalidTransfer(
|
||||||
|
"transaction type must be 2".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.coin.len() != COIN_BYTES || !self.coin.is_ascii() {
|
||||||
|
return Err(BrowserCoreError::InvalidTransfer(
|
||||||
|
"coin must use the canonical 15-byte padded format".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
address::short_address_to_bytes(&self.sender)?;
|
||||||
|
address::short_address_to_bytes(&self.receiver)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn json(&self) -> Result<String, BrowserCoreError> {
|
||||||
|
serde_json::to_string(self).map_err(|error| BrowserCoreError::Json(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hash(&self) -> Result<Vec<u8>, BrowserCoreError> {
|
||||||
|
Ok(crypto::skein_256(self.json()?.as_bytes()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignedTransfer {
|
||||||
|
pub fn validate_shape(&self) -> Result<(), BrowserCoreError> {
|
||||||
|
self.unsigned_transfer.validate()?;
|
||||||
|
let signature =
|
||||||
|
hex::decode(&self.signature).map_err(|_| BrowserCoreError::InvalidSignature)?;
|
||||||
|
if signature.len() != SIGNATURE_LENGTH {
|
||||||
|
return Err(BrowserCoreError::InvalidSignature);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_bytes(&self) -> Result<Vec<u8>, BrowserCoreError> {
|
||||||
|
self.validate_shape()?;
|
||||||
|
let mut bytes = Vec::with_capacity(TRANSFER_BYTES);
|
||||||
|
bytes.push(self.unsigned_transfer.txtype);
|
||||||
|
bytes.extend_from_slice(&self.unsigned_transfer.time.to_le_bytes());
|
||||||
|
bytes.extend_from_slice(&self.unsigned_transfer.value.to_le_bytes());
|
||||||
|
bytes.extend_from_slice(self.unsigned_transfer.coin.as_bytes());
|
||||||
|
bytes.extend_from_slice(&self.unsigned_transfer.nft_series.to_le_bytes());
|
||||||
|
bytes.extend_from_slice(&address::short_address_to_bytes(
|
||||||
|
&self.unsigned_transfer.sender,
|
||||||
|
)?);
|
||||||
|
bytes.extend_from_slice(&address::short_address_to_bytes(
|
||||||
|
&self.unsigned_transfer.receiver,
|
||||||
|
)?);
|
||||||
|
bytes.extend_from_slice(&self.unsigned_transfer.txfee.to_le_bytes());
|
||||||
|
bytes.extend_from_slice(
|
||||||
|
&hex::decode(&self.signature).map_err(|_| BrowserCoreError::InvalidSignature)?,
|
||||||
|
);
|
||||||
|
debug_assert_eq!(bytes.len(), TRANSFER_BYTES);
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_bytes(bytes: &[u8]) -> Result<Self, BrowserCoreError> {
|
||||||
|
if bytes.len() != TRANSFER_BYTES || bytes[0] != TRANSFER_TYPE {
|
||||||
|
return Err(BrowserCoreError::InvalidTransfer(format!(
|
||||||
|
"expected a {TRANSFER_BYTES}-byte type-2 transaction"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut offset = 1;
|
||||||
|
let time = read_u32(bytes, &mut offset);
|
||||||
|
let value = read_u64(bytes, &mut offset);
|
||||||
|
let coin = String::from_utf8(bytes[offset..offset + COIN_BYTES].to_vec())
|
||||||
|
.map_err(|_| BrowserCoreError::InvalidTransfer("coin is not UTF-8".into()))?;
|
||||||
|
offset += COIN_BYTES;
|
||||||
|
let nft_series = read_u32(bytes, &mut offset);
|
||||||
|
let sender = address::bytes_to_short_address(&bytes[offset..offset + 22])?;
|
||||||
|
offset += 22;
|
||||||
|
let receiver = address::bytes_to_short_address(&bytes[offset..offset + 22])?;
|
||||||
|
offset += 22;
|
||||||
|
let txfee = read_u64(bytes, &mut offset);
|
||||||
|
let signature = hex::encode(&bytes[offset..offset + SIGNATURE_LENGTH]);
|
||||||
|
|
||||||
|
let transfer = Self {
|
||||||
|
unsigned_transfer: UnsignedTransfer {
|
||||||
|
txtype: TRANSFER_TYPE,
|
||||||
|
time,
|
||||||
|
value,
|
||||||
|
coin,
|
||||||
|
nft_series,
|
||||||
|
sender,
|
||||||
|
receiver,
|
||||||
|
txfee,
|
||||||
|
},
|
||||||
|
signature,
|
||||||
|
};
|
||||||
|
transfer.validate_shape()?;
|
||||||
|
Ok(transfer)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn browser_output(&self) -> Result<BrowserTransferOutput, BrowserCoreError> {
|
||||||
|
let bytes_hex = hex::encode(self.to_bytes()?);
|
||||||
|
let hash = hex::encode(self.unsigned_transfer.hash()?);
|
||||||
|
Ok(BrowserTransferOutput {
|
||||||
|
txtype: self.unsigned_transfer.txtype,
|
||||||
|
time: self.unsigned_transfer.time,
|
||||||
|
value: self.unsigned_transfer.value.to_string(),
|
||||||
|
coin: self.unsigned_transfer.coin.clone(),
|
||||||
|
nft_series: self.unsigned_transfer.nft_series,
|
||||||
|
sender: self.unsigned_transfer.sender.clone(),
|
||||||
|
receiver: self.unsigned_transfer.receiver.clone(),
|
||||||
|
txfee: self.unsigned_transfer.txfee.to_string(),
|
||||||
|
hash,
|
||||||
|
signature: self.signature.clone(),
|
||||||
|
bytes_hex,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u32(bytes: &[u8], offset: &mut usize) -> u32 {
|
||||||
|
let value = u32::from_le_bytes(bytes[*offset..*offset + 4].try_into().unwrap());
|
||||||
|
*offset += 4;
|
||||||
|
value
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u64(bytes: &[u8], offset: &mut usize) -> u64 {
|
||||||
|
let value = u64::from_le_bytes(bytes[*offset..*offset + 8].try_into().unwrap());
|
||||||
|
*offset += 8;
|
||||||
|
value
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn address(byte: u8) -> String {
|
||||||
|
format!("{}.{}", hex::encode([byte; 20]), crate::address::network_suffix())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unsigned() -> UnsignedTransfer {
|
||||||
|
UnsignedTransfer {
|
||||||
|
txtype: TRANSFER_TYPE,
|
||||||
|
time: 1_700_000_000,
|
||||||
|
value: 123_456_789,
|
||||||
|
coin: "CLTC ".to_string(),
|
||||||
|
nft_series: 0,
|
||||||
|
sender: address(0x11),
|
||||||
|
receiver: address(0x22),
|
||||||
|
txfee: 1_234_568,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsigned_json_matches_node_field_order() {
|
||||||
|
let serialized = unsigned().json().unwrap();
|
||||||
|
assert!(serialized.contains(&format!("1111111111111111111111111111111111111111.{}", crate::address::network_suffix())));
|
||||||
|
assert!(serialized.contains(&format!("2222222222222222222222222222222222222222.{}", crate::address::network_suffix())));
|
||||||
|
#[cfg(feature = "testnet")]
|
||||||
|
assert_eq!(
|
||||||
|
hex::encode(unsigned().hash().unwrap()),
|
||||||
|
"7e6a1ef0232eee327dd6c18640f85b16e632762a7c424abfc720ee72fb002c32"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transfer_wire_layout_round_trips() {
|
||||||
|
let transfer = SignedTransfer {
|
||||||
|
unsigned_transfer: unsigned(),
|
||||||
|
signature: hex::encode([0x33; SIGNATURE_LENGTH]),
|
||||||
|
};
|
||||||
|
let bytes = transfer.to_bytes().unwrap();
|
||||||
|
assert_eq!(bytes.len(), TRANSFER_BYTES);
|
||||||
|
assert_eq!(SignedTransfer::from_bytes(&bytes).unwrap(), transfer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,338 @@
|
||||||
|
use aes_gcm::aead::{Aead, KeyInit};
|
||||||
|
use aes_gcm::{Aes256Gcm, Nonce};
|
||||||
|
use falcon::FnDsaKeyPair;
|
||||||
|
use pbkdf2::pbkdf2_hmac;
|
||||||
|
use rand::{rngs::OsRng, RngCore};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::Sha256;
|
||||||
|
use zeroize::Zeroize;
|
||||||
|
|
||||||
|
use crate::address;
|
||||||
|
use crate::crypto;
|
||||||
|
use crate::error::BrowserCoreError;
|
||||||
|
use crate::legacy_wallet;
|
||||||
|
use crate::{PRIVATE_KEY_LENGTH, PUBLIC_KEY_LENGTH};
|
||||||
|
|
||||||
|
const KDF_ITERATIONS: u32 = 310_000;
|
||||||
|
const REGISTRATION_COMMAND: u8 = 38;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct BrowserWallet {
|
||||||
|
pub version: u8,
|
||||||
|
pub network: String,
|
||||||
|
pub short_address: String,
|
||||||
|
pub vanity_address: Option<String>,
|
||||||
|
pub public_key: String,
|
||||||
|
pub salt: String,
|
||||||
|
pub iterations: u32,
|
||||||
|
pub nonce: String,
|
||||||
|
pub encrypted_private_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct LegacySavedWallet {
|
||||||
|
pub short_address: String,
|
||||||
|
pub vanity_address: Option<String>,
|
||||||
|
pub public_key: String,
|
||||||
|
pub private_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct UnlockedWallet {
|
||||||
|
pub short_address: String,
|
||||||
|
pub vanity_address: Option<String>,
|
||||||
|
pub public_key: String,
|
||||||
|
pub private_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RegistrationPayload {
|
||||||
|
pub command: u8,
|
||||||
|
pub short_address: String,
|
||||||
|
pub public_key: String,
|
||||||
|
pub hash: String,
|
||||||
|
pub signature: String,
|
||||||
|
pub payload_hex: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate(password: &str) -> Result<BrowserWallet, BrowserCoreError> {
|
||||||
|
let (public_key, mut private_key) = generate_keypair()?;
|
||||||
|
let wallet = encrypt_wallet(&public_key, &private_key, None, password);
|
||||||
|
private_key.zeroize();
|
||||||
|
wallet
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unlock(wallet: &BrowserWallet, password: &str) -> Result<UnlockedWallet, BrowserCoreError> {
|
||||||
|
if wallet.version != 1 || wallet.iterations < 100_000 {
|
||||||
|
return Err(BrowserCoreError::InvalidRequest(
|
||||||
|
"unsupported browser wallet format".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let salt = decode_length(&wallet.salt, 32, "wallet salt")?;
|
||||||
|
let nonce = decode_length(&wallet.nonce, 12, "wallet nonce")?;
|
||||||
|
let ciphertext = hex::decode(&wallet.encrypted_private_key)
|
||||||
|
.map_err(|_| BrowserCoreError::InvalidPrivateKey)?;
|
||||||
|
let key = derive_key(password, &salt, wallet.iterations);
|
||||||
|
let private_key = Aes256Gcm::new_from_slice(&key)
|
||||||
|
.unwrap()
|
||||||
|
.decrypt(Nonce::from_slice(&nonce), ciphertext.as_ref())
|
||||||
|
.map_err(|_| {
|
||||||
|
BrowserCoreError::InvalidRequest("wallet password or integrity check failed".into())
|
||||||
|
})?;
|
||||||
|
validate_key_material(&private_key, &wallet.public_key, &wallet.short_address)?;
|
||||||
|
Ok(UnlockedWallet {
|
||||||
|
short_address: wallet.short_address.clone(),
|
||||||
|
vanity_address: wallet.vanity_address.clone(),
|
||||||
|
public_key: wallet.public_key.clone(),
|
||||||
|
private_key: hex::encode(private_key),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn import_legacy(
|
||||||
|
legacy: &LegacySavedWallet,
|
||||||
|
legacy_password: &str,
|
||||||
|
browser_password: &str,
|
||||||
|
) -> Result<BrowserWallet, BrowserCoreError> {
|
||||||
|
let private_key_hex = legacy_wallet::decrypt_private_key(&legacy.private_key, legacy_password)?;
|
||||||
|
let mut private_key =
|
||||||
|
hex::decode(&private_key_hex).map_err(|_| BrowserCoreError::InvalidPrivateKey)?;
|
||||||
|
validate_key_material(&private_key, &legacy.public_key, &legacy.short_address)?;
|
||||||
|
let public_key =
|
||||||
|
hex::decode(&legacy.public_key).map_err(|_| BrowserCoreError::InvalidPublicKey)?;
|
||||||
|
let wallet = encrypt_wallet(
|
||||||
|
&public_key,
|
||||||
|
&private_key,
|
||||||
|
legacy.vanity_address.clone(),
|
||||||
|
browser_password,
|
||||||
|
);
|
||||||
|
private_key.zeroize();
|
||||||
|
wallet
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn import_private_key_image(
|
||||||
|
image_base64: &str,
|
||||||
|
image_password: &str,
|
||||||
|
browser_password: &str,
|
||||||
|
) -> Result<BrowserWallet, BrowserCoreError> {
|
||||||
|
let mut private_key_hex = legacy_wallet::decrypt_private_key(image_base64, image_password)?;
|
||||||
|
let result = import_private_key(&private_key_hex, browser_password);
|
||||||
|
private_key_hex.zeroize();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn import_private_key(
|
||||||
|
private_key_hex: &str,
|
||||||
|
browser_password: &str,
|
||||||
|
) -> Result<BrowserWallet, BrowserCoreError> {
|
||||||
|
let mut private_key =
|
||||||
|
hex::decode(private_key_hex).map_err(|_| BrowserCoreError::InvalidPrivateKey)?;
|
||||||
|
if private_key.len() != PRIVATE_KEY_LENGTH {
|
||||||
|
private_key.zeroize();
|
||||||
|
return Err(BrowserCoreError::InvalidPrivateKey);
|
||||||
|
}
|
||||||
|
let result = wallet_from_private_key(&private_key, browser_password);
|
||||||
|
private_key.zeroize();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn export_legacy(
|
||||||
|
wallet: &BrowserWallet,
|
||||||
|
browser_password: &str,
|
||||||
|
legacy_password: &str,
|
||||||
|
) -> Result<LegacySavedWallet, BrowserCoreError> {
|
||||||
|
let mut unlocked = unlock(wallet, browser_password)?;
|
||||||
|
let image = legacy_wallet::encrypt_private_key(&unlocked.private_key, legacy_password)?;
|
||||||
|
unlocked.private_key.zeroize();
|
||||||
|
Ok(LegacySavedWallet {
|
||||||
|
short_address: wallet.short_address.clone(),
|
||||||
|
vanity_address: wallet.vanity_address.clone(),
|
||||||
|
public_key: wallet.public_key.clone(),
|
||||||
|
private_key: image,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn registration(
|
||||||
|
wallet: &BrowserWallet,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<RegistrationPayload, BrowserCoreError> {
|
||||||
|
let mut unlocked = unlock(wallet, password)?;
|
||||||
|
let result = registration_from_keys(
|
||||||
|
&unlocked.short_address,
|
||||||
|
&unlocked.public_key,
|
||||||
|
&unlocked.private_key,
|
||||||
|
);
|
||||||
|
unlocked.private_key.zeroize();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn registration_from_keys(
|
||||||
|
short_address: &str,
|
||||||
|
public_key_hex: &str,
|
||||||
|
private_key_hex: &str,
|
||||||
|
) -> Result<RegistrationPayload, BrowserCoreError> {
|
||||||
|
let address_bytes = address::short_address_to_bytes(short_address)?;
|
||||||
|
let public_key = decode_length(public_key_hex, PUBLIC_KEY_LENGTH, "public key")?;
|
||||||
|
let mut signed_payload = Vec::with_capacity(1 + 22 + PUBLIC_KEY_LENGTH);
|
||||||
|
signed_payload.push(REGISTRATION_COMMAND);
|
||||||
|
signed_payload.extend_from_slice(&address_bytes);
|
||||||
|
signed_payload.extend_from_slice(&public_key);
|
||||||
|
let hash = crypto::skein_256(&signed_payload);
|
||||||
|
let private_key =
|
||||||
|
hex::decode(private_key_hex).map_err(|_| BrowserCoreError::InvalidPrivateKey)?;
|
||||||
|
validate_key_material(&private_key, public_key_hex, short_address)?;
|
||||||
|
let signature = crypto::sign(&hash, &private_key)?;
|
||||||
|
let mut payload = signed_payload.clone();
|
||||||
|
payload.extend_from_slice(&signature);
|
||||||
|
Ok(RegistrationPayload {
|
||||||
|
command: REGISTRATION_COMMAND,
|
||||||
|
short_address: short_address.to_owned(),
|
||||||
|
public_key: public_key_hex.to_owned(),
|
||||||
|
hash: hex::encode(hash),
|
||||||
|
signature: hex::encode(signature),
|
||||||
|
payload_hex: hex::encode(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handshake_proof(
|
||||||
|
public_key_hex: &str,
|
||||||
|
private_key_hex: &str,
|
||||||
|
) -> Result<String, BrowserCoreError> {
|
||||||
|
let public_key = decode_length(public_key_hex, PUBLIC_KEY_LENGTH, "public key")?;
|
||||||
|
let private_key =
|
||||||
|
hex::decode(private_key_hex).map_err(|_| BrowserCoreError::InvalidPrivateKey)?;
|
||||||
|
let keypair = FnDsaKeyPair::from_private_key(&private_key)
|
||||||
|
.map_err(|_| BrowserCoreError::InvalidPrivateKey)?;
|
||||||
|
if keypair.public_key() != public_key {
|
||||||
|
return Err(BrowserCoreError::InvalidRequest(
|
||||||
|
"wallet public key does not match its private key".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(hex::encode(crypto::sign(
|
||||||
|
&crypto::skein_256(b"aced"),
|
||||||
|
&private_key,
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_keypair() -> Result<(Vec<u8>, Vec<u8>), BrowserCoreError> {
|
||||||
|
loop {
|
||||||
|
let keypair = FnDsaKeyPair::generate(9).map_err(|_| BrowserCoreError::InvalidPrivateKey)?;
|
||||||
|
let public_key = keypair.public_key().to_vec();
|
||||||
|
if address::valid_public_key(&public_key) {
|
||||||
|
return Ok((public_key, keypair.private_key().to_vec()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wallet_from_private_key(
|
||||||
|
private_key: &[u8],
|
||||||
|
password: &str,
|
||||||
|
) -> Result<BrowserWallet, BrowserCoreError> {
|
||||||
|
let keypair = FnDsaKeyPair::from_private_key(private_key)
|
||||||
|
.map_err(|_| BrowserCoreError::InvalidPrivateKey)?;
|
||||||
|
let public_key = keypair.public_key();
|
||||||
|
address::public_key_to_short_address(public_key)?;
|
||||||
|
encrypt_wallet(public_key, private_key, None, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encrypt_wallet(
|
||||||
|
public_key: &[u8],
|
||||||
|
private_key: &[u8],
|
||||||
|
vanity_address: Option<String>,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<BrowserWallet, BrowserCoreError> {
|
||||||
|
let short_address = address::public_key_to_short_address(public_key)?;
|
||||||
|
let mut salt = [0u8; 32];
|
||||||
|
let mut nonce = [0u8; 12];
|
||||||
|
OsRng.fill_bytes(&mut salt);
|
||||||
|
OsRng.fill_bytes(&mut nonce);
|
||||||
|
let mut key = derive_key(password, &salt, KDF_ITERATIONS);
|
||||||
|
let ciphertext = Aes256Gcm::new_from_slice(&key)
|
||||||
|
.unwrap()
|
||||||
|
.encrypt(Nonce::from_slice(&nonce), private_key)
|
||||||
|
.map_err(|_| BrowserCoreError::InvalidRequest("wallet encryption failed".into()))?;
|
||||||
|
key.zeroize();
|
||||||
|
Ok(BrowserWallet {
|
||||||
|
version: 1,
|
||||||
|
network: address::network_suffix().into(),
|
||||||
|
short_address,
|
||||||
|
vanity_address,
|
||||||
|
public_key: hex::encode(public_key),
|
||||||
|
salt: hex::encode(salt),
|
||||||
|
iterations: KDF_ITERATIONS,
|
||||||
|
nonce: hex::encode(nonce),
|
||||||
|
encrypted_private_key: hex::encode(ciphertext),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_key(password: &str, salt: &[u8], iterations: u32) -> [u8; 32] {
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
pbkdf2_hmac::<Sha256>(password.as_bytes(), salt, iterations, &mut key);
|
||||||
|
key
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_key_material(
|
||||||
|
private_key: &[u8],
|
||||||
|
public_key_hex: &str,
|
||||||
|
short_address: &str,
|
||||||
|
) -> Result<(), BrowserCoreError> {
|
||||||
|
if private_key.len() != PRIVATE_KEY_LENGTH {
|
||||||
|
return Err(BrowserCoreError::InvalidPrivateKey);
|
||||||
|
}
|
||||||
|
let keypair = FnDsaKeyPair::from_private_key(private_key)
|
||||||
|
.map_err(|_| BrowserCoreError::InvalidPrivateKey)?;
|
||||||
|
let public_key = keypair.public_key();
|
||||||
|
if public_key.len() != PUBLIC_KEY_LENGTH || hex::encode(public_key) != public_key_hex {
|
||||||
|
return Err(BrowserCoreError::InvalidRequest(
|
||||||
|
"wallet public key does not match its private key".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if address::public_key_to_short_address(public_key)? != short_address {
|
||||||
|
return Err(BrowserCoreError::InvalidRequest(
|
||||||
|
"wallet address does not match its private key".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_length(value: &str, length: usize, name: &str) -> Result<Vec<u8>, BrowserCoreError> {
|
||||||
|
let bytes = hex::decode(value)
|
||||||
|
.map_err(|_| BrowserCoreError::InvalidRequest(format!("{name} is not hexadecimal")))?;
|
||||||
|
if bytes.len() != length {
|
||||||
|
return Err(BrowserCoreError::InvalidRequest(format!(
|
||||||
|
"{name} has an invalid length"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{export_legacy, generate, import_private_key, import_private_key_image, unlock};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn private_key_import_recreates_the_same_wallet() {
|
||||||
|
let original = generate("original password").unwrap();
|
||||||
|
let unlocked = unlock(&original, "original password").unwrap();
|
||||||
|
let imported = import_private_key(&unlocked.private_key, "new password").unwrap();
|
||||||
|
let imported_unlocked = unlock(&imported, "new password").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(imported.short_address, original.short_address);
|
||||||
|
assert_eq!(imported.public_key, original.public_key);
|
||||||
|
assert_eq!(imported_unlocked.private_key, unlocked.private_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn private_key_image_import_recreates_the_same_wallet() {
|
||||||
|
let original = generate("browser password").unwrap();
|
||||||
|
let legacy = export_legacy(&original, "browser password", "image password").unwrap();
|
||||||
|
let imported =
|
||||||
|
import_private_key_image(&legacy.private_key, "image password", "new password")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(imported.short_address, original.short_address);
|
||||||
|
assert_eq!(imported.public_key, original.public_key);
|
||||||
|
assert!(unlock(&imported, "new password").is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
# Contractless Browser Wallet
|
||||||
|
|
||||||
|
This is the WebExtension application for the Contractless Web3 wallet. It uses
|
||||||
|
the repository's `core` package for wallet generation, encryption,
|
||||||
|
transaction inspection, signing, message proofs, and wallet registration.
|
||||||
|
|
||||||
|
The private key is encrypted in extension-local storage. Decrypted key
|
||||||
|
material exists only in the background service worker while the wallet is
|
||||||
|
unlocked and is cleared when the wallet locks or the worker is suspended.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
Install:
|
||||||
|
|
||||||
|
- Rust and Cargo
|
||||||
|
- `wasm-pack`
|
||||||
|
- Node.js 20 or later
|
||||||
|
- pnpm
|
||||||
|
|
||||||
|
Install the JavaScript dependencies from this directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo install wasm-pack
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Build both browser packages:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pnpm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
This compiles separate testnet and mainnet versions of the Rust core to
|
||||||
|
WebAssembly and creates:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dist/chrome/
|
||||||
|
dist/firefox/
|
||||||
|
```
|
||||||
|
|
||||||
|
Build only one browser when developing:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pnpm run build:chrome
|
||||||
|
pnpm run build:firefox
|
||||||
|
```
|
||||||
|
|
||||||
|
Chrome and Firefox use the same TypeScript, HTML, CSS, SDK provider, and two
|
||||||
|
network-specific WebAssembly cores. Each output receives its own manifest because Chrome uses a
|
||||||
|
Manifest V3 background service worker while Firefox uses a Manifest V3
|
||||||
|
background event script.
|
||||||
|
|
||||||
|
### Load in Chrome
|
||||||
|
|
||||||
|
Open `chrome://extensions`, enable **Developer mode**, select **Load
|
||||||
|
unpacked**, and choose `dist/chrome`.
|
||||||
|
|
||||||
|
### Load in Firefox
|
||||||
|
|
||||||
|
Open `about:debugging`, select **This Firefox**, select **Load Temporary
|
||||||
|
Add-on**, and choose `dist/firefox/manifest.json`.
|
||||||
|
|
||||||
|
The Firefox build requires Firefox 149 or later. Firefox 149 removed the user
|
||||||
|
gesture restriction from `action.openPopup`, allowing website connection and
|
||||||
|
transaction requests to open the wallet approval interface automatically in
|
||||||
|
the same way as the Chrome build.
|
||||||
|
|
||||||
|
Run Mozilla's validator after building Firefox:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pnpm run lint:firefox
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the Firefox submission archive:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pnpm run package:firefox
|
||||||
|
```
|
||||||
|
|
||||||
|
The archive is written to `web-ext-artifacts`. Firefox requires Mozilla signing
|
||||||
|
before the packaged extension can be installed permanently in standard
|
||||||
|
Firefox releases.
|
||||||
|
|
||||||
|
Safari requires conversion through Apple's Safari Web Extension tooling and
|
||||||
|
Xcode.
|
||||||
|
|
||||||
|
## Networks
|
||||||
|
|
||||||
|
The wallet presents a Testnet/Mainnet selector before wallet creation and
|
||||||
|
unlock. Addresses, active wallets, WebAssembly transaction codecs, API URLs,
|
||||||
|
and website sessions are isolated by the selected network. Switching networks
|
||||||
|
locks the wallet and expires all application access keys.
|
||||||
|
|
||||||
|
Testnet uses `.cltc` addresses and defaults to
|
||||||
|
`https://api.contractless.dev`. Mainnet uses `.clc` addresses and has no API
|
||||||
|
configured by default. A mainnet wallet may still be created, imported, and
|
||||||
|
backed up while that endpoint is empty; network lookups, registration, and
|
||||||
|
transaction broadcasts remain unavailable until a mainnet API is configured.
|
||||||
|
|
||||||
|
## Provider
|
||||||
|
|
||||||
|
Websites receive `window.contractless` with a Promise-based `request` method.
|
||||||
|
Applications should normally use the downloadable client in
|
||||||
|
`../sdk/contractless.js` instead of calling the provider directly:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { ContractlessWallet } from "./contractless.js";
|
||||||
|
|
||||||
|
const wallet = new ContractlessWallet({
|
||||||
|
appId: "example-application"
|
||||||
|
});
|
||||||
|
|
||||||
|
const connection = await wallet.connect({
|
||||||
|
message: "Sign in to Example Application"
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Transaction and message requests are routed through the background worker and
|
||||||
|
wallet approval screen. Websites never receive wallet private keys or
|
||||||
|
unrestricted raw-signing access. Single-signer transactions are signed and
|
||||||
|
broadcast by the wallet. The transaction ID and signed bytes are returned only
|
||||||
|
after the configured API accepts the broadcast.
|
||||||
|
|
||||||
|
Supported provider methods are:
|
||||||
|
|
||||||
|
- `contractless_detect`
|
||||||
|
- `contractless_connect`
|
||||||
|
- `contractless_accounts`
|
||||||
|
- `contractless_balances`
|
||||||
|
- `contractless_disconnect`
|
||||||
|
- `contractless_signMessage`
|
||||||
|
- `contractless_sendTransaction`
|
||||||
|
|
||||||
|
Connection, message signing, and transaction submission wait for an explicit
|
||||||
|
approval or rejection. A successful connection creates a random 256-bit
|
||||||
|
access key bound to the browser-derived origin, stable application ID, active
|
||||||
|
wallet, and current wallet unlock session.
|
||||||
|
|
||||||
|
The website stores its copy in browser `sessionStorage`. The extension stores
|
||||||
|
the matching authorization in extension session storage. The key is reusable
|
||||||
|
until the wallet locks, changes active wallets, or the application
|
||||||
|
disconnects. It is not a permanent connected-site permission.
|
||||||
|
|
||||||
|
Every authorized request includes the claimed origin, application ID, and
|
||||||
|
access key. The extension independently derives the real origin from the
|
||||||
|
browser message sender and rejects mismatches.
|
||||||
|
|
||||||
|
See `../sdk/README.md` for complete website integration examples.
|
||||||
|
|
||||||
|
## Wallet registration
|
||||||
|
|
||||||
|
The **Register wallet** button creates the command-38 registration signature
|
||||||
|
and the fixed API handshake proof locally. It sends the address, public key,
|
||||||
|
and signatures to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/v1/addresses/register
|
||||||
|
```
|
||||||
|
|
||||||
|
The private key and wallet encryption key never leave the extension. The API
|
||||||
|
requires the authenticated address and public key to match the registration
|
||||||
|
being submitted before forwarding it to a Contractless node.
|
||||||
|
|
||||||
|
The API operator must include the installed extension origin in
|
||||||
|
`API_CORS_ORIGINS`, for example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
API_CORS_ORIGINS=chrome-extension://extension-id
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep any existing allowed website origins in the comma-separated list.
|
||||||
|
Changing the API endpoint in the wallet prompts the user for permission to
|
||||||
|
contact that HTTPS origin.
|
||||||
|
|
||||||
|
Firefox extension pages use a `moz-extension://` origin. The production API
|
||||||
|
must allow the installed Firefox extension to make the same wallet requests as
|
||||||
|
the Chrome build.
|
||||||
|
|
||||||
|
## Firefox Submission Source
|
||||||
|
|
||||||
|
Mozilla reviewers must receive the readable source because Vite bundles the
|
||||||
|
TypeScript and `wasm-pack` generates the WebAssembly package. Submit the built
|
||||||
|
Firefox archive as the add-on and a separate source archive containing at
|
||||||
|
least:
|
||||||
|
|
||||||
|
```text
|
||||||
|
core/
|
||||||
|
extension/src/
|
||||||
|
extension/package.json
|
||||||
|
extension/pnpm-lock.yaml
|
||||||
|
extension/pnpm-workspace.yaml
|
||||||
|
extension/tsconfig.json
|
||||||
|
extension/vite.config.ts
|
||||||
|
README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not include `extension/node_modules`, `extension/dist`, or `core/target` in
|
||||||
|
the source archive. Reviewers can reproduce the Firefox build by entering the
|
||||||
|
`extension` directory and running:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm run build:firefox
|
||||||
|
```
|
||||||
|
After Width: | Height: | Size: 5.2 KiB |
|
|
@ -0,0 +1 @@
|
||||||
|
*{box-sizing:border-box}body{max-width:760px;margin:0 auto;padding:34px;color:#111820;font:14px/1.45 Arial,Helvetica,sans-serif}header{display:flex;align-items:center;gap:15px;padding-bottom:18px;border-bottom:3px solid #2167a5}header img{width:58px}h1{margin:0;font-size:24px}header p{margin:2px 0 0;color:#5c6972}.warning{margin:22px 0;border:2px solid #9b1c1c;padding:12px;color:#7a1111;font-weight:700}section{margin-top:18px}h2{margin:0 0 7px;color:#42515b;font-size:12px;text-transform:uppercase}pre{margin:0;overflow-wrap:anywhere;white-space:pre-wrap;border:1px solid #aebac2;padding:11px;font:10px/1.35 Consolas,monospace}.print-error{color:#9b1c1c;font-weight:700}@media print{body{max-width:none;padding:0}}
|
||||||
|
After Width: | Height: | Size: 5.2 KiB |
|
|
@ -0,0 +1 @@
|
||||||
|
:root{font:16px/1.5 Arial,Helvetica,sans-serif;color:#fff;background:#08131c}*{box-sizing:border-box}body{min-width:320px;min-height:100vh;margin:0;background:#08131c}button,input{font:inherit}button{cursor:pointer}button:disabled{cursor:wait;opacity:.72}.site-header{display:flex;align-items:center;justify-content:space-between;min-height:76px;padding:0 32px;border-bottom:1px solid #2e4757;background:#0d1d28}.brand{display:flex;align-items:center;gap:12px}.brand img{width:39px;filter:brightness(0) invert(1)}.brand div{display:grid}.brand strong{font-size:18px}.brand span,.local-page{color:#b9cbd6;font-size:13px}main{display:grid;place-items:start center;padding:54px 20px}.import-panel,.success-panel{width:min(100%,500px);padding:34px;border:1px solid #385666;background:#10222e}.eyebrow{color:#58d5e7;font-size:12px;font-weight:700;text-transform:uppercase}h1{margin:7px 0 10px;font-size:31px;line-height:1.15;letter-spacing:0}.intro,.success-panel p{margin:0 0 26px;color:#c8d6de}form{display:grid}label{margin:21px 0 8px;font-size:13px;font-weight:700}.visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}.file-picker{display:flex;align-items:center;gap:14px;min-height:78px;margin-top:8px;padding:13px;border:1px solid #5ac9de;background:#132d3b;cursor:pointer}.file-picker>span:last-child{display:grid;gap:3px}.file-picker small{color:#afc6d1;font-weight:400}.file-icon{display:grid;place-items:center;width:44px;height:44px;border:1px solid #62d7e9;color:#62d7e9;font-size:25px}.input-row{display:grid;grid-template-columns:1fr auto}.input-row input{min-width:0;height:50px;padding:0 14px;border:1px solid #5a7180;border-right:0;background:#08151e;color:#fff}.input-row button{width:68px;border:1px solid #5a7180;background:#173345;color:#fff}.security-note,.address-result{display:grid;gap:4px;margin-top:20px;padding:13px 14px;border-left:3px solid #57d0df;background:#0a1922}.security-note span,.address-result span{color:#b7cad4;font-size:13px}.address-result strong{overflow-wrap:anywhere}.primary{min-height:50px;margin-top:24px;border:1px solid #67d5e4;background:#236eaa;color:#fff;font-weight:700}#notice{margin:14px 0 0;padding:10px 12px;border-left:3px solid #ff7168;background:#321c20;color:#ffc0bb;font-size:13px}#notice:empty{display:none}.success-mark{display:grid;place-items:center;width:58px;height:58px;margin-bottom:25px;border:2px solid #62d7e9;border-radius:50%;color:#62d7e9;font-size:31px;font-weight:700}.completion-message{margin-top:24px!important}@media(max-width:560px){.site-header{padding:0 18px}.local-page{display:none}main{padding:24px 12px}.import-panel,.success-panel{padding:24px 20px}h1{font-size:27px}}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
*{box-sizing:border-box}body{max-width:760px;margin:0 auto;padding:34px;color:#111820;font:14px/1.45 Arial,Helvetica,sans-serif}header{display:flex;align-items:center;gap:15px;padding-bottom:18px;border-bottom:3px solid #2167a5}header img{width:58px}h1{margin:0;font-size:24px}header p{margin:2px 0 0;color:#5c6972}.warning{margin:22px 0;border:2px solid #9b1c1c;padding:12px;color:#7a1111;font-weight:700}section{margin-top:18px}h2{margin:0 0 7px;color:#42515b;font-size:12px;text-transform:uppercase}pre{margin:0;overflow-wrap:anywhere;white-space:pre-wrap;border:1px solid #aebac2;padding:11px;font:10px/1.35 Consolas,monospace}.print-error{color:#9b1c1c;font-weight:700}@media print{body{max-width:none;padding:0}}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const e of document.querySelectorAll('link[rel="modulepreload"]'))i(e);new MutationObserver(e=>{for(const r of e)if(r.type==="childList")for(const o of r.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&i(o)}).observe(document,{childList:!0,subtree:!0});function s(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),e.crossOrigin==="use-credentials"?r.credentials="include":e.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function i(e){if(e.ep)return;e.ep=!0;const r=s(e);fetch(e.href,r)}})();
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
const o=document.createElement("script");o.src=chrome.runtime.getURL("provider.js");o.onload=()=>o.remove();(document.head||document.documentElement).appendChild(o);window.addEventListener("message",e=>{var t;e.source!==window||((t=e.data)==null?void 0:t.source)!=="contractless-page"||chrome.runtime.sendMessage({type:"provider-request",method:e.data.method,params:e.data.params},a=>{window.postMessage({source:"contractless-wallet",id:e.data.id,response:a},window.location.origin)})});
|
||||||
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 707 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
|
@ -0,0 +1,70 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Import Contractless Wallet</title>
|
||||||
|
<script type="module" crossorigin src="/import-wallet.js"></script>
|
||||||
|
<link rel="modulepreload" crossorigin href="/chunks/modulepreload-polyfill-B5Qt9EMX.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/import-wallet-D9ROrgVZ.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="brand">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><span>Browser Wallet</span></div>
|
||||||
|
</div>
|
||||||
|
<span class="local-page">Local extension page</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section id="import-panel" class="import-panel">
|
||||||
|
<span class="eyebrow">Restore <span id="selected-network">Contractless</span> wallet</span>
|
||||||
|
<h1>Import wallet image</h1>
|
||||||
|
<p class="intro">
|
||||||
|
Select your encrypted Contractless private-key image and enter the
|
||||||
|
encryption key originally used to create it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form id="import-form">
|
||||||
|
<label class="file-picker" for="wallet-image">
|
||||||
|
<span class="file-icon">+</span>
|
||||||
|
<span>
|
||||||
|
<strong id="image-file-name">Choose wallet image</strong>
|
||||||
|
<small>PNG encrypted private-key image</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input id="wallet-image" class="visually-hidden" type="file" accept="image/png" required />
|
||||||
|
|
||||||
|
<label for="image-password">Encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="image-password" type="password" autocomplete="current-password" required />
|
||||||
|
<button id="reveal-password" type="button">Show</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="security-note">
|
||||||
|
<strong>Processed locally by Contractless Wallet</strong>
|
||||||
|
<span>The wallet image and encryption key are not sent to the website you were visiting.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="import-button" class="primary" type="submit">Import wallet</button>
|
||||||
|
<p id="notice" role="alert" aria-live="polite"></p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="success-panel" class="success-panel" hidden>
|
||||||
|
<span class="success-mark" aria-hidden="true">✓</span>
|
||||||
|
<span class="eyebrow">Import complete</span>
|
||||||
|
<h1>Wallet imported successfully</h1>
|
||||||
|
<p>The imported wallet is unlocked and is now the active Contractless address.</p>
|
||||||
|
<div class="address-result">
|
||||||
|
<span>Active wallet</span>
|
||||||
|
<strong id="imported-address"></strong>
|
||||||
|
</div>
|
||||||
|
<p class="completion-message">You may close this page and reopen the Contractless Wallet extension.</p>
|
||||||
|
<button id="close-page" class="primary" type="button">Close page</button>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
import"./chunks/modulepreload-polyfill-B5Qt9EMX.js";const a=t=>document.getElementById(t),u=a("import-form"),l=a("wallet-image"),r=a("image-password"),s=a("import-button"),i=a("notice");m("status").then(t=>{const e=t;a("selected-network").textContent=e.networkName}).catch(t=>{i.textContent=d(t)});function w(t){let e="";for(let o=0;o<t.length;o+=32768)e+=String.fromCharCode(...t.subarray(o,o+32768));return btoa(e)}function d(t){return t instanceof Error?t.message:String(t)}async function m(t,e={}){const n=await chrome.runtime.sendMessage({type:t,...e});if(!(n!=null&&n.ok))throw new Error((n==null?void 0:n.error)??"Wallet request failed.");return n.result}l.addEventListener("change",()=>{var t,e;a("image-file-name").textContent=((e=(t=l.files)==null?void 0:t[0])==null?void 0:e.name)??"Choose wallet image",i.textContent=""});a("reveal-password").addEventListener("click",t=>{const e=t.currentTarget,n=r.type==="text";r.type=n?"password":"text",e.textContent=n?"Show":"Hide"});u.addEventListener("submit",t=>{t.preventDefault(),(async()=>{var c;const e=(c=l.files)==null?void 0:c[0];if(!e)throw new Error("Choose a wallet image.");if(!r.value.trim())throw new Error("Enter the wallet image encryption key.");i.textContent="",s.disabled=!0,s.textContent="Importing wallet...";const n=w(new Uint8Array(await e.arrayBuffer())),o=await m("import-wallet-image",{imageBase64:n,imagePassword:r.value,password:r.value});r.value="",a("imported-address").textContent=o.address??"Imported wallet",a("import-panel").hidden=!0,a("success-panel").hidden=!1})().catch(e=>{i.textContent=d(e),s.disabled=!1,s.textContent="Import wallet"})});a("close-page").addEventListener("click",()=>window.close());
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Contractless Wallet",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"description": "A local-signing Web3 wallet for Contractless.",
|
||||||
|
"content_security_policy": {
|
||||||
|
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
||||||
|
},
|
||||||
|
"permissions": ["storage", "alarms", "activeTab", "clipboardWrite"],
|
||||||
|
"host_permissions": ["https://api.contractless.dev/*"],
|
||||||
|
"optional_host_permissions": ["https://*/"],
|
||||||
|
"action": {
|
||||||
|
"default_popup": "popup.html",
|
||||||
|
"default_title": "Contractless Wallet",
|
||||||
|
"default_icon": {
|
||||||
|
"16": "contractless-logo-16.png",
|
||||||
|
"32": "contractless-logo-32.png",
|
||||||
|
"48": "contractless-logo-48.png",
|
||||||
|
"128": "contractless-logo-128.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"icons": {
|
||||||
|
"16": "contractless-logo-16.png",
|
||||||
|
"32": "contractless-logo-32.png",
|
||||||
|
"48": "contractless-logo-48.png",
|
||||||
|
"128": "contractless-logo-128.png"
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"service_worker": "background.js",
|
||||||
|
"type": "module"
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"],
|
||||||
|
"js": ["content.js"],
|
||||||
|
"run_at": "document_start"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"web_accessible_resources": [
|
||||||
|
{
|
||||||
|
"resources": ["provider.js"],
|
||||||
|
"matches": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,654 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Contractless Wallet</title>
|
||||||
|
<script type="module" crossorigin src="/popup.js"></script>
|
||||||
|
<link rel="modulepreload" crossorigin href="/chunks/modulepreload-polyfill-B5Qt9EMX.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/chunks/core-D2IK1U91.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/popup-B3vY8vDf.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="wallet-shell">
|
||||||
|
<section id="loading" class="screen loading-screen">
|
||||||
|
<div class="loading-brand">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="Contractless" />
|
||||||
|
<div class="spinner" aria-label="Loading"></div>
|
||||||
|
</div>
|
||||||
|
<p>Opening wallet</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="create-screen" class="screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small>Browser Wallet</small></div>
|
||||||
|
<span>1 of 3</span>
|
||||||
|
</header>
|
||||||
|
<div class="screen-content">
|
||||||
|
<div class="network-choice" role="group" aria-label="Wallet network">
|
||||||
|
<button type="button" data-select-network="testnet">Testnet</button>
|
||||||
|
<button type="button" data-select-network="mainnet">Mainnet</button>
|
||||||
|
</div>
|
||||||
|
<span class="eyebrow">New wallet</span>
|
||||||
|
<h1>Create your wallet</h1>
|
||||||
|
<p class="intro">Choose an encryption key used to unlock and recover this wallet.</p>
|
||||||
|
|
||||||
|
<label for="new-password">Encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="new-password" type="password" autocomplete="new-password" />
|
||||||
|
<button class="reveal" type="button" data-input="new-password">Show</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>This key is not stored for you.</strong>
|
||||||
|
<span>You will verify it before the wallet is created.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="continue-create" class="primary" type="button">Continue <span>→</span></button>
|
||||||
|
<div class="divider"><span>or restore an existing wallet</span></div>
|
||||||
|
<button class="secondary navigation" data-screen="image-screen" type="button">
|
||||||
|
Import wallet from image
|
||||||
|
</button>
|
||||||
|
<button class="secondary navigation" data-screen="private-screen" type="button">
|
||||||
|
Import wallet from private key
|
||||||
|
</button>
|
||||||
|
<button id="cancel-wallet-add" class="text-button" type="button" hidden>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="image-screen" class="screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small>Browser Wallet</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
<form id="import-image-form" class="screen-content">
|
||||||
|
<span class="eyebrow">Existing wallet</span>
|
||||||
|
<h1>Import wallet image</h1>
|
||||||
|
<p class="intro">
|
||||||
|
Select your encrypted private-key image and enter the encryption key
|
||||||
|
originally used to create it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="file-heading" for="wallet-image">Wallet image</label>
|
||||||
|
<label class="file-picker" for="wallet-image">
|
||||||
|
<span class="file-icon">+</span>
|
||||||
|
<span><strong id="image-file-name">Choose wallet image</strong><small>PNG encrypted private-key image</small></span>
|
||||||
|
</label>
|
||||||
|
<input id="wallet-image" class="visually-hidden" type="file" accept="image/png" />
|
||||||
|
|
||||||
|
<label for="image-password">Encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="image-password" type="password" autocomplete="current-password" />
|
||||||
|
<button class="reveal" type="button" data-input="image-password">Show</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="import-image" class="primary" type="submit">Import wallet</button>
|
||||||
|
<button class="text-button navigation" data-screen="create-screen" type="button">Back</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="private-screen" class="screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small>Browser Wallet</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
<div class="screen-content compact">
|
||||||
|
<span class="eyebrow">Existing wallet</span>
|
||||||
|
<h1>Import private key</h1>
|
||||||
|
<p class="intro">
|
||||||
|
Paste the private key, then choose the encryption key that will protect
|
||||||
|
this wallet from now on.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label for="private-key">Private key</label>
|
||||||
|
<textarea id="private-key" rows="4" placeholder="Paste private key"></textarea>
|
||||||
|
|
||||||
|
<label for="private-password">New encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="private-password" type="password" autocomplete="new-password" />
|
||||||
|
<button class="reveal" type="button" data-input="private-password">Show</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note compact-note">
|
||||||
|
<strong>This key protects the new wallet image.</strong>
|
||||||
|
<span>Store it securely and separately from the image.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="import-private" class="primary" type="button">Import and create wallet</button>
|
||||||
|
<button class="text-button navigation" data-screen="create-screen" type="button">Back</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="verify-screen" class="screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small>Browser Wallet</small></div>
|
||||||
|
<span>2 of 3</span>
|
||||||
|
</header>
|
||||||
|
<div class="screen-content">
|
||||||
|
<span class="eyebrow">Recovery</span>
|
||||||
|
<h1>Verify your encryption key</h1>
|
||||||
|
<p class="intro">
|
||||||
|
Enter the same key again. Keep it somewhere secure and separate from
|
||||||
|
your wallet image.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label for="verify-password">Re-enter encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="verify-password" type="password" autocomplete="new-password" />
|
||||||
|
<button class="reveal" type="button" data-input="verify-password">Show</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="warning">
|
||||||
|
<strong>There is no password reset.</strong>
|
||||||
|
Losing this key means the wallet image cannot restore your wallet.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="create-wallet" class="primary" type="button">Create wallet <span>→</span></button>
|
||||||
|
<button class="text-button navigation" data-screen="create-screen" type="button">Back</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="backup-screen" class="screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small>Browser Wallet</small></div>
|
||||||
|
<span>3 of 3</span>
|
||||||
|
</header>
|
||||||
|
<div class="screen-content backup-content">
|
||||||
|
<span class="success-mark">✓</span>
|
||||||
|
<h1>Your wallet is ready</h1>
|
||||||
|
<p class="intro">
|
||||||
|
Save the private-key image now. You need both this image and your
|
||||||
|
encryption key to recover the wallet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="image-preview">
|
||||||
|
<img id="wallet-image-preview" alt="Encrypted private-key wallet image" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="save-image" class="primary" type="button">↓ Save wallet image</button>
|
||||||
|
<label class="confirmation">
|
||||||
|
<input id="backup-confirmed" type="checkbox" disabled />
|
||||||
|
<span>I saved the image and stored my encryption key separately.</span>
|
||||||
|
</label>
|
||||||
|
<button id="open-wallet" class="secondary" type="button" disabled>Open wallet</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="locked-screen" class="screen" hidden>
|
||||||
|
<form id="unlock-form" class="centered-content">
|
||||||
|
<img class="large-logo" src="/assets/contractless-logo-BgfMS-Kf.png" alt="Contractless" />
|
||||||
|
<div class="network-choice" role="group" aria-label="Wallet network">
|
||||||
|
<button type="button" data-select-network="testnet">Testnet</button>
|
||||||
|
<button type="button" data-select-network="mainnet">Mainnet</button>
|
||||||
|
</div>
|
||||||
|
<h1>Unlock wallet</h1>
|
||||||
|
<p class="intro">Enter your encryption key to continue.</p>
|
||||||
|
<label for="unlock-password">Encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="unlock-password" type="password" autocomplete="current-password" />
|
||||||
|
<button class="reveal" type="button" data-input="unlock-password">Show</button>
|
||||||
|
</div>
|
||||||
|
<button id="unlock-wallet" class="primary" type="submit">Unlock wallet</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="provider-approval-screen" class="screen provider-approval-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small>Wallet request</small></div>
|
||||||
|
<span>Review</span>
|
||||||
|
</header>
|
||||||
|
<div class="screen-content">
|
||||||
|
<div class="approval-context">
|
||||||
|
<span class="eyebrow" id="provider-approval-origin"></span>
|
||||||
|
<span id="provider-approval-risk" class="risk-label"></span>
|
||||||
|
</div>
|
||||||
|
<h1 id="provider-approval-title">Review request</h1>
|
||||||
|
<p class="intro" id="provider-approval-intro">Review this request before approving it.</p>
|
||||||
|
<dl id="provider-approval-details" class="approval-details"></dl>
|
||||||
|
</div>
|
||||||
|
<div class="approval-actions">
|
||||||
|
<button id="reject-provider-approval" class="secondary" type="button">Reject</button>
|
||||||
|
<button id="approve-provider-approval" class="primary" type="button">Approve</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="dashboard-screen" class="screen" hidden>
|
||||||
|
<header class="wallet-header dashboard-header">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small data-network-name>Testnet</small></div>
|
||||||
|
<button
|
||||||
|
id="account-menu-button"
|
||||||
|
class="account-button"
|
||||||
|
type="button"
|
||||||
|
title="Wallets and settings"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="account-drawer"
|
||||||
|
>
|
||||||
|
<span class="network-dot"></span>
|
||||||
|
<span id="wallet-address-short">Account</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="assets-dashboard">
|
||||||
|
<nav class="wallet-actions" aria-label="Wallet actions">
|
||||||
|
<button id="open-send" type="button">
|
||||||
|
<span class="action-icon">↗</span>
|
||||||
|
<span>Send</span>
|
||||||
|
</button>
|
||||||
|
<button id="open-receive" type="button">
|
||||||
|
<span class="action-icon">↙</span>
|
||||||
|
<span>Receive</span>
|
||||||
|
</button>
|
||||||
|
<button id="open-nfts" type="button">
|
||||||
|
<span class="action-icon">#</span>
|
||||||
|
<span>NFTs/RWAs</span>
|
||||||
|
</button>
|
||||||
|
<button id="open-activity" type="button">
|
||||||
|
<span class="action-icon">◷</span>
|
||||||
|
<span>Activity</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section class="portfolio-balance" aria-labelledby="portfolio-title">
|
||||||
|
<span id="portfolio-title">Available balance</span>
|
||||||
|
<strong><span id="base-balance">0.00000000</span> <small data-network-symbol>CLTC</small></strong>
|
||||||
|
<p id="wallet-address"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="asset-ledger" aria-labelledby="assets-title">
|
||||||
|
<div class="asset-ledger-heading">
|
||||||
|
<h1 id="assets-title">Assets</h1>
|
||||||
|
<button id="refresh-balances" type="button" title="Refresh balances" aria-label="Refresh balances">↻</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="asset-filter" role="group" aria-label="Token visibility">
|
||||||
|
<button class="active" data-token-filter="held" type="button">Held tokens</button>
|
||||||
|
<button data-token-filter="all" type="button">All tokens</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="asset-row base-asset">
|
||||||
|
<div class="asset-symbol contractless-symbol">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
</div>
|
||||||
|
<div class="asset-name">
|
||||||
|
<strong id="base-asset-name">Contractless Testnet</strong>
|
||||||
|
<span data-network-symbol>CLTC</span>
|
||||||
|
</div>
|
||||||
|
<div class="asset-value">
|
||||||
|
<strong id="base-asset-balance">0.00000000</strong>
|
||||||
|
<span data-network-symbol>CLTC</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="token-list" class="token-list">
|
||||||
|
<div class="empty-assets">
|
||||||
|
<strong>Loading token balances...</strong>
|
||||||
|
<span>Balances are retrieved from the selected Contractless API.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="nfts-screen" class="screen nfts-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-nfts" class="header-back" type="button" title="Back to assets" aria-label="Back to assets">←</button>
|
||||||
|
<div><strong>NFTs and RWAs</strong><small>Assets owned by this wallet</small></div>
|
||||||
|
<button id="refresh-nfts" class="header-refresh" type="button" title="Refresh assets" aria-label="Refresh assets">↻</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="nfts-content">
|
||||||
|
<div class="nfts-summary">
|
||||||
|
<span id="nfts-count">Loading ownership...</span>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</div>
|
||||||
|
<div id="nfts-list" class="nfts-list"></div>
|
||||||
|
<div id="nfts-pagination" class="nfts-pagination" hidden>
|
||||||
|
<button id="nfts-previous" type="button" aria-label="Previous NFT page">←</button>
|
||||||
|
<span>Page <strong id="nfts-page">1</strong> of <strong id="nfts-pages">1</strong></span>
|
||||||
|
<button id="nfts-next" type="button" aria-label="Next NFT page">→</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="nft-detail-screen" class="screen nft-detail-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-nft-detail" class="header-back" type="button" title="Back to NFTs and RWAs" aria-label="Back to NFTs and RWAs">←</button>
|
||||||
|
<div><strong id="nft-detail-title">NFT/RWA</strong><small id="nft-detail-kind">On-chain asset</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="nft-detail-content">
|
||||||
|
<div class="nft-detail-media">
|
||||||
|
<img id="nft-detail-image" alt="" />
|
||||||
|
</div>
|
||||||
|
<div class="nft-detail-heading">
|
||||||
|
<div>
|
||||||
|
<span id="nft-detail-badge">NFT</span>
|
||||||
|
<h1 id="nft-detail-name">NFT/RWA</h1>
|
||||||
|
</div>
|
||||||
|
<strong id="nft-detail-ownership"></strong>
|
||||||
|
</div>
|
||||||
|
<p id="nft-detail-description" class="nft-detail-description"></p>
|
||||||
|
<div id="nft-detail-attributes" class="nft-detail-attributes"></div>
|
||||||
|
<dl id="nft-detail-fields" class="nft-detail-fields"></dl>
|
||||||
|
<div class="nft-detail-actions">
|
||||||
|
<button id="transfer-nft" class="primary" type="button">Transfer asset</button>
|
||||||
|
</div>
|
||||||
|
<section class="nft-provenance">
|
||||||
|
<h2>Provenance</h2>
|
||||||
|
<div id="nft-detail-history"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="send-screen" class="screen send-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-send" class="header-back" type="button" title="Back to assets" aria-label="Back to assets">←</button>
|
||||||
|
<div><strong>Send</strong><small>Create a transfer</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form id="send-form" class="send-content">
|
||||||
|
<div>
|
||||||
|
<label for="send-asset">Asset</label>
|
||||||
|
<select id="send-asset"></select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="send-recipient">Receiving address</label>
|
||||||
|
<input id="send-recipient" type="text" autocomplete="off" spellcheck="false" placeholder="Wallet or vanity address" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="send-amount">Amount</label>
|
||||||
|
<div class="send-amount-row">
|
||||||
|
<input id="send-amount" type="text" inputmode="decimal" autocomplete="off" placeholder="0.00000000" />
|
||||||
|
<span id="send-amount-symbol" data-network-symbol>CLTC</span>
|
||||||
|
</div>
|
||||||
|
<span id="send-available" class="field-note">Available: 0.00000000 CLTC</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="send-fee">Transaction fee</label>
|
||||||
|
<div class="send-amount-row">
|
||||||
|
<input id="send-fee" type="text" inputmode="decimal" autocomplete="off" placeholder="0.00000000" />
|
||||||
|
<span data-network-symbol>CLTC</span>
|
||||||
|
</div>
|
||||||
|
<span id="send-fee-note" class="field-note">Minimum fee: 1% of the transfer amount.</span>
|
||||||
|
</div>
|
||||||
|
<button class="primary send-continue" type="submit">Review transfer</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="send-review-screen" class="screen send-review-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="edit-send" class="header-back" type="button" title="Edit transfer" aria-label="Edit transfer">←</button>
|
||||||
|
<div><strong>Review transfer</strong><small>Confirm before signing</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="send-review-content">
|
||||||
|
<div class="send-review-amount">
|
||||||
|
<span>You are sending</span>
|
||||||
|
<strong><span id="review-amount">0.00000000</span> <small id="review-asset">CLTC</small></strong>
|
||||||
|
</div>
|
||||||
|
<dl class="send-review-details">
|
||||||
|
<div><dt>From</dt><dd id="review-sender"></dd></div>
|
||||||
|
<div><dt>To</dt><dd id="review-recipient"></dd></div>
|
||||||
|
<div><dt>Resolved address</dt><dd id="review-resolved-recipient"></dd></div>
|
||||||
|
<div><dt>Transaction fee</dt><dd><span id="review-fee"></span> <span data-network-symbol>CLTC</span></dd></div>
|
||||||
|
<div><dt>Total deducted</dt><dd id="review-total"></dd></div>
|
||||||
|
</dl>
|
||||||
|
<p class="signing-notice">Your private key remains inside this wallet. The transaction is signed locally and only the completed transaction is sent to the API.</p>
|
||||||
|
<button id="sign-broadcast-transfer" class="primary" type="button">Sign and broadcast</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="send-success-screen" class="screen send-success-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<div><strong>Transfer broadcast</strong><small>Submitted to Contractless</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
<div class="send-success-content">
|
||||||
|
<span class="success-mark">✓</span>
|
||||||
|
<h1>Transaction sent</h1>
|
||||||
|
<p>The signed transfer was accepted for broadcast.</p>
|
||||||
|
<div class="send-txid">
|
||||||
|
<span>Transaction ID</span>
|
||||||
|
<strong id="send-result-txid"></strong>
|
||||||
|
</div>
|
||||||
|
<button id="copy-send-txid" class="secondary" type="button">Copy transaction ID</button>
|
||||||
|
<button id="finish-send" class="primary" type="button">Return to assets</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="activity-screen" class="screen activity-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-activity" class="header-back" type="button" title="Back to assets" aria-label="Back to assets">←</button>
|
||||||
|
<div><strong>Activity</strong><small>Latest 25 transactions</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="activity-content">
|
||||||
|
<div class="activity-summary">
|
||||||
|
<span>Newest transactions first</span>
|
||||||
|
<div>
|
||||||
|
<strong id="activity-count">0 shown</strong>
|
||||||
|
<button id="refresh-activity" type="button" title="Refresh activity" aria-label="Refresh activity">↻</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="activity-list" class="activity-list"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="transaction-screen" class="screen transaction-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-transaction" class="header-back" type="button" title="Back to activity" aria-label="Back to activity">←</button>
|
||||||
|
<div><strong id="transaction-title">Transaction</strong><small>Complete on-chain details</small></div>
|
||||||
|
<span id="transaction-header-status"></span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="transaction-content">
|
||||||
|
<section class="transaction-identity">
|
||||||
|
<div>
|
||||||
|
<span>Transaction ID</span>
|
||||||
|
<strong id="transaction-full-txid"></strong>
|
||||||
|
</div>
|
||||||
|
<button id="copy-transaction-txid" type="button">Copy TXID</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="transaction-overview" class="transaction-fields"></div>
|
||||||
|
|
||||||
|
<section class="transaction-section">
|
||||||
|
<h2>Transaction Fields</h2>
|
||||||
|
<div id="transaction-fields" class="transaction-fields"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="miner-earnings-section" class="transaction-section" hidden>
|
||||||
|
<h2>Miner Earnings</h2>
|
||||||
|
<div id="transaction-miner-earnings" class="transaction-fields"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<details class="transaction-technical">
|
||||||
|
<summary>Technical Details</summary>
|
||||||
|
<div id="transaction-technical-fields" class="transaction-fields"></div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="receive-screen" class="screen receive-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-receive" class="header-back" type="button" title="Back to assets" aria-label="Back to assets">←</button>
|
||||||
|
<div><strong>Receive</strong><small>Contractless wallet address</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="receive-content">
|
||||||
|
<span class="receive-label">Receive <span data-network-symbol>CLTC</span> and Contractless assets</span>
|
||||||
|
<h1>Share your address</h1>
|
||||||
|
<p>
|
||||||
|
Scan the QR code or copy the address below. Only send assets issued on
|
||||||
|
the Contractless <span data-network-name>Testnet</span> network to this address.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="qr-frame">
|
||||||
|
<canvas id="receive-qr" width="220" height="220" aria-label="Wallet address QR code"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="receive-address">
|
||||||
|
<span id="receive-address-label">Wallet address</span>
|
||||||
|
<strong id="receive-address-value"></strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="copy-receive-address" class="primary receive-copy" type="button">
|
||||||
|
Copy address
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="drawer-backdrop" class="drawer-backdrop" hidden></div>
|
||||||
|
<aside id="account-drawer" class="account-drawer" aria-hidden="true">
|
||||||
|
<header class="drawer-header">
|
||||||
|
<div>
|
||||||
|
<strong>Wallets</strong>
|
||||||
|
<span>Accounts and settings</span>
|
||||||
|
</div>
|
||||||
|
<button id="close-account-drawer" type="button" title="Close menu" aria-label="Close menu">×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="drawer-section">
|
||||||
|
<span class="drawer-label">Network</span>
|
||||||
|
<div class="network-choice drawer-network" role="group" aria-label="Wallet network">
|
||||||
|
<button type="button" data-select-network="testnet">Testnet</button>
|
||||||
|
<button type="button" data-select-network="mainnet">Mainnet</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="drawer-section">
|
||||||
|
<span class="drawer-label">Known addresses</span>
|
||||||
|
<div id="known-addresses" class="known-addresses"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav class="drawer-navigation" aria-label="Wallet menu">
|
||||||
|
<button id="menu-create-wallet" type="button">
|
||||||
|
<span class="menu-icon">+</span>
|
||||||
|
<span><strong>Create another address</strong><small>Generate a new Contractless wallet</small></span>
|
||||||
|
<span class="menu-arrow">›</span>
|
||||||
|
</button>
|
||||||
|
<button id="menu-import-wallet" type="button">
|
||||||
|
<span class="menu-icon">↓</span>
|
||||||
|
<span><strong>Import another address</strong><small>Use an image or private key</small></span>
|
||||||
|
<span class="menu-arrow">›</span>
|
||||||
|
</button>
|
||||||
|
<button id="menu-settings" type="button">
|
||||||
|
<span class="menu-icon">⚙</span>
|
||||||
|
<span><strong>Settings</strong><small>API endpoint and lock timeout</small></span>
|
||||||
|
<span class="menu-arrow">›</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<button id="drawer-lock-wallet" class="drawer-lock" type="button">
|
||||||
|
<span>⌁</span>
|
||||||
|
Lock wallet
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section id="settings-screen" class="screen settings-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-settings" class="header-back" type="button" title="Back to assets" aria-label="Back to assets">←</button>
|
||||||
|
<div><strong>Settings</strong><small>Wallet preferences and backup</small></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="settings-content">
|
||||||
|
<section class="settings-group">
|
||||||
|
<div class="settings-heading">
|
||||||
|
<strong>Security</strong>
|
||||||
|
<span>Control how long the active wallet stays unlocked.</span>
|
||||||
|
</div>
|
||||||
|
<label for="settings-lock-timeout">Automatic lock</label>
|
||||||
|
<select id="settings-lock-timeout">
|
||||||
|
<option value="15">15 minutes</option>
|
||||||
|
<option value="30">30 minutes</option>
|
||||||
|
<option value="60">60 minutes</option>
|
||||||
|
<option value="240">4 hours</option>
|
||||||
|
<option value="720">12 hours</option>
|
||||||
|
<option value="1440">24 hours</option>
|
||||||
|
</select>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-group">
|
||||||
|
<div class="settings-heading">
|
||||||
|
<strong>Contractless API</strong>
|
||||||
|
<span>Choose the API used for lookups and transaction broadcasts.</span>
|
||||||
|
</div>
|
||||||
|
<label for="settings-testnet-api-url">Testnet API URL</label>
|
||||||
|
<input id="settings-testnet-api-url" type="url" value="https://api.contractless.dev" />
|
||||||
|
<label for="settings-mainnet-api-url">Mainnet API URL</label>
|
||||||
|
<input id="settings-mainnet-api-url" type="url" placeholder="Not configured" />
|
||||||
|
<span class="settings-help">Mainnet wallets can be created and backed up before a mainnet API is configured.</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button id="save-wallet-settings" class="primary settings-save" type="button">Save settings</button>
|
||||||
|
|
||||||
|
<section class="settings-group backup-settings">
|
||||||
|
<div class="settings-heading">
|
||||||
|
<strong>Active wallet backup</strong>
|
||||||
|
<span>These actions require the active wallet encryption key.</span>
|
||||||
|
</div>
|
||||||
|
<button class="settings-action" data-backup-action="image" type="button">
|
||||||
|
<span class="settings-action-icon">▧</span>
|
||||||
|
<span><strong>Save wallet image</strong><small>Download an encrypted PNG backup</small></span>
|
||||||
|
<span>›</span>
|
||||||
|
</button>
|
||||||
|
<button class="settings-action" data-backup-action="private" type="button">
|
||||||
|
<span class="settings-action-icon">⌘</span>
|
||||||
|
<span><strong>View private key</strong><small>Reveal and copy the active private key</small></span>
|
||||||
|
<span>›</span>
|
||||||
|
</button>
|
||||||
|
<button class="settings-action" data-backup-action="print" type="button">
|
||||||
|
<span class="settings-action-icon">▤</span>
|
||||||
|
<span><strong>Print wallet backup</strong><small>Private key and encryption key</small></span>
|
||||||
|
<span>›</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="private-key-result" class="private-key-result" hidden>
|
||||||
|
<div>
|
||||||
|
<strong>Private key</strong>
|
||||||
|
<button id="clear-private-key" type="button" title="Clear private key" aria-label="Clear private key">×</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="revealed-private-key" readonly></textarea>
|
||||||
|
<button id="copy-private-key" class="secondary" type="button">Copy private key</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="backup-key-backdrop" class="modal-backdrop" hidden></div>
|
||||||
|
<section id="backup-key-modal" class="backup-key-modal" hidden aria-modal="true" role="dialog" aria-labelledby="backup-key-title">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<strong id="backup-key-title">Confirm encryption key</strong>
|
||||||
|
<span>Required to decrypt this wallet locally.</span>
|
||||||
|
</div>
|
||||||
|
<button id="close-backup-key" type="button" title="Cancel" aria-label="Cancel">×</button>
|
||||||
|
</header>
|
||||||
|
<div>
|
||||||
|
<label for="backup-key-input">Encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="backup-key-input" type="password" autocomplete="current-password" />
|
||||||
|
<button class="reveal" type="button" data-input="backup-key-input">Show</button>
|
||||||
|
</div>
|
||||||
|
<button id="confirm-backup-key" class="primary" type="button">Continue</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p id="notice" role="alert" aria-live="polite"></p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Contractless Wallet Backup</title>
|
||||||
|
<script type="module" crossorigin src="/print.js"></script>
|
||||||
|
<link rel="modulepreload" crossorigin href="/chunks/modulepreload-polyfill-B5Qt9EMX.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/print-DTb91-bp.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
<div>
|
||||||
|
<h1>Contractless Wallet Backup</h1>
|
||||||
|
<p>Private recovery information</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p class="warning">
|
||||||
|
Keep this page private. Anyone with the private key and encryption key can
|
||||||
|
control this wallet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Wallet</h2>
|
||||||
|
<pre id="wallet-label"></pre>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Address</h2>
|
||||||
|
<pre id="wallet-address"></pre>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Private Key</h2>
|
||||||
|
<pre id="wallet-private-key"></pre>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Encryption Key</h2>
|
||||||
|
<pre id="wallet-encryption-key"></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p id="print-error" class="print-error"></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
import"./chunks/modulepreload-polyfill-B5Qt9EMX.js";async function a(){const e=new URLSearchParams(location.search).get("id");if(!e)throw new Error("Missing printable wallet backup.");const n=`print-backup:${e}`,o=await chrome.storage.session.get(n);await chrome.storage.session.remove(n);const t=o[n];if(!t)throw new Error("The printable wallet backup has expired.");document.getElementById("wallet-label").textContent=t.label,document.getElementById("wallet-address").textContent=t.address,document.getElementById("wallet-private-key").textContent=t.privateKey,document.getElementById("wallet-encryption-key").textContent=t.encryptionKey,setTimeout(()=>{window.focus(),window.print()},150)}a().catch(e=>{document.getElementById("print-error").textContent=e instanceof Error?e.message:String(e)});
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
const a=new Map;window.addEventListener("message",e=>{var s,t,o;if(e.source!==window||((s=e.data)==null?void 0:s.source)!=="contractless-wallet")return;const r=a.get(e.data.id);r&&(a.delete(e.data.id),(t=e.data.response)!=null&&t.ok?r.resolve(e.data.response.result):r.reject(new Error(((o=e.data.response)==null?void 0:o.error)??"Wallet request failed.")))});const n=Object.freeze({isContractless:!0,request({method:e,params:r}){const s=crypto.randomUUID();return new Promise((t,o)=>{a.set(s,{resolve:t,reject:o}),window.postMessage({source:"contractless-page",id:s,method:e,params:r},window.location.origin)})}});Object.defineProperty(window,"contractless",{value:n,writable:!1,configurable:!1});
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const e of document.querySelectorAll('link[rel="modulepreload"]'))i(e);new MutationObserver(e=>{for(const r of e)if(r.type==="childList")for(const o of r.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&i(o)}).observe(document,{childList:!0,subtree:!0});function s(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),e.crossOrigin==="use-credentials"?r.credentials="include":e.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function i(e){if(e.ep)return;e.ep=!0;const r=s(e);fetch(e.href,r)}})();
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
const o=document.createElement("script");o.src=chrome.runtime.getURL("provider.js");o.onload=()=>o.remove();(document.head||document.documentElement).appendChild(o);window.addEventListener("message",e=>{var t;e.source!==window||((t=e.data)==null?void 0:t.source)!=="contractless-page"||chrome.runtime.sendMessage({type:"provider-request",method:e.data.method,params:e.data.params},a=>{window.postMessage({source:"contractless-wallet",id:e.data.id,response:a},window.location.origin)})});
|
||||||
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
|
@ -0,0 +1 @@
|
||||||
|
:root{font:16px/1.5 Arial,Helvetica,sans-serif;color:#fff;background:#08131c}*{box-sizing:border-box}body{min-width:320px;min-height:100vh;margin:0;background:#08131c}button,input{font:inherit}button{cursor:pointer}button:disabled{cursor:wait;opacity:.72}.site-header{display:flex;align-items:center;justify-content:space-between;min-height:76px;padding:0 32px;border-bottom:1px solid #2e4757;background:#0d1d28}.brand{display:flex;align-items:center;gap:12px}.brand img{width:39px;filter:brightness(0) invert(1)}.brand div{display:grid}.brand strong{font-size:18px}.brand span,.local-page{color:#b9cbd6;font-size:13px}main{display:grid;place-items:start center;padding:54px 20px}.import-panel,.success-panel{width:min(100%,500px);padding:34px;border:1px solid #385666;background:#10222e}.eyebrow{color:#58d5e7;font-size:12px;font-weight:700;text-transform:uppercase}h1{margin:7px 0 10px;font-size:31px;line-height:1.15;letter-spacing:0}.intro,.success-panel p{margin:0 0 26px;color:#c8d6de}form{display:grid}label{margin:21px 0 8px;font-size:13px;font-weight:700}.visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}.file-picker{display:flex;align-items:center;gap:14px;min-height:78px;margin-top:8px;padding:13px;border:1px solid #5ac9de;background:#132d3b;cursor:pointer}.file-picker>span:last-child{display:grid;gap:3px}.file-picker small{color:#afc6d1;font-weight:400}.file-icon{display:grid;place-items:center;width:44px;height:44px;border:1px solid #62d7e9;color:#62d7e9;font-size:25px}.input-row{display:grid;grid-template-columns:1fr auto}.input-row input{min-width:0;height:50px;padding:0 14px;border:1px solid #5a7180;border-right:0;background:#08151e;color:#fff}.input-row button{width:68px;border:1px solid #5a7180;background:#173345;color:#fff}.security-note,.address-result{display:grid;gap:4px;margin-top:20px;padding:13px 14px;border-left:3px solid #57d0df;background:#0a1922}.security-note span,.address-result span{color:#b7cad4;font-size:13px}.address-result strong{overflow-wrap:anywhere}.primary{min-height:50px;margin-top:24px;border:1px solid #67d5e4;background:#236eaa;color:#fff;font-weight:700}#notice{margin:14px 0 0;padding:10px 12px;border-left:3px solid #ff7168;background:#321c20;color:#ffc0bb;font-size:13px}#notice:empty{display:none}.success-mark{display:grid;place-items:center;width:58px;height:58px;margin-bottom:25px;border:2px solid #62d7e9;border-radius:50%;color:#62d7e9;font-size:31px;font-weight:700}.completion-message{margin-top:24px!important}@media(max-width:560px){.site-header{padding:0 18px}.local-page{display:none}main{padding:24px 12px}.import-panel,.success-panel{padding:24px 20px}h1{font-size:27px}}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
*{box-sizing:border-box}body{max-width:760px;margin:0 auto;padding:34px;color:#111820;font:14px/1.45 Arial,Helvetica,sans-serif}header{display:flex;align-items:center;gap:15px;padding-bottom:18px;border-bottom:3px solid #2167a5}header img{width:58px}h1{margin:0;font-size:24px}header p{margin:2px 0 0;color:#5c6972}.warning{margin:22px 0;border:2px solid #9b1c1c;padding:12px;color:#7a1111;font-weight:700}section{margin-top:18px}h2{margin:0 0 7px;color:#42515b;font-size:12px;text-transform:uppercase}pre{margin:0;overflow-wrap:anywhere;white-space:pre-wrap;border:1px solid #aebac2;padding:11px;font:10px/1.35 Consolas,monospace}.print-error{color:#9b1c1c;font-weight:700}@media print{body{max-width:none;padding:0}}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const e of document.querySelectorAll('link[rel="modulepreload"]'))i(e);new MutationObserver(e=>{for(const r of e)if(r.type==="childList")for(const o of r.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&i(o)}).observe(document,{childList:!0,subtree:!0});function s(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),e.crossOrigin==="use-credentials"?r.credentials="include":e.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function i(e){if(e.ep)return;e.ep=!0;const r=s(e);fetch(e.href,r)}})();
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
const o=document.createElement("script");o.src=chrome.runtime.getURL("provider.js");o.onload=()=>o.remove();(document.head||document.documentElement).appendChild(o);window.addEventListener("message",e=>{var t;e.source!==window||((t=e.data)==null?void 0:t.source)!=="contractless-page"||chrome.runtime.sendMessage({type:"provider-request",method:e.data.method,params:e.data.params},a=>{window.postMessage({source:"contractless-wallet",id:e.data.id,response:a},window.location.origin)})});
|
||||||
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 707 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
|
@ -0,0 +1,70 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Import Contractless Wallet</title>
|
||||||
|
<script type="module" crossorigin src="/import-wallet.js"></script>
|
||||||
|
<link rel="modulepreload" crossorigin href="/chunks/modulepreload-polyfill-B5Qt9EMX.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/import-wallet-D9ROrgVZ.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="brand">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><span>Browser Wallet</span></div>
|
||||||
|
</div>
|
||||||
|
<span class="local-page">Local extension page</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section id="import-panel" class="import-panel">
|
||||||
|
<span class="eyebrow">Restore <span id="selected-network">Contractless</span> wallet</span>
|
||||||
|
<h1>Import wallet image</h1>
|
||||||
|
<p class="intro">
|
||||||
|
Select your encrypted Contractless private-key image and enter the
|
||||||
|
encryption key originally used to create it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form id="import-form">
|
||||||
|
<label class="file-picker" for="wallet-image">
|
||||||
|
<span class="file-icon">+</span>
|
||||||
|
<span>
|
||||||
|
<strong id="image-file-name">Choose wallet image</strong>
|
||||||
|
<small>PNG encrypted private-key image</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input id="wallet-image" class="visually-hidden" type="file" accept="image/png" required />
|
||||||
|
|
||||||
|
<label for="image-password">Encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="image-password" type="password" autocomplete="current-password" required />
|
||||||
|
<button id="reveal-password" type="button">Show</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="security-note">
|
||||||
|
<strong>Processed locally by Contractless Wallet</strong>
|
||||||
|
<span>The wallet image and encryption key are not sent to the website you were visiting.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="import-button" class="primary" type="submit">Import wallet</button>
|
||||||
|
<p id="notice" role="alert" aria-live="polite"></p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="success-panel" class="success-panel" hidden>
|
||||||
|
<span class="success-mark" aria-hidden="true">✓</span>
|
||||||
|
<span class="eyebrow">Import complete</span>
|
||||||
|
<h1>Wallet imported successfully</h1>
|
||||||
|
<p>The imported wallet is unlocked and is now the active Contractless address.</p>
|
||||||
|
<div class="address-result">
|
||||||
|
<span>Active wallet</span>
|
||||||
|
<strong id="imported-address"></strong>
|
||||||
|
</div>
|
||||||
|
<p class="completion-message">You may close this page and reopen the Contractless Wallet extension.</p>
|
||||||
|
<button id="close-page" class="primary" type="button">Close page</button>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
import"./chunks/modulepreload-polyfill-B5Qt9EMX.js";const a=t=>document.getElementById(t),u=a("import-form"),l=a("wallet-image"),r=a("image-password"),s=a("import-button"),i=a("notice");m("status").then(t=>{const e=t;a("selected-network").textContent=e.networkName}).catch(t=>{i.textContent=d(t)});function w(t){let e="";for(let o=0;o<t.length;o+=32768)e+=String.fromCharCode(...t.subarray(o,o+32768));return btoa(e)}function d(t){return t instanceof Error?t.message:String(t)}async function m(t,e={}){const n=await chrome.runtime.sendMessage({type:t,...e});if(!(n!=null&&n.ok))throw new Error((n==null?void 0:n.error)??"Wallet request failed.");return n.result}l.addEventListener("change",()=>{var t,e;a("image-file-name").textContent=((e=(t=l.files)==null?void 0:t[0])==null?void 0:e.name)??"Choose wallet image",i.textContent=""});a("reveal-password").addEventListener("click",t=>{const e=t.currentTarget,n=r.type==="text";r.type=n?"password":"text",e.textContent=n?"Show":"Hide"});u.addEventListener("submit",t=>{t.preventDefault(),(async()=>{var c;const e=(c=l.files)==null?void 0:c[0];if(!e)throw new Error("Choose a wallet image.");if(!r.value.trim())throw new Error("Enter the wallet image encryption key.");i.textContent="",s.disabled=!0,s.textContent="Importing wallet...";const n=w(new Uint8Array(await e.arrayBuffer())),o=await m("import-wallet-image",{imageBase64:n,imagePassword:r.value,password:r.value});r.value="",a("imported-address").textContent=o.address??"Imported wallet",a("import-panel").hidden=!0,a("success-panel").hidden=!1})().catch(e=>{i.textContent=d(e),s.disabled=!1,s.textContent="Import wallet"})});a("close-page").addEventListener("click",()=>window.close());
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Contractless Wallet",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"description": "A local-signing Web3 wallet for Contractless.",
|
||||||
|
"browser_specific_settings": {
|
||||||
|
"gecko": {
|
||||||
|
"id": "wallet@contractless.dev",
|
||||||
|
"strict_min_version": "149.0",
|
||||||
|
"data_collection_permissions": {
|
||||||
|
"required": ["none"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gecko_android": {
|
||||||
|
"strict_min_version": "149.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content_security_policy": {
|
||||||
|
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
||||||
|
},
|
||||||
|
"permissions": ["storage", "alarms", "activeTab", "clipboardWrite"],
|
||||||
|
"host_permissions": ["https://api.contractless.dev/*"],
|
||||||
|
"optional_host_permissions": ["https://*/"],
|
||||||
|
"action": {
|
||||||
|
"default_popup": "popup.html",
|
||||||
|
"default_title": "Contractless Wallet",
|
||||||
|
"default_icon": {
|
||||||
|
"16": "contractless-logo-16.png",
|
||||||
|
"32": "contractless-logo-32.png",
|
||||||
|
"48": "contractless-logo-48.png",
|
||||||
|
"128": "contractless-logo-128.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"icons": {
|
||||||
|
"16": "contractless-logo-16.png",
|
||||||
|
"32": "contractless-logo-32.png",
|
||||||
|
"48": "contractless-logo-48.png",
|
||||||
|
"128": "contractless-logo-128.png"
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"scripts": ["background.js"],
|
||||||
|
"type": "module"
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"],
|
||||||
|
"js": ["content.js"],
|
||||||
|
"run_at": "document_start"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"web_accessible_resources": [
|
||||||
|
{
|
||||||
|
"resources": ["provider.js"],
|
||||||
|
"matches": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,654 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Contractless Wallet</title>
|
||||||
|
<script type="module" crossorigin src="/popup.js"></script>
|
||||||
|
<link rel="modulepreload" crossorigin href="/chunks/modulepreload-polyfill-B5Qt9EMX.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/chunks/core-D2IK1U91.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/popup-B3vY8vDf.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="wallet-shell">
|
||||||
|
<section id="loading" class="screen loading-screen">
|
||||||
|
<div class="loading-brand">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="Contractless" />
|
||||||
|
<div class="spinner" aria-label="Loading"></div>
|
||||||
|
</div>
|
||||||
|
<p>Opening wallet</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="create-screen" class="screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small>Browser Wallet</small></div>
|
||||||
|
<span>1 of 3</span>
|
||||||
|
</header>
|
||||||
|
<div class="screen-content">
|
||||||
|
<div class="network-choice" role="group" aria-label="Wallet network">
|
||||||
|
<button type="button" data-select-network="testnet">Testnet</button>
|
||||||
|
<button type="button" data-select-network="mainnet">Mainnet</button>
|
||||||
|
</div>
|
||||||
|
<span class="eyebrow">New wallet</span>
|
||||||
|
<h1>Create your wallet</h1>
|
||||||
|
<p class="intro">Choose an encryption key used to unlock and recover this wallet.</p>
|
||||||
|
|
||||||
|
<label for="new-password">Encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="new-password" type="password" autocomplete="new-password" />
|
||||||
|
<button class="reveal" type="button" data-input="new-password">Show</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>This key is not stored for you.</strong>
|
||||||
|
<span>You will verify it before the wallet is created.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="continue-create" class="primary" type="button">Continue <span>→</span></button>
|
||||||
|
<div class="divider"><span>or restore an existing wallet</span></div>
|
||||||
|
<button class="secondary navigation" data-screen="image-screen" type="button">
|
||||||
|
Import wallet from image
|
||||||
|
</button>
|
||||||
|
<button class="secondary navigation" data-screen="private-screen" type="button">
|
||||||
|
Import wallet from private key
|
||||||
|
</button>
|
||||||
|
<button id="cancel-wallet-add" class="text-button" type="button" hidden>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="image-screen" class="screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small>Browser Wallet</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
<form id="import-image-form" class="screen-content">
|
||||||
|
<span class="eyebrow">Existing wallet</span>
|
||||||
|
<h1>Import wallet image</h1>
|
||||||
|
<p class="intro">
|
||||||
|
Select your encrypted private-key image and enter the encryption key
|
||||||
|
originally used to create it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="file-heading" for="wallet-image">Wallet image</label>
|
||||||
|
<label class="file-picker" for="wallet-image">
|
||||||
|
<span class="file-icon">+</span>
|
||||||
|
<span><strong id="image-file-name">Choose wallet image</strong><small>PNG encrypted private-key image</small></span>
|
||||||
|
</label>
|
||||||
|
<input id="wallet-image" class="visually-hidden" type="file" accept="image/png" />
|
||||||
|
|
||||||
|
<label for="image-password">Encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="image-password" type="password" autocomplete="current-password" />
|
||||||
|
<button class="reveal" type="button" data-input="image-password">Show</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="import-image" class="primary" type="submit">Import wallet</button>
|
||||||
|
<button class="text-button navigation" data-screen="create-screen" type="button">Back</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="private-screen" class="screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small>Browser Wallet</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
<div class="screen-content compact">
|
||||||
|
<span class="eyebrow">Existing wallet</span>
|
||||||
|
<h1>Import private key</h1>
|
||||||
|
<p class="intro">
|
||||||
|
Paste the private key, then choose the encryption key that will protect
|
||||||
|
this wallet from now on.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label for="private-key">Private key</label>
|
||||||
|
<textarea id="private-key" rows="4" placeholder="Paste private key"></textarea>
|
||||||
|
|
||||||
|
<label for="private-password">New encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="private-password" type="password" autocomplete="new-password" />
|
||||||
|
<button class="reveal" type="button" data-input="private-password">Show</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note compact-note">
|
||||||
|
<strong>This key protects the new wallet image.</strong>
|
||||||
|
<span>Store it securely and separately from the image.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="import-private" class="primary" type="button">Import and create wallet</button>
|
||||||
|
<button class="text-button navigation" data-screen="create-screen" type="button">Back</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="verify-screen" class="screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small>Browser Wallet</small></div>
|
||||||
|
<span>2 of 3</span>
|
||||||
|
</header>
|
||||||
|
<div class="screen-content">
|
||||||
|
<span class="eyebrow">Recovery</span>
|
||||||
|
<h1>Verify your encryption key</h1>
|
||||||
|
<p class="intro">
|
||||||
|
Enter the same key again. Keep it somewhere secure and separate from
|
||||||
|
your wallet image.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label for="verify-password">Re-enter encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="verify-password" type="password" autocomplete="new-password" />
|
||||||
|
<button class="reveal" type="button" data-input="verify-password">Show</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="warning">
|
||||||
|
<strong>There is no password reset.</strong>
|
||||||
|
Losing this key means the wallet image cannot restore your wallet.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="create-wallet" class="primary" type="button">Create wallet <span>→</span></button>
|
||||||
|
<button class="text-button navigation" data-screen="create-screen" type="button">Back</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="backup-screen" class="screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small>Browser Wallet</small></div>
|
||||||
|
<span>3 of 3</span>
|
||||||
|
</header>
|
||||||
|
<div class="screen-content backup-content">
|
||||||
|
<span class="success-mark">✓</span>
|
||||||
|
<h1>Your wallet is ready</h1>
|
||||||
|
<p class="intro">
|
||||||
|
Save the private-key image now. You need both this image and your
|
||||||
|
encryption key to recover the wallet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="image-preview">
|
||||||
|
<img id="wallet-image-preview" alt="Encrypted private-key wallet image" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="save-image" class="primary" type="button">↓ Save wallet image</button>
|
||||||
|
<label class="confirmation">
|
||||||
|
<input id="backup-confirmed" type="checkbox" disabled />
|
||||||
|
<span>I saved the image and stored my encryption key separately.</span>
|
||||||
|
</label>
|
||||||
|
<button id="open-wallet" class="secondary" type="button" disabled>Open wallet</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="locked-screen" class="screen" hidden>
|
||||||
|
<form id="unlock-form" class="centered-content">
|
||||||
|
<img class="large-logo" src="/assets/contractless-logo-BgfMS-Kf.png" alt="Contractless" />
|
||||||
|
<div class="network-choice" role="group" aria-label="Wallet network">
|
||||||
|
<button type="button" data-select-network="testnet">Testnet</button>
|
||||||
|
<button type="button" data-select-network="mainnet">Mainnet</button>
|
||||||
|
</div>
|
||||||
|
<h1>Unlock wallet</h1>
|
||||||
|
<p class="intro">Enter your encryption key to continue.</p>
|
||||||
|
<label for="unlock-password">Encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="unlock-password" type="password" autocomplete="current-password" />
|
||||||
|
<button class="reveal" type="button" data-input="unlock-password">Show</button>
|
||||||
|
</div>
|
||||||
|
<button id="unlock-wallet" class="primary" type="submit">Unlock wallet</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="provider-approval-screen" class="screen provider-approval-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small>Wallet request</small></div>
|
||||||
|
<span>Review</span>
|
||||||
|
</header>
|
||||||
|
<div class="screen-content">
|
||||||
|
<div class="approval-context">
|
||||||
|
<span class="eyebrow" id="provider-approval-origin"></span>
|
||||||
|
<span id="provider-approval-risk" class="risk-label"></span>
|
||||||
|
</div>
|
||||||
|
<h1 id="provider-approval-title">Review request</h1>
|
||||||
|
<p class="intro" id="provider-approval-intro">Review this request before approving it.</p>
|
||||||
|
<dl id="provider-approval-details" class="approval-details"></dl>
|
||||||
|
</div>
|
||||||
|
<div class="approval-actions">
|
||||||
|
<button id="reject-provider-approval" class="secondary" type="button">Reject</button>
|
||||||
|
<button id="approve-provider-approval" class="primary" type="button">Approve</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="dashboard-screen" class="screen" hidden>
|
||||||
|
<header class="wallet-header dashboard-header">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small data-network-name>Testnet</small></div>
|
||||||
|
<button
|
||||||
|
id="account-menu-button"
|
||||||
|
class="account-button"
|
||||||
|
type="button"
|
||||||
|
title="Wallets and settings"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="account-drawer"
|
||||||
|
>
|
||||||
|
<span class="network-dot"></span>
|
||||||
|
<span id="wallet-address-short">Account</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="assets-dashboard">
|
||||||
|
<nav class="wallet-actions" aria-label="Wallet actions">
|
||||||
|
<button id="open-send" type="button">
|
||||||
|
<span class="action-icon">↗</span>
|
||||||
|
<span>Send</span>
|
||||||
|
</button>
|
||||||
|
<button id="open-receive" type="button">
|
||||||
|
<span class="action-icon">↙</span>
|
||||||
|
<span>Receive</span>
|
||||||
|
</button>
|
||||||
|
<button id="open-nfts" type="button">
|
||||||
|
<span class="action-icon">#</span>
|
||||||
|
<span>NFTs/RWAs</span>
|
||||||
|
</button>
|
||||||
|
<button id="open-activity" type="button">
|
||||||
|
<span class="action-icon">◷</span>
|
||||||
|
<span>Activity</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section class="portfolio-balance" aria-labelledby="portfolio-title">
|
||||||
|
<span id="portfolio-title">Available balance</span>
|
||||||
|
<strong><span id="base-balance">0.00000000</span> <small data-network-symbol>CLTC</small></strong>
|
||||||
|
<p id="wallet-address"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="asset-ledger" aria-labelledby="assets-title">
|
||||||
|
<div class="asset-ledger-heading">
|
||||||
|
<h1 id="assets-title">Assets</h1>
|
||||||
|
<button id="refresh-balances" type="button" title="Refresh balances" aria-label="Refresh balances">↻</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="asset-filter" role="group" aria-label="Token visibility">
|
||||||
|
<button class="active" data-token-filter="held" type="button">Held tokens</button>
|
||||||
|
<button data-token-filter="all" type="button">All tokens</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="asset-row base-asset">
|
||||||
|
<div class="asset-symbol contractless-symbol">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
</div>
|
||||||
|
<div class="asset-name">
|
||||||
|
<strong id="base-asset-name">Contractless Testnet</strong>
|
||||||
|
<span data-network-symbol>CLTC</span>
|
||||||
|
</div>
|
||||||
|
<div class="asset-value">
|
||||||
|
<strong id="base-asset-balance">0.00000000</strong>
|
||||||
|
<span data-network-symbol>CLTC</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="token-list" class="token-list">
|
||||||
|
<div class="empty-assets">
|
||||||
|
<strong>Loading token balances...</strong>
|
||||||
|
<span>Balances are retrieved from the selected Contractless API.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="nfts-screen" class="screen nfts-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-nfts" class="header-back" type="button" title="Back to assets" aria-label="Back to assets">←</button>
|
||||||
|
<div><strong>NFTs and RWAs</strong><small>Assets owned by this wallet</small></div>
|
||||||
|
<button id="refresh-nfts" class="header-refresh" type="button" title="Refresh assets" aria-label="Refresh assets">↻</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="nfts-content">
|
||||||
|
<div class="nfts-summary">
|
||||||
|
<span id="nfts-count">Loading ownership...</span>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</div>
|
||||||
|
<div id="nfts-list" class="nfts-list"></div>
|
||||||
|
<div id="nfts-pagination" class="nfts-pagination" hidden>
|
||||||
|
<button id="nfts-previous" type="button" aria-label="Previous NFT page">←</button>
|
||||||
|
<span>Page <strong id="nfts-page">1</strong> of <strong id="nfts-pages">1</strong></span>
|
||||||
|
<button id="nfts-next" type="button" aria-label="Next NFT page">→</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="nft-detail-screen" class="screen nft-detail-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-nft-detail" class="header-back" type="button" title="Back to NFTs and RWAs" aria-label="Back to NFTs and RWAs">←</button>
|
||||||
|
<div><strong id="nft-detail-title">NFT/RWA</strong><small id="nft-detail-kind">On-chain asset</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="nft-detail-content">
|
||||||
|
<div class="nft-detail-media">
|
||||||
|
<img id="nft-detail-image" alt="" />
|
||||||
|
</div>
|
||||||
|
<div class="nft-detail-heading">
|
||||||
|
<div>
|
||||||
|
<span id="nft-detail-badge">NFT</span>
|
||||||
|
<h1 id="nft-detail-name">NFT/RWA</h1>
|
||||||
|
</div>
|
||||||
|
<strong id="nft-detail-ownership"></strong>
|
||||||
|
</div>
|
||||||
|
<p id="nft-detail-description" class="nft-detail-description"></p>
|
||||||
|
<div id="nft-detail-attributes" class="nft-detail-attributes"></div>
|
||||||
|
<dl id="nft-detail-fields" class="nft-detail-fields"></dl>
|
||||||
|
<div class="nft-detail-actions">
|
||||||
|
<button id="transfer-nft" class="primary" type="button">Transfer asset</button>
|
||||||
|
</div>
|
||||||
|
<section class="nft-provenance">
|
||||||
|
<h2>Provenance</h2>
|
||||||
|
<div id="nft-detail-history"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="send-screen" class="screen send-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-send" class="header-back" type="button" title="Back to assets" aria-label="Back to assets">←</button>
|
||||||
|
<div><strong>Send</strong><small>Create a transfer</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form id="send-form" class="send-content">
|
||||||
|
<div>
|
||||||
|
<label for="send-asset">Asset</label>
|
||||||
|
<select id="send-asset"></select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="send-recipient">Receiving address</label>
|
||||||
|
<input id="send-recipient" type="text" autocomplete="off" spellcheck="false" placeholder="Wallet or vanity address" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="send-amount">Amount</label>
|
||||||
|
<div class="send-amount-row">
|
||||||
|
<input id="send-amount" type="text" inputmode="decimal" autocomplete="off" placeholder="0.00000000" />
|
||||||
|
<span id="send-amount-symbol" data-network-symbol>CLTC</span>
|
||||||
|
</div>
|
||||||
|
<span id="send-available" class="field-note">Available: 0.00000000 CLTC</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="send-fee">Transaction fee</label>
|
||||||
|
<div class="send-amount-row">
|
||||||
|
<input id="send-fee" type="text" inputmode="decimal" autocomplete="off" placeholder="0.00000000" />
|
||||||
|
<span data-network-symbol>CLTC</span>
|
||||||
|
</div>
|
||||||
|
<span id="send-fee-note" class="field-note">Minimum fee: 1% of the transfer amount.</span>
|
||||||
|
</div>
|
||||||
|
<button class="primary send-continue" type="submit">Review transfer</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="send-review-screen" class="screen send-review-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="edit-send" class="header-back" type="button" title="Edit transfer" aria-label="Edit transfer">←</button>
|
||||||
|
<div><strong>Review transfer</strong><small>Confirm before signing</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="send-review-content">
|
||||||
|
<div class="send-review-amount">
|
||||||
|
<span>You are sending</span>
|
||||||
|
<strong><span id="review-amount">0.00000000</span> <small id="review-asset">CLTC</small></strong>
|
||||||
|
</div>
|
||||||
|
<dl class="send-review-details">
|
||||||
|
<div><dt>From</dt><dd id="review-sender"></dd></div>
|
||||||
|
<div><dt>To</dt><dd id="review-recipient"></dd></div>
|
||||||
|
<div><dt>Resolved address</dt><dd id="review-resolved-recipient"></dd></div>
|
||||||
|
<div><dt>Transaction fee</dt><dd><span id="review-fee"></span> <span data-network-symbol>CLTC</span></dd></div>
|
||||||
|
<div><dt>Total deducted</dt><dd id="review-total"></dd></div>
|
||||||
|
</dl>
|
||||||
|
<p class="signing-notice">Your private key remains inside this wallet. The transaction is signed locally and only the completed transaction is sent to the API.</p>
|
||||||
|
<button id="sign-broadcast-transfer" class="primary" type="button">Sign and broadcast</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="send-success-screen" class="screen send-success-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<div><strong>Transfer broadcast</strong><small>Submitted to Contractless</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
<div class="send-success-content">
|
||||||
|
<span class="success-mark">✓</span>
|
||||||
|
<h1>Transaction sent</h1>
|
||||||
|
<p>The signed transfer was accepted for broadcast.</p>
|
||||||
|
<div class="send-txid">
|
||||||
|
<span>Transaction ID</span>
|
||||||
|
<strong id="send-result-txid"></strong>
|
||||||
|
</div>
|
||||||
|
<button id="copy-send-txid" class="secondary" type="button">Copy transaction ID</button>
|
||||||
|
<button id="finish-send" class="primary" type="button">Return to assets</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="activity-screen" class="screen activity-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-activity" class="header-back" type="button" title="Back to assets" aria-label="Back to assets">←</button>
|
||||||
|
<div><strong>Activity</strong><small>Latest 25 transactions</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="activity-content">
|
||||||
|
<div class="activity-summary">
|
||||||
|
<span>Newest transactions first</span>
|
||||||
|
<div>
|
||||||
|
<strong id="activity-count">0 shown</strong>
|
||||||
|
<button id="refresh-activity" type="button" title="Refresh activity" aria-label="Refresh activity">↻</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="activity-list" class="activity-list"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="transaction-screen" class="screen transaction-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-transaction" class="header-back" type="button" title="Back to activity" aria-label="Back to activity">←</button>
|
||||||
|
<div><strong id="transaction-title">Transaction</strong><small>Complete on-chain details</small></div>
|
||||||
|
<span id="transaction-header-status"></span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="transaction-content">
|
||||||
|
<section class="transaction-identity">
|
||||||
|
<div>
|
||||||
|
<span>Transaction ID</span>
|
||||||
|
<strong id="transaction-full-txid"></strong>
|
||||||
|
</div>
|
||||||
|
<button id="copy-transaction-txid" type="button">Copy TXID</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="transaction-overview" class="transaction-fields"></div>
|
||||||
|
|
||||||
|
<section class="transaction-section">
|
||||||
|
<h2>Transaction Fields</h2>
|
||||||
|
<div id="transaction-fields" class="transaction-fields"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="miner-earnings-section" class="transaction-section" hidden>
|
||||||
|
<h2>Miner Earnings</h2>
|
||||||
|
<div id="transaction-miner-earnings" class="transaction-fields"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<details class="transaction-technical">
|
||||||
|
<summary>Technical Details</summary>
|
||||||
|
<div id="transaction-technical-fields" class="transaction-fields"></div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="receive-screen" class="screen receive-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-receive" class="header-back" type="button" title="Back to assets" aria-label="Back to assets">←</button>
|
||||||
|
<div><strong>Receive</strong><small>Contractless wallet address</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="receive-content">
|
||||||
|
<span class="receive-label">Receive <span data-network-symbol>CLTC</span> and Contractless assets</span>
|
||||||
|
<h1>Share your address</h1>
|
||||||
|
<p>
|
||||||
|
Scan the QR code or copy the address below. Only send assets issued on
|
||||||
|
the Contractless <span data-network-name>Testnet</span> network to this address.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="qr-frame">
|
||||||
|
<canvas id="receive-qr" width="220" height="220" aria-label="Wallet address QR code"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="receive-address">
|
||||||
|
<span id="receive-address-label">Wallet address</span>
|
||||||
|
<strong id="receive-address-value"></strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="copy-receive-address" class="primary receive-copy" type="button">
|
||||||
|
Copy address
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="drawer-backdrop" class="drawer-backdrop" hidden></div>
|
||||||
|
<aside id="account-drawer" class="account-drawer" aria-hidden="true">
|
||||||
|
<header class="drawer-header">
|
||||||
|
<div>
|
||||||
|
<strong>Wallets</strong>
|
||||||
|
<span>Accounts and settings</span>
|
||||||
|
</div>
|
||||||
|
<button id="close-account-drawer" type="button" title="Close menu" aria-label="Close menu">×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="drawer-section">
|
||||||
|
<span class="drawer-label">Network</span>
|
||||||
|
<div class="network-choice drawer-network" role="group" aria-label="Wallet network">
|
||||||
|
<button type="button" data-select-network="testnet">Testnet</button>
|
||||||
|
<button type="button" data-select-network="mainnet">Mainnet</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="drawer-section">
|
||||||
|
<span class="drawer-label">Known addresses</span>
|
||||||
|
<div id="known-addresses" class="known-addresses"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav class="drawer-navigation" aria-label="Wallet menu">
|
||||||
|
<button id="menu-create-wallet" type="button">
|
||||||
|
<span class="menu-icon">+</span>
|
||||||
|
<span><strong>Create another address</strong><small>Generate a new Contractless wallet</small></span>
|
||||||
|
<span class="menu-arrow">›</span>
|
||||||
|
</button>
|
||||||
|
<button id="menu-import-wallet" type="button">
|
||||||
|
<span class="menu-icon">↓</span>
|
||||||
|
<span><strong>Import another address</strong><small>Use an image or private key</small></span>
|
||||||
|
<span class="menu-arrow">›</span>
|
||||||
|
</button>
|
||||||
|
<button id="menu-settings" type="button">
|
||||||
|
<span class="menu-icon">⚙</span>
|
||||||
|
<span><strong>Settings</strong><small>API endpoint and lock timeout</small></span>
|
||||||
|
<span class="menu-arrow">›</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<button id="drawer-lock-wallet" class="drawer-lock" type="button">
|
||||||
|
<span>⌁</span>
|
||||||
|
Lock wallet
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section id="settings-screen" class="screen settings-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-settings" class="header-back" type="button" title="Back to assets" aria-label="Back to assets">←</button>
|
||||||
|
<div><strong>Settings</strong><small>Wallet preferences and backup</small></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="settings-content">
|
||||||
|
<section class="settings-group">
|
||||||
|
<div class="settings-heading">
|
||||||
|
<strong>Security</strong>
|
||||||
|
<span>Control how long the active wallet stays unlocked.</span>
|
||||||
|
</div>
|
||||||
|
<label for="settings-lock-timeout">Automatic lock</label>
|
||||||
|
<select id="settings-lock-timeout">
|
||||||
|
<option value="15">15 minutes</option>
|
||||||
|
<option value="30">30 minutes</option>
|
||||||
|
<option value="60">60 minutes</option>
|
||||||
|
<option value="240">4 hours</option>
|
||||||
|
<option value="720">12 hours</option>
|
||||||
|
<option value="1440">24 hours</option>
|
||||||
|
</select>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-group">
|
||||||
|
<div class="settings-heading">
|
||||||
|
<strong>Contractless API</strong>
|
||||||
|
<span>Choose the API used for lookups and transaction broadcasts.</span>
|
||||||
|
</div>
|
||||||
|
<label for="settings-testnet-api-url">Testnet API URL</label>
|
||||||
|
<input id="settings-testnet-api-url" type="url" value="https://api.contractless.dev" />
|
||||||
|
<label for="settings-mainnet-api-url">Mainnet API URL</label>
|
||||||
|
<input id="settings-mainnet-api-url" type="url" placeholder="Not configured" />
|
||||||
|
<span class="settings-help">Mainnet wallets can be created and backed up before a mainnet API is configured.</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button id="save-wallet-settings" class="primary settings-save" type="button">Save settings</button>
|
||||||
|
|
||||||
|
<section class="settings-group backup-settings">
|
||||||
|
<div class="settings-heading">
|
||||||
|
<strong>Active wallet backup</strong>
|
||||||
|
<span>These actions require the active wallet encryption key.</span>
|
||||||
|
</div>
|
||||||
|
<button class="settings-action" data-backup-action="image" type="button">
|
||||||
|
<span class="settings-action-icon">▧</span>
|
||||||
|
<span><strong>Save wallet image</strong><small>Download an encrypted PNG backup</small></span>
|
||||||
|
<span>›</span>
|
||||||
|
</button>
|
||||||
|
<button class="settings-action" data-backup-action="private" type="button">
|
||||||
|
<span class="settings-action-icon">⌘</span>
|
||||||
|
<span><strong>View private key</strong><small>Reveal and copy the active private key</small></span>
|
||||||
|
<span>›</span>
|
||||||
|
</button>
|
||||||
|
<button class="settings-action" data-backup-action="print" type="button">
|
||||||
|
<span class="settings-action-icon">▤</span>
|
||||||
|
<span><strong>Print wallet backup</strong><small>Private key and encryption key</small></span>
|
||||||
|
<span>›</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="private-key-result" class="private-key-result" hidden>
|
||||||
|
<div>
|
||||||
|
<strong>Private key</strong>
|
||||||
|
<button id="clear-private-key" type="button" title="Clear private key" aria-label="Clear private key">×</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="revealed-private-key" readonly></textarea>
|
||||||
|
<button id="copy-private-key" class="secondary" type="button">Copy private key</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="backup-key-backdrop" class="modal-backdrop" hidden></div>
|
||||||
|
<section id="backup-key-modal" class="backup-key-modal" hidden aria-modal="true" role="dialog" aria-labelledby="backup-key-title">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<strong id="backup-key-title">Confirm encryption key</strong>
|
||||||
|
<span>Required to decrypt this wallet locally.</span>
|
||||||
|
</div>
|
||||||
|
<button id="close-backup-key" type="button" title="Cancel" aria-label="Cancel">×</button>
|
||||||
|
</header>
|
||||||
|
<div>
|
||||||
|
<label for="backup-key-input">Encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="backup-key-input" type="password" autocomplete="current-password" />
|
||||||
|
<button class="reveal" type="button" data-input="backup-key-input">Show</button>
|
||||||
|
</div>
|
||||||
|
<button id="confirm-backup-key" class="primary" type="button">Continue</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p id="notice" role="alert" aria-live="polite"></p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Contractless Wallet Backup</title>
|
||||||
|
<script type="module" crossorigin src="/print.js"></script>
|
||||||
|
<link rel="modulepreload" crossorigin href="/chunks/modulepreload-polyfill-B5Qt9EMX.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/print-DTb91-bp.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
<div>
|
||||||
|
<h1>Contractless Wallet Backup</h1>
|
||||||
|
<p>Private recovery information</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p class="warning">
|
||||||
|
Keep this page private. Anyone with the private key and encryption key can
|
||||||
|
control this wallet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Wallet</h2>
|
||||||
|
<pre id="wallet-label"></pre>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Address</h2>
|
||||||
|
<pre id="wallet-address"></pre>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Private Key</h2>
|
||||||
|
<pre id="wallet-private-key"></pre>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Encryption Key</h2>
|
||||||
|
<pre id="wallet-encryption-key"></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p id="print-error" class="print-error"></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
import"./chunks/modulepreload-polyfill-B5Qt9EMX.js";async function a(){const e=new URLSearchParams(location.search).get("id");if(!e)throw new Error("Missing printable wallet backup.");const n=`print-backup:${e}`,o=await chrome.storage.session.get(n);await chrome.storage.session.remove(n);const t=o[n];if(!t)throw new Error("The printable wallet backup has expired.");document.getElementById("wallet-label").textContent=t.label,document.getElementById("wallet-address").textContent=t.address,document.getElementById("wallet-private-key").textContent=t.privateKey,document.getElementById("wallet-encryption-key").textContent=t.encryptionKey,setTimeout(()=>{window.focus(),window.print()},150)}a().catch(e=>{document.getElementById("print-error").textContent=e instanceof Error?e.message:String(e)});
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
const a=new Map;window.addEventListener("message",e=>{var s,t,o;if(e.source!==window||((s=e.data)==null?void 0:s.source)!=="contractless-wallet")return;const r=a.get(e.data.id);r&&(a.delete(e.data.id),(t=e.data.response)!=null&&t.ok?r.resolve(e.data.response.result):r.reject(new Error(((o=e.data.response)==null?void 0:o.error)??"Wallet request failed.")))});const n=Object.freeze({isContractless:!0,request({method:e,params:r}){const s=crypto.randomUUID();return new Promise((t,o)=>{a.set(s,{resolve:t,reject:o}),window.postMessage({source:"contractless-page",id:s,method:e,params:r},window.location.origin)})}});Object.defineProperty(window,"contractless",{value:n,writable:!1,configurable:!1});
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Contractless Wallet",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"description": "A local-signing Web3 wallet for Contractless.",
|
||||||
|
"content_security_policy": {
|
||||||
|
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
||||||
|
},
|
||||||
|
"permissions": ["storage", "alarms", "activeTab", "clipboardWrite"],
|
||||||
|
"host_permissions": ["https://api.contractless.dev/*"],
|
||||||
|
"optional_host_permissions": ["https://*/"],
|
||||||
|
"action": {
|
||||||
|
"default_popup": "popup.html",
|
||||||
|
"default_title": "Contractless Wallet",
|
||||||
|
"default_icon": {
|
||||||
|
"16": "contractless-logo.png",
|
||||||
|
"32": "contractless-logo.png",
|
||||||
|
"48": "contractless-logo.png",
|
||||||
|
"128": "contractless-logo.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"icons": {
|
||||||
|
"16": "contractless-logo.png",
|
||||||
|
"32": "contractless-logo.png",
|
||||||
|
"48": "contractless-logo.png",
|
||||||
|
"128": "contractless-logo.png"
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"service_worker": "background.js",
|
||||||
|
"type": "module"
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"],
|
||||||
|
"js": ["content.js"],
|
||||||
|
"run_at": "document_start"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"web_accessible_resources": [
|
||||||
|
{
|
||||||
|
"resources": ["provider.js"],
|
||||||
|
"matches": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,635 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Contractless Wallet</title>
|
||||||
|
<script type="module" crossorigin src="/popup.js"></script>
|
||||||
|
<link rel="modulepreload" crossorigin href="/chunks/modulepreload-polyfill-B5Qt9EMX.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/chunks/core-CvwXoH-U.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/popup-C5oUQoSX.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="wallet-shell">
|
||||||
|
<section id="loading" class="screen loading-screen">
|
||||||
|
<div class="loading-brand">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="Contractless" />
|
||||||
|
<div class="spinner" aria-label="Loading"></div>
|
||||||
|
</div>
|
||||||
|
<p>Opening wallet</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="create-screen" class="screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small>Browser Wallet</small></div>
|
||||||
|
<span>1 of 3</span>
|
||||||
|
</header>
|
||||||
|
<div class="screen-content">
|
||||||
|
<span class="eyebrow">New wallet</span>
|
||||||
|
<h1>Create your wallet</h1>
|
||||||
|
<p class="intro">Choose an encryption key used to unlock and recover this wallet.</p>
|
||||||
|
|
||||||
|
<label for="new-password">Encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="new-password" type="password" autocomplete="new-password" />
|
||||||
|
<button class="reveal" type="button" data-input="new-password">Show</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>This key is not stored for you.</strong>
|
||||||
|
<span>You will verify it before the wallet is created.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="continue-create" class="primary" type="button">Continue <span>→</span></button>
|
||||||
|
<div class="divider"><span>or restore an existing wallet</span></div>
|
||||||
|
<button class="secondary navigation" data-screen="image-screen" type="button">
|
||||||
|
Import wallet from image
|
||||||
|
</button>
|
||||||
|
<button class="secondary navigation" data-screen="private-screen" type="button">
|
||||||
|
Import wallet from private key
|
||||||
|
</button>
|
||||||
|
<button id="cancel-wallet-add" class="text-button" type="button" hidden>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="image-screen" class="screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small>Browser Wallet</small></div>
|
||||||
|
<span>Import</span>
|
||||||
|
</header>
|
||||||
|
<div class="screen-content">
|
||||||
|
<span class="eyebrow">Existing wallet</span>
|
||||||
|
<h1>Import wallet image</h1>
|
||||||
|
<p class="intro">
|
||||||
|
Select your encrypted private-key image and enter the encryption key
|
||||||
|
originally used to create it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label for="image-password">Encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="image-password" type="password" autocomplete="current-password" />
|
||||||
|
<button class="reveal" type="button" data-input="image-password">Show</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="file-heading" for="wallet-image">Wallet image</label>
|
||||||
|
<label class="file-picker" for="wallet-image">
|
||||||
|
<span class="file-icon">+</span>
|
||||||
|
<span><strong id="image-file-name">Choose wallet image</strong><small>PNG encrypted private-key image</small></span>
|
||||||
|
</label>
|
||||||
|
<input id="wallet-image" class="visually-hidden" type="file" accept="image/png" />
|
||||||
|
|
||||||
|
<button id="import-image" class="primary" type="button">Import wallet</button>
|
||||||
|
<button class="text-button navigation" data-screen="create-screen" type="button">Back</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="private-screen" class="screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small>Browser Wallet</small></div>
|
||||||
|
<span>Import</span>
|
||||||
|
</header>
|
||||||
|
<div class="screen-content compact">
|
||||||
|
<span class="eyebrow">Existing wallet</span>
|
||||||
|
<h1>Import private key</h1>
|
||||||
|
<p class="intro">
|
||||||
|
Paste the private key, then choose the encryption key that will protect
|
||||||
|
this wallet from now on.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label for="private-key">Private key</label>
|
||||||
|
<textarea id="private-key" rows="4" placeholder="Paste private key"></textarea>
|
||||||
|
|
||||||
|
<label for="private-password">New encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="private-password" type="password" autocomplete="new-password" />
|
||||||
|
<button class="reveal" type="button" data-input="private-password">Show</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note compact-note">
|
||||||
|
<strong>This key protects the new wallet image.</strong>
|
||||||
|
<span>Store it securely and separately from the image.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="import-private" class="primary" type="button">Import and create wallet</button>
|
||||||
|
<button class="text-button navigation" data-screen="create-screen" type="button">Back</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="verify-screen" class="screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small>Browser Wallet</small></div>
|
||||||
|
<span>2 of 3</span>
|
||||||
|
</header>
|
||||||
|
<div class="screen-content">
|
||||||
|
<span class="eyebrow">Recovery</span>
|
||||||
|
<h1>Verify your encryption key</h1>
|
||||||
|
<p class="intro">
|
||||||
|
Enter the same key again. Keep it somewhere secure and separate from
|
||||||
|
your wallet image.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label for="verify-password">Re-enter encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="verify-password" type="password" autocomplete="new-password" />
|
||||||
|
<button class="reveal" type="button" data-input="verify-password">Show</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="warning">
|
||||||
|
<strong>There is no password reset.</strong>
|
||||||
|
Losing this key means the wallet image cannot restore your wallet.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="create-wallet" class="primary" type="button">Create wallet <span>→</span></button>
|
||||||
|
<button class="text-button navigation" data-screen="create-screen" type="button">Back</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="backup-screen" class="screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small>Browser Wallet</small></div>
|
||||||
|
<span>3 of 3</span>
|
||||||
|
</header>
|
||||||
|
<div class="screen-content backup-content">
|
||||||
|
<span class="success-mark">✓</span>
|
||||||
|
<h1>Your wallet is ready</h1>
|
||||||
|
<p class="intro">
|
||||||
|
Save the private-key image now. You need both this image and your
|
||||||
|
encryption key to recover the wallet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="image-preview">
|
||||||
|
<img id="wallet-image-preview" alt="Encrypted private-key wallet image" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="save-image" class="primary" type="button">↓ Save wallet image</button>
|
||||||
|
<label class="confirmation">
|
||||||
|
<input id="backup-confirmed" type="checkbox" disabled />
|
||||||
|
<span>I saved the image and stored my encryption key separately.</span>
|
||||||
|
</label>
|
||||||
|
<button id="open-wallet" class="secondary" type="button" disabled>Open wallet</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="locked-screen" class="screen" hidden>
|
||||||
|
<form id="unlock-form" class="centered-content">
|
||||||
|
<img class="large-logo" src="/assets/contractless-logo-BgfMS-Kf.png" alt="Contractless" />
|
||||||
|
<h1>Unlock wallet</h1>
|
||||||
|
<p class="intro">Enter your encryption key to continue.</p>
|
||||||
|
<label for="unlock-password">Encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="unlock-password" type="password" autocomplete="current-password" />
|
||||||
|
<button class="reveal" type="button" data-input="unlock-password">Show</button>
|
||||||
|
</div>
|
||||||
|
<button id="unlock-wallet" class="primary" type="submit">Unlock wallet</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="provider-approval-screen" class="screen provider-approval-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small>Wallet request</small></div>
|
||||||
|
<span>Review</span>
|
||||||
|
</header>
|
||||||
|
<div class="screen-content">
|
||||||
|
<div class="approval-context">
|
||||||
|
<span class="eyebrow" id="provider-approval-origin"></span>
|
||||||
|
<span id="provider-approval-risk" class="risk-label"></span>
|
||||||
|
</div>
|
||||||
|
<h1 id="provider-approval-title">Review request</h1>
|
||||||
|
<p class="intro" id="provider-approval-intro">Review this request before approving it.</p>
|
||||||
|
<dl id="provider-approval-details" class="approval-details"></dl>
|
||||||
|
</div>
|
||||||
|
<div class="approval-actions">
|
||||||
|
<button id="reject-provider-approval" class="secondary" type="button">Reject</button>
|
||||||
|
<button id="approve-provider-approval" class="primary" type="button">Approve</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="dashboard-screen" class="screen" hidden>
|
||||||
|
<header class="wallet-header dashboard-header">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small data-network-name>Testnet</small></div>
|
||||||
|
<button
|
||||||
|
id="account-menu-button"
|
||||||
|
class="account-button"
|
||||||
|
type="button"
|
||||||
|
title="Wallets and settings"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="account-drawer"
|
||||||
|
>
|
||||||
|
<span class="network-dot"></span>
|
||||||
|
<span id="wallet-address-short">Account</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="assets-dashboard">
|
||||||
|
<nav class="wallet-actions" aria-label="Wallet actions">
|
||||||
|
<button id="open-send" type="button">
|
||||||
|
<span class="action-icon">↗</span>
|
||||||
|
<span>Send</span>
|
||||||
|
</button>
|
||||||
|
<button id="open-receive" type="button">
|
||||||
|
<span class="action-icon">↙</span>
|
||||||
|
<span>Receive</span>
|
||||||
|
</button>
|
||||||
|
<button id="open-nfts" type="button">
|
||||||
|
<span class="action-icon">#</span>
|
||||||
|
<span>NFTs/RWAs</span>
|
||||||
|
</button>
|
||||||
|
<button id="open-activity" type="button">
|
||||||
|
<span class="action-icon">◷</span>
|
||||||
|
<span>Activity</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section class="portfolio-balance" aria-labelledby="portfolio-title">
|
||||||
|
<span id="portfolio-title">Available balance</span>
|
||||||
|
<strong><span id="base-balance">0.00000000</span> <small data-network-symbol>CLTC</small></strong>
|
||||||
|
<p id="wallet-address"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="asset-ledger" aria-labelledby="assets-title">
|
||||||
|
<div class="asset-ledger-heading">
|
||||||
|
<h1 id="assets-title">Assets</h1>
|
||||||
|
<button id="refresh-balances" type="button" title="Refresh balances" aria-label="Refresh balances">↻</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="asset-filter" role="group" aria-label="Token visibility">
|
||||||
|
<button class="active" data-token-filter="held" type="button">Held tokens</button>
|
||||||
|
<button data-token-filter="all" type="button">All tokens</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="asset-row base-asset">
|
||||||
|
<div class="asset-symbol contractless-symbol">
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
</div>
|
||||||
|
<div class="asset-name">
|
||||||
|
<strong id="base-asset-name">Contractless Testnet</strong>
|
||||||
|
<span data-network-symbol>CLTC</span>
|
||||||
|
</div>
|
||||||
|
<div class="asset-value">
|
||||||
|
<strong id="base-asset-balance">0.00000000</strong>
|
||||||
|
<span data-network-symbol>CLTC</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="token-list" class="token-list">
|
||||||
|
<div class="empty-assets">
|
||||||
|
<strong>Loading token balances...</strong>
|
||||||
|
<span>Balances are retrieved from the selected Contractless API.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="nfts-screen" class="screen nfts-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-nfts" class="header-back" type="button" title="Back to assets" aria-label="Back to assets">←</button>
|
||||||
|
<div><strong>NFTs and RWAs</strong><small>Assets owned by this wallet</small></div>
|
||||||
|
<button id="refresh-nfts" class="header-refresh" type="button" title="Refresh assets" aria-label="Refresh assets">↻</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="nfts-content">
|
||||||
|
<div class="nfts-summary">
|
||||||
|
<span id="nfts-count">Loading ownership...</span>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</div>
|
||||||
|
<div id="nfts-list" class="nfts-list"></div>
|
||||||
|
<div id="nfts-pagination" class="nfts-pagination" hidden>
|
||||||
|
<button id="nfts-previous" type="button" aria-label="Previous NFT page">←</button>
|
||||||
|
<span>Page <strong id="nfts-page">1</strong> of <strong id="nfts-pages">1</strong></span>
|
||||||
|
<button id="nfts-next" type="button" aria-label="Next NFT page">→</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="nft-detail-screen" class="screen nft-detail-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-nft-detail" class="header-back" type="button" title="Back to NFTs and RWAs" aria-label="Back to NFTs and RWAs">←</button>
|
||||||
|
<div><strong id="nft-detail-title">NFT/RWA</strong><small id="nft-detail-kind">On-chain asset</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="nft-detail-content">
|
||||||
|
<div class="nft-detail-media">
|
||||||
|
<img id="nft-detail-image" alt="" />
|
||||||
|
</div>
|
||||||
|
<div class="nft-detail-heading">
|
||||||
|
<div>
|
||||||
|
<span id="nft-detail-badge">NFT</span>
|
||||||
|
<h1 id="nft-detail-name">NFT/RWA</h1>
|
||||||
|
</div>
|
||||||
|
<strong id="nft-detail-ownership"></strong>
|
||||||
|
</div>
|
||||||
|
<p id="nft-detail-description" class="nft-detail-description"></p>
|
||||||
|
<div id="nft-detail-attributes" class="nft-detail-attributes"></div>
|
||||||
|
<dl id="nft-detail-fields" class="nft-detail-fields"></dl>
|
||||||
|
<div class="nft-detail-actions">
|
||||||
|
<button id="transfer-nft" class="primary" type="button">Transfer asset</button>
|
||||||
|
</div>
|
||||||
|
<section class="nft-provenance">
|
||||||
|
<h2>Provenance</h2>
|
||||||
|
<div id="nft-detail-history"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="send-screen" class="screen send-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-send" class="header-back" type="button" title="Back to assets" aria-label="Back to assets">←</button>
|
||||||
|
<div><strong>Send</strong><small>Create a transfer</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form id="send-form" class="send-content">
|
||||||
|
<div>
|
||||||
|
<label for="send-asset">Asset</label>
|
||||||
|
<select id="send-asset"></select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="send-recipient">Receiving address</label>
|
||||||
|
<input id="send-recipient" type="text" autocomplete="off" spellcheck="false" placeholder="Wallet or vanity address" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="send-amount">Amount</label>
|
||||||
|
<div class="send-amount-row">
|
||||||
|
<input id="send-amount" type="text" inputmode="decimal" autocomplete="off" placeholder="0.00000000" />
|
||||||
|
<span id="send-amount-symbol" data-network-symbol>CLTC</span>
|
||||||
|
</div>
|
||||||
|
<span id="send-available" class="field-note">Available: 0.00000000 CLTC</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="send-fee">Transaction fee</label>
|
||||||
|
<div class="send-amount-row">
|
||||||
|
<input id="send-fee" type="text" inputmode="decimal" autocomplete="off" placeholder="0.00000000" />
|
||||||
|
<span data-network-symbol>CLTC</span>
|
||||||
|
</div>
|
||||||
|
<span id="send-fee-note" class="field-note">Minimum fee: 1% of the transfer amount.</span>
|
||||||
|
</div>
|
||||||
|
<button class="primary send-continue" type="submit">Review transfer</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="send-review-screen" class="screen send-review-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="edit-send" class="header-back" type="button" title="Edit transfer" aria-label="Edit transfer">←</button>
|
||||||
|
<div><strong>Review transfer</strong><small>Confirm before signing</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="send-review-content">
|
||||||
|
<div class="send-review-amount">
|
||||||
|
<span>You are sending</span>
|
||||||
|
<strong><span id="review-amount">0.00000000</span> <small id="review-asset">CLTC</small></strong>
|
||||||
|
</div>
|
||||||
|
<dl class="send-review-details">
|
||||||
|
<div><dt>From</dt><dd id="review-sender"></dd></div>
|
||||||
|
<div><dt>To</dt><dd id="review-recipient"></dd></div>
|
||||||
|
<div><dt>Resolved address</dt><dd id="review-resolved-recipient"></dd></div>
|
||||||
|
<div><dt>Transaction fee</dt><dd><span id="review-fee"></span> <span data-network-symbol>CLTC</span></dd></div>
|
||||||
|
<div><dt>Total deducted</dt><dd id="review-total"></dd></div>
|
||||||
|
</dl>
|
||||||
|
<p class="signing-notice">Your private key remains inside this wallet. The transaction is signed locally and only the completed transaction is sent to the API.</p>
|
||||||
|
<button id="sign-broadcast-transfer" class="primary" type="button">Sign and broadcast</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="send-success-screen" class="screen send-success-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<div><strong>Transfer broadcast</strong><small>Submitted to Contractless</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
<div class="send-success-content">
|
||||||
|
<span class="success-mark">✓</span>
|
||||||
|
<h1>Transaction sent</h1>
|
||||||
|
<p>The signed transfer was accepted for broadcast.</p>
|
||||||
|
<div class="send-txid">
|
||||||
|
<span>Transaction ID</span>
|
||||||
|
<strong id="send-result-txid"></strong>
|
||||||
|
</div>
|
||||||
|
<button id="copy-send-txid" class="secondary" type="button">Copy transaction ID</button>
|
||||||
|
<button id="finish-send" class="primary" type="button">Return to assets</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="activity-screen" class="screen activity-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-activity" class="header-back" type="button" title="Back to assets" aria-label="Back to assets">←</button>
|
||||||
|
<div><strong>Activity</strong><small>Latest 25 transactions</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="activity-content">
|
||||||
|
<div class="activity-summary">
|
||||||
|
<span>Newest transactions first</span>
|
||||||
|
<div>
|
||||||
|
<strong id="activity-count">0 shown</strong>
|
||||||
|
<button id="refresh-activity" type="button" title="Refresh activity" aria-label="Refresh activity">↻</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="activity-list" class="activity-list"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="transaction-screen" class="screen transaction-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-transaction" class="header-back" type="button" title="Back to activity" aria-label="Back to activity">←</button>
|
||||||
|
<div><strong id="transaction-title">Transaction</strong><small>Complete on-chain details</small></div>
|
||||||
|
<span id="transaction-header-status"></span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="transaction-content">
|
||||||
|
<section class="transaction-identity">
|
||||||
|
<div>
|
||||||
|
<span>Transaction ID</span>
|
||||||
|
<strong id="transaction-full-txid"></strong>
|
||||||
|
</div>
|
||||||
|
<button id="copy-transaction-txid" type="button">Copy TXID</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="transaction-overview" class="transaction-fields"></div>
|
||||||
|
|
||||||
|
<section class="transaction-section">
|
||||||
|
<h2>Transaction Fields</h2>
|
||||||
|
<div id="transaction-fields" class="transaction-fields"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="miner-earnings-section" class="transaction-section" hidden>
|
||||||
|
<h2>Miner Earnings</h2>
|
||||||
|
<div id="transaction-miner-earnings" class="transaction-fields"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<details class="transaction-technical">
|
||||||
|
<summary>Technical Details</summary>
|
||||||
|
<div id="transaction-technical-fields" class="transaction-fields"></div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="receive-screen" class="screen receive-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-receive" class="header-back" type="button" title="Back to assets" aria-label="Back to assets">←</button>
|
||||||
|
<div><strong>Receive</strong><small>Contractless wallet address</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="receive-content">
|
||||||
|
<span class="receive-label">Receive CLTC and Contractless assets</span>
|
||||||
|
<h1>Share your address</h1>
|
||||||
|
<p>
|
||||||
|
Scan the QR code or copy the address below. Only send assets issued on
|
||||||
|
the Contractless testnet to this address.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="qr-frame">
|
||||||
|
<canvas id="receive-qr" width="220" height="220" aria-label="Wallet address QR code"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="receive-address">
|
||||||
|
<span id="receive-address-label">Wallet address</span>
|
||||||
|
<strong id="receive-address-value"></strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="copy-receive-address" class="primary receive-copy" type="button">
|
||||||
|
Copy address
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="drawer-backdrop" class="drawer-backdrop" hidden></div>
|
||||||
|
<aside id="account-drawer" class="account-drawer" aria-hidden="true">
|
||||||
|
<header class="drawer-header">
|
||||||
|
<div>
|
||||||
|
<strong>Wallets</strong>
|
||||||
|
<span>Accounts and settings</span>
|
||||||
|
</div>
|
||||||
|
<button id="close-account-drawer" type="button" title="Close menu" aria-label="Close menu">×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="drawer-section">
|
||||||
|
<span class="drawer-label">Known addresses</span>
|
||||||
|
<div id="known-addresses" class="known-addresses"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav class="drawer-navigation" aria-label="Wallet menu">
|
||||||
|
<button id="menu-create-wallet" type="button">
|
||||||
|
<span class="menu-icon">+</span>
|
||||||
|
<span><strong>Create another address</strong><small>Generate a new Contractless wallet</small></span>
|
||||||
|
<span class="menu-arrow">›</span>
|
||||||
|
</button>
|
||||||
|
<button id="menu-import-wallet" type="button">
|
||||||
|
<span class="menu-icon">↓</span>
|
||||||
|
<span><strong>Import another address</strong><small>Use an image or private key</small></span>
|
||||||
|
<span class="menu-arrow">›</span>
|
||||||
|
</button>
|
||||||
|
<button id="menu-settings" type="button">
|
||||||
|
<span class="menu-icon">⚙</span>
|
||||||
|
<span><strong>Settings</strong><small>API endpoint and lock timeout</small></span>
|
||||||
|
<span class="menu-arrow">›</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<button id="drawer-lock-wallet" class="drawer-lock" type="button">
|
||||||
|
<span>⌁</span>
|
||||||
|
Lock wallet
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section id="settings-screen" class="screen settings-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-settings" class="header-back" type="button" title="Back to assets" aria-label="Back to assets">←</button>
|
||||||
|
<div><strong>Settings</strong><small>Wallet preferences and backup</small></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="settings-content">
|
||||||
|
<section class="settings-group">
|
||||||
|
<div class="settings-heading">
|
||||||
|
<strong>Security</strong>
|
||||||
|
<span>Control how long the active wallet stays unlocked.</span>
|
||||||
|
</div>
|
||||||
|
<label for="settings-lock-timeout">Automatic lock</label>
|
||||||
|
<select id="settings-lock-timeout">
|
||||||
|
<option value="15">15 minutes</option>
|
||||||
|
<option value="30">30 minutes</option>
|
||||||
|
<option value="60">60 minutes</option>
|
||||||
|
<option value="240">4 hours</option>
|
||||||
|
<option value="720">12 hours</option>
|
||||||
|
<option value="1440">24 hours</option>
|
||||||
|
</select>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-group">
|
||||||
|
<div class="settings-heading">
|
||||||
|
<strong>Contractless API</strong>
|
||||||
|
<span>Choose the API used for lookups and transaction broadcasts.</span>
|
||||||
|
</div>
|
||||||
|
<label for="settings-api-url">API URL</label>
|
||||||
|
<input id="settings-api-url" type="url" value="https://api.contractless.dev" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button id="save-wallet-settings" class="primary settings-save" type="button">Save settings</button>
|
||||||
|
|
||||||
|
<section class="settings-group backup-settings">
|
||||||
|
<div class="settings-heading">
|
||||||
|
<strong>Active wallet backup</strong>
|
||||||
|
<span>These actions require the active wallet encryption key.</span>
|
||||||
|
</div>
|
||||||
|
<button class="settings-action" data-backup-action="image" type="button">
|
||||||
|
<span class="settings-action-icon">▧</span>
|
||||||
|
<span><strong>Save wallet image</strong><small>Download an encrypted PNG backup</small></span>
|
||||||
|
<span>›</span>
|
||||||
|
</button>
|
||||||
|
<button class="settings-action" data-backup-action="private" type="button">
|
||||||
|
<span class="settings-action-icon">⌘</span>
|
||||||
|
<span><strong>View private key</strong><small>Reveal and copy the active private key</small></span>
|
||||||
|
<span>›</span>
|
||||||
|
</button>
|
||||||
|
<button class="settings-action" data-backup-action="print" type="button">
|
||||||
|
<span class="settings-action-icon">▤</span>
|
||||||
|
<span><strong>Print wallet backup</strong><small>Private key and encryption key</small></span>
|
||||||
|
<span>›</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="private-key-result" class="private-key-result" hidden>
|
||||||
|
<div>
|
||||||
|
<strong>Private key</strong>
|
||||||
|
<button id="clear-private-key" type="button" title="Clear private key" aria-label="Clear private key">×</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="revealed-private-key" readonly></textarea>
|
||||||
|
<button id="copy-private-key" class="secondary" type="button">Copy private key</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="backup-key-backdrop" class="modal-backdrop" hidden></div>
|
||||||
|
<section id="backup-key-modal" class="backup-key-modal" hidden aria-modal="true" role="dialog" aria-labelledby="backup-key-title">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<strong id="backup-key-title">Confirm encryption key</strong>
|
||||||
|
<span>Required to decrypt this wallet locally.</span>
|
||||||
|
</div>
|
||||||
|
<button id="close-backup-key" type="button" title="Cancel" aria-label="Cancel">×</button>
|
||||||
|
</header>
|
||||||
|
<div>
|
||||||
|
<label for="backup-key-input">Encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="backup-key-input" type="password" autocomplete="current-password" />
|
||||||
|
<button class="reveal" type="button" data-input="backup-key-input">Show</button>
|
||||||
|
</div>
|
||||||
|
<button id="confirm-backup-key" class="primary" type="button">Continue</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p id="notice" role="alert" aria-live="polite"></p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Contractless Wallet Backup</title>
|
||||||
|
<script type="module" crossorigin src="/print.js"></script>
|
||||||
|
<link rel="modulepreload" crossorigin href="/chunks/modulepreload-polyfill-B5Qt9EMX.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/print-DTb91-bp.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<img src="/assets/contractless-logo-BgfMS-Kf.png" alt="" />
|
||||||
|
<div>
|
||||||
|
<h1>Contractless Wallet Backup</h1>
|
||||||
|
<p>Private recovery information</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p class="warning">
|
||||||
|
Keep this page private. Anyone with the private key and encryption key can
|
||||||
|
control this wallet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Wallet</h2>
|
||||||
|
<pre id="wallet-label"></pre>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Address</h2>
|
||||||
|
<pre id="wallet-address"></pre>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Private Key</h2>
|
||||||
|
<pre id="wallet-private-key"></pre>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Encryption Key</h2>
|
||||||
|
<pre id="wallet-encryption-key"></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p id="print-error" class="print-error"></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
import"./chunks/modulepreload-polyfill-B5Qt9EMX.js";async function a(){const e=new URLSearchParams(location.search).get("id");if(!e)throw new Error("Missing printable wallet backup.");const n=`print-backup:${e}`,o=await chrome.storage.session.get(n);await chrome.storage.session.remove(n);const t=o[n];if(!t)throw new Error("The printable wallet backup has expired.");document.getElementById("wallet-label").textContent=t.label,document.getElementById("wallet-address").textContent=t.address,document.getElementById("wallet-private-key").textContent=t.privateKey,document.getElementById("wallet-encryption-key").textContent=t.encryptionKey,setTimeout(()=>{window.focus(),window.print()},150)}a().catch(e=>{document.getElementById("print-error").textContent=e instanceof Error?e.message:String(e)});
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
const a=new Map;window.addEventListener("message",e=>{var s,t,o;if(e.source!==window||((s=e.data)==null?void 0:s.source)!=="contractless-wallet")return;const r=a.get(e.data.id);r&&(a.delete(e.data.id),(t=e.data.response)!=null&&t.ok?r.resolve(e.data.response.result):r.reject(new Error(((o=e.data.response)==null?void 0:o.error)??"Wallet request failed.")))});const n=Object.freeze({isContractless:!0,request({method:e,params:r}){const s=crypto.randomUUID();return new Promise((t,o)=>{a.set(s,{resolve:t,reject:o}),window.postMessage({source:"contractless-page",id:s,method:e,params:r},window.location.origin)})}});Object.defineProperty(window,"contractless",{value:n,writable:!1,configurable:!1});
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"name": "contractless-browser-wallet",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build:wasm:testnet": "wasm-pack build ../core --target web --out-dir ../extension/src/wasm/testnet --out-name contractless_browser_core --no-default-features --features testnet",
|
||||||
|
"build:wasm:mainnet": "wasm-pack build ../core --target web --out-dir ../extension/src/wasm/mainnet --out-name contractless_browser_core --no-default-features --features mainnet",
|
||||||
|
"build:wasm": "pnpm run build:wasm:testnet && pnpm run build:wasm:mainnet",
|
||||||
|
"build:chrome": "pnpm run build:wasm && vite build --mode chrome",
|
||||||
|
"build:firefox": "pnpm run build:wasm && vite build --mode firefox",
|
||||||
|
"build": "pnpm run build:wasm && vite build --mode chrome && vite build --mode firefox",
|
||||||
|
"check": "tsc --noEmit",
|
||||||
|
"lint:firefox": "web-ext lint --source-dir dist/firefox",
|
||||||
|
"package:firefox": "web-ext build --source-dir dist/firefox --artifacts-dir web-ext-artifacts --overwrite-dest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/chrome": "^0.0.326",
|
||||||
|
"@types/node": "^22.15.3",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"vite-plugin-static-copy": "^2.3.1",
|
||||||
|
"web-ext": "^10.5.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"qrcode": "^1.5.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 707 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
|
@ -0,0 +1,25 @@
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = chrome.runtime.getURL("provider.js");
|
||||||
|
script.onload = () => script.remove();
|
||||||
|
(document.head || document.documentElement).appendChild(script);
|
||||||
|
|
||||||
|
window.addEventListener("message", (event) => {
|
||||||
|
if (event.source !== window || event.data?.source !== "contractless-page") return;
|
||||||
|
chrome.runtime.sendMessage(
|
||||||
|
{
|
||||||
|
type: "provider-request",
|
||||||
|
method: event.data.method,
|
||||||
|
params: event.data.params
|
||||||
|
},
|
||||||
|
(response) => {
|
||||||
|
window.postMessage(
|
||||||
|
{
|
||||||
|
source: "contractless-wallet",
|
||||||
|
id: event.data.id,
|
||||||
|
response
|
||||||
|
},
|
||||||
|
window.location.origin
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import initTestnet, * as testnet from "./wasm/testnet/contractless_browser_core";
|
||||||
|
import initMainnet, * as mainnet from "./wasm/mainnet/contractless_browser_core";
|
||||||
|
import type { ContractlessNetwork } from "./types";
|
||||||
|
|
||||||
|
const initialized: Partial<Record<ContractlessNetwork, Promise<unknown>>> = {};
|
||||||
|
|
||||||
|
export async function core(network: ContractlessNetwork = "testnet") {
|
||||||
|
const module = network === "mainnet" ? mainnet : testnet;
|
||||||
|
initialized[network] ??= network === "mainnet" ? initMainnet() : initTestnet();
|
||||||
|
await initialized[network];
|
||||||
|
return module;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
:root {
|
||||||
|
font: 16px/1.5 Arial, Helvetica, sans-serif;
|
||||||
|
color: #fff;
|
||||||
|
background: #08131c;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: #08131c;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input { font: inherit; }
|
||||||
|
|
||||||
|
button { cursor: pointer; }
|
||||||
|
button:disabled { cursor: wait; opacity: .72; }
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 76px;
|
||||||
|
padding: 0 32px;
|
||||||
|
border-bottom: 1px solid #2e4757;
|
||||||
|
background: #0d1d28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand img {
|
||||||
|
width: 39px;
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand div { display: grid; }
|
||||||
|
.brand strong { font-size: 18px; }
|
||||||
|
.brand span,
|
||||||
|
.local-page { color: #b9cbd6; font-size: 13px; }
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: grid;
|
||||||
|
place-items: start center;
|
||||||
|
padding: 54px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-panel,
|
||||||
|
.success-panel {
|
||||||
|
width: min(100%, 500px);
|
||||||
|
padding: 34px;
|
||||||
|
border: 1px solid #385666;
|
||||||
|
background: #10222e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: #58d5e7;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 7px 0 10px;
|
||||||
|
font-size: 31px;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro,
|
||||||
|
.success-panel p { margin: 0 0 26px; color: #c8d6de; }
|
||||||
|
|
||||||
|
form { display: grid; }
|
||||||
|
|
||||||
|
label {
|
||||||
|
margin: 21px 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-picker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 78px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 13px;
|
||||||
|
border: 1px solid #5ac9de;
|
||||||
|
background: #132d3b;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-picker > span:last-child { display: grid; gap: 3px; }
|
||||||
|
.file-picker small { color: #afc6d1; font-weight: 400; }
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border: 1px solid #62d7e9;
|
||||||
|
color: #62d7e9;
|
||||||
|
font-size: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row input {
|
||||||
|
min-width: 0;
|
||||||
|
height: 50px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid #5a7180;
|
||||||
|
border-right: 0;
|
||||||
|
background: #08151e;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row button {
|
||||||
|
width: 68px;
|
||||||
|
border: 1px solid #5a7180;
|
||||||
|
background: #173345;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-note,
|
||||||
|
.address-result {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 13px 14px;
|
||||||
|
border-left: 3px solid #57d0df;
|
||||||
|
background: #0a1922;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-note span,
|
||||||
|
.address-result span { color: #b7cad4; font-size: 13px; }
|
||||||
|
|
||||||
|
.address-result strong { overflow-wrap: anywhere; }
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
min-height: 50px;
|
||||||
|
margin-top: 24px;
|
||||||
|
border: 1px solid #67d5e4;
|
||||||
|
background: #236eaa;
|
||||||
|
color: white;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
#notice {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-left: 3px solid #ff7168;
|
||||||
|
background: #321c20;
|
||||||
|
color: #ffc0bb;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#notice:empty { display: none; }
|
||||||
|
|
||||||
|
.success-mark {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
border: 2px solid #62d7e9;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #62d7e9;
|
||||||
|
font-size: 31px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completion-message { margin-top: 24px !important; }
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.site-header { padding: 0 18px; }
|
||||||
|
.local-page { display: none; }
|
||||||
|
main { padding: 24px 12px; }
|
||||||
|
.import-panel,
|
||||||
|
.success-panel { padding: 24px 20px; }
|
||||||
|
h1 { font-size: 27px; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Import Contractless Wallet</title>
|
||||||
|
<link rel="stylesheet" href="./import-wallet.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="brand">
|
||||||
|
<img src="./assets/contractless-logo.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><span>Browser Wallet</span></div>
|
||||||
|
</div>
|
||||||
|
<span class="local-page">Local extension page</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section id="import-panel" class="import-panel">
|
||||||
|
<span class="eyebrow">Restore <span id="selected-network">Contractless</span> wallet</span>
|
||||||
|
<h1>Import wallet image</h1>
|
||||||
|
<p class="intro">
|
||||||
|
Select your encrypted Contractless private-key image and enter the
|
||||||
|
encryption key originally used to create it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form id="import-form">
|
||||||
|
<label class="file-picker" for="wallet-image">
|
||||||
|
<span class="file-icon">+</span>
|
||||||
|
<span>
|
||||||
|
<strong id="image-file-name">Choose wallet image</strong>
|
||||||
|
<small>PNG encrypted private-key image</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input id="wallet-image" class="visually-hidden" type="file" accept="image/png" required />
|
||||||
|
|
||||||
|
<label for="image-password">Encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="image-password" type="password" autocomplete="current-password" required />
|
||||||
|
<button id="reveal-password" type="button">Show</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="security-note">
|
||||||
|
<strong>Processed locally by Contractless Wallet</strong>
|
||||||
|
<span>The wallet image and encryption key are not sent to the website you were visiting.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="import-button" class="primary" type="submit">Import wallet</button>
|
||||||
|
<p id="notice" role="alert" aria-live="polite"></p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="success-panel" class="success-panel" hidden>
|
||||||
|
<span class="success-mark" aria-hidden="true">✓</span>
|
||||||
|
<span class="eyebrow">Import complete</span>
|
||||||
|
<h1>Wallet imported successfully</h1>
|
||||||
|
<p>The imported wallet is unlocked and is now the active Contractless address.</p>
|
||||||
|
<div class="address-result">
|
||||||
|
<span>Active wallet</span>
|
||||||
|
<strong id="imported-address"></strong>
|
||||||
|
</div>
|
||||||
|
<p class="completion-message">You may close this page and reopen the Contractless Wallet extension.</p>
|
||||||
|
<button id="close-page" class="primary" type="button">Close page</button>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="./import-wallet.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import type { WalletStatus } from "./types";
|
||||||
|
|
||||||
|
const byId = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||||
|
const form = byId<HTMLFormElement>("import-form");
|
||||||
|
const imageInput = byId<HTMLInputElement>("wallet-image");
|
||||||
|
const passwordInput = byId<HTMLInputElement>("image-password");
|
||||||
|
const importButton = byId<HTMLButtonElement>("import-button");
|
||||||
|
const notice = byId<HTMLParagraphElement>("notice");
|
||||||
|
|
||||||
|
void send("status").then((status) => {
|
||||||
|
const walletStatus = status as WalletStatus;
|
||||||
|
byId("selected-network").textContent = walletStatus.networkName;
|
||||||
|
}).catch((error) => {
|
||||||
|
notice.textContent = errorText(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
function bytesToBase64(bytes: Uint8Array): string {
|
||||||
|
let binary = "";
|
||||||
|
const chunkSize = 0x8000;
|
||||||
|
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||||
|
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send(type: string, values: Record<string, unknown> = {}) {
|
||||||
|
const response = await chrome.runtime.sendMessage({ type, ...values });
|
||||||
|
if (!response?.ok) throw new Error(response?.error ?? "Wallet request failed.");
|
||||||
|
return response.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
imageInput.addEventListener("change", () => {
|
||||||
|
byId("image-file-name").textContent =
|
||||||
|
imageInput.files?.[0]?.name ?? "Choose wallet image";
|
||||||
|
notice.textContent = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
byId("reveal-password").addEventListener("click", (event) => {
|
||||||
|
const button = event.currentTarget as HTMLButtonElement;
|
||||||
|
const showing = passwordInput.type === "text";
|
||||||
|
passwordInput.type = showing ? "password" : "text";
|
||||||
|
button.textContent = showing ? "Show" : "Hide";
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void (async () => {
|
||||||
|
const file = imageInput.files?.[0];
|
||||||
|
if (!file) throw new Error("Choose a wallet image.");
|
||||||
|
if (!passwordInput.value.trim()) throw new Error("Enter the wallet image encryption key.");
|
||||||
|
|
||||||
|
notice.textContent = "";
|
||||||
|
importButton.disabled = true;
|
||||||
|
importButton.textContent = "Importing wallet...";
|
||||||
|
|
||||||
|
const imageBase64 = bytesToBase64(new Uint8Array(await file.arrayBuffer()));
|
||||||
|
const status = await send("import-wallet-image", {
|
||||||
|
imageBase64,
|
||||||
|
imagePassword: passwordInput.value,
|
||||||
|
password: passwordInput.value
|
||||||
|
}) as WalletStatus;
|
||||||
|
|
||||||
|
passwordInput.value = "";
|
||||||
|
byId("imported-address").textContent = status.address ?? "Imported wallet";
|
||||||
|
byId("import-panel").hidden = true;
|
||||||
|
byId("success-panel").hidden = false;
|
||||||
|
})().catch((error) => {
|
||||||
|
notice.textContent = errorText(error);
|
||||||
|
importButton.disabled = false;
|
||||||
|
importButton.textContent = "Import wallet";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
byId("close-page").addEventListener("click", () => window.close());
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Contractless Wallet",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"description": "A local-signing Web3 wallet for Contractless.",
|
||||||
|
"content_security_policy": {
|
||||||
|
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
||||||
|
},
|
||||||
|
"permissions": ["storage", "alarms", "activeTab", "clipboardWrite"],
|
||||||
|
"host_permissions": ["https://api.contractless.dev/*"],
|
||||||
|
"optional_host_permissions": ["https://*/"],
|
||||||
|
"action": {
|
||||||
|
"default_popup": "popup.html",
|
||||||
|
"default_title": "Contractless Wallet",
|
||||||
|
"default_icon": {
|
||||||
|
"16": "contractless-logo-16.png",
|
||||||
|
"32": "contractless-logo-32.png",
|
||||||
|
"48": "contractless-logo-48.png",
|
||||||
|
"128": "contractless-logo-128.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"icons": {
|
||||||
|
"16": "contractless-logo-16.png",
|
||||||
|
"32": "contractless-logo-32.png",
|
||||||
|
"48": "contractless-logo-48.png",
|
||||||
|
"128": "contractless-logo-128.png"
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"service_worker": "background.js",
|
||||||
|
"type": "module"
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"],
|
||||||
|
"js": ["content.js"],
|
||||||
|
"run_at": "document_start"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"web_accessible_resources": [
|
||||||
|
{
|
||||||
|
"resources": ["provider.js"],
|
||||||
|
"matches": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Contractless Wallet",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"description": "A local-signing Web3 wallet for Contractless.",
|
||||||
|
"browser_specific_settings": {
|
||||||
|
"gecko": {
|
||||||
|
"id": "wallet@contractless.dev",
|
||||||
|
"strict_min_version": "149.0",
|
||||||
|
"data_collection_permissions": {
|
||||||
|
"required": ["none"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gecko_android": {
|
||||||
|
"strict_min_version": "149.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content_security_policy": {
|
||||||
|
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
||||||
|
},
|
||||||
|
"permissions": ["storage", "alarms", "activeTab", "clipboardWrite"],
|
||||||
|
"host_permissions": ["https://api.contractless.dev/*"],
|
||||||
|
"optional_host_permissions": ["https://*/"],
|
||||||
|
"action": {
|
||||||
|
"default_popup": "popup.html",
|
||||||
|
"default_title": "Contractless Wallet",
|
||||||
|
"default_icon": {
|
||||||
|
"16": "contractless-logo-16.png",
|
||||||
|
"32": "contractless-logo-32.png",
|
||||||
|
"48": "contractless-logo-48.png",
|
||||||
|
"128": "contractless-logo-128.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"icons": {
|
||||||
|
"16": "contractless-logo-16.png",
|
||||||
|
"32": "contractless-logo-32.png",
|
||||||
|
"48": "contractless-logo-48.png",
|
||||||
|
"128": "contractless-logo-128.png"
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"scripts": ["background.js"],
|
||||||
|
"type": "module"
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"],
|
||||||
|
"js": ["content.js"],
|
||||||
|
"run_at": "document_start"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"web_accessible_resources": [
|
||||||
|
{
|
||||||
|
"resources": ["provider.js"],
|
||||||
|
"matches": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,652 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Contractless Wallet</title>
|
||||||
|
<link rel="stylesheet" href="./wallet.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="wallet-shell">
|
||||||
|
<section id="loading" class="screen loading-screen">
|
||||||
|
<div class="loading-brand">
|
||||||
|
<img src="./assets/contractless-logo.png" alt="Contractless" />
|
||||||
|
<div class="spinner" aria-label="Loading"></div>
|
||||||
|
</div>
|
||||||
|
<p>Opening wallet</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="create-screen" class="screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<img src="./assets/contractless-logo.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small>Browser Wallet</small></div>
|
||||||
|
<span>1 of 3</span>
|
||||||
|
</header>
|
||||||
|
<div class="screen-content">
|
||||||
|
<div class="network-choice" role="group" aria-label="Wallet network">
|
||||||
|
<button type="button" data-select-network="testnet">Testnet</button>
|
||||||
|
<button type="button" data-select-network="mainnet">Mainnet</button>
|
||||||
|
</div>
|
||||||
|
<span class="eyebrow">New wallet</span>
|
||||||
|
<h1>Create your wallet</h1>
|
||||||
|
<p class="intro">Choose an encryption key used to unlock and recover this wallet.</p>
|
||||||
|
|
||||||
|
<label for="new-password">Encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="new-password" type="password" autocomplete="new-password" />
|
||||||
|
<button class="reveal" type="button" data-input="new-password">Show</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>This key is not stored for you.</strong>
|
||||||
|
<span>You will verify it before the wallet is created.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="continue-create" class="primary" type="button">Continue <span>→</span></button>
|
||||||
|
<div class="divider"><span>or restore an existing wallet</span></div>
|
||||||
|
<button class="secondary navigation" data-screen="image-screen" type="button">
|
||||||
|
Import wallet from image
|
||||||
|
</button>
|
||||||
|
<button class="secondary navigation" data-screen="private-screen" type="button">
|
||||||
|
Import wallet from private key
|
||||||
|
</button>
|
||||||
|
<button id="cancel-wallet-add" class="text-button" type="button" hidden>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="image-screen" class="screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<img src="./assets/contractless-logo.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small>Browser Wallet</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
<form id="import-image-form" class="screen-content">
|
||||||
|
<span class="eyebrow">Existing wallet</span>
|
||||||
|
<h1>Import wallet image</h1>
|
||||||
|
<p class="intro">
|
||||||
|
Select your encrypted private-key image and enter the encryption key
|
||||||
|
originally used to create it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="file-heading" for="wallet-image">Wallet image</label>
|
||||||
|
<label class="file-picker" for="wallet-image">
|
||||||
|
<span class="file-icon">+</span>
|
||||||
|
<span><strong id="image-file-name">Choose wallet image</strong><small>PNG encrypted private-key image</small></span>
|
||||||
|
</label>
|
||||||
|
<input id="wallet-image" class="visually-hidden" type="file" accept="image/png" />
|
||||||
|
|
||||||
|
<label for="image-password">Encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="image-password" type="password" autocomplete="current-password" />
|
||||||
|
<button class="reveal" type="button" data-input="image-password">Show</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="import-image" class="primary" type="submit">Import wallet</button>
|
||||||
|
<button class="text-button navigation" data-screen="create-screen" type="button">Back</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="private-screen" class="screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<img src="./assets/contractless-logo.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small>Browser Wallet</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
<div class="screen-content compact">
|
||||||
|
<span class="eyebrow">Existing wallet</span>
|
||||||
|
<h1>Import private key</h1>
|
||||||
|
<p class="intro">
|
||||||
|
Paste the private key, then choose the encryption key that will protect
|
||||||
|
this wallet from now on.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label for="private-key">Private key</label>
|
||||||
|
<textarea id="private-key" rows="4" placeholder="Paste private key"></textarea>
|
||||||
|
|
||||||
|
<label for="private-password">New encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="private-password" type="password" autocomplete="new-password" />
|
||||||
|
<button class="reveal" type="button" data-input="private-password">Show</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note compact-note">
|
||||||
|
<strong>This key protects the new wallet image.</strong>
|
||||||
|
<span>Store it securely and separately from the image.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="import-private" class="primary" type="button">Import and create wallet</button>
|
||||||
|
<button class="text-button navigation" data-screen="create-screen" type="button">Back</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="verify-screen" class="screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<img src="./assets/contractless-logo.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small>Browser Wallet</small></div>
|
||||||
|
<span>2 of 3</span>
|
||||||
|
</header>
|
||||||
|
<div class="screen-content">
|
||||||
|
<span class="eyebrow">Recovery</span>
|
||||||
|
<h1>Verify your encryption key</h1>
|
||||||
|
<p class="intro">
|
||||||
|
Enter the same key again. Keep it somewhere secure and separate from
|
||||||
|
your wallet image.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label for="verify-password">Re-enter encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="verify-password" type="password" autocomplete="new-password" />
|
||||||
|
<button class="reveal" type="button" data-input="verify-password">Show</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="warning">
|
||||||
|
<strong>There is no password reset.</strong>
|
||||||
|
Losing this key means the wallet image cannot restore your wallet.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="create-wallet" class="primary" type="button">Create wallet <span>→</span></button>
|
||||||
|
<button class="text-button navigation" data-screen="create-screen" type="button">Back</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="backup-screen" class="screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<img src="./assets/contractless-logo.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small>Browser Wallet</small></div>
|
||||||
|
<span>3 of 3</span>
|
||||||
|
</header>
|
||||||
|
<div class="screen-content backup-content">
|
||||||
|
<span class="success-mark">✓</span>
|
||||||
|
<h1>Your wallet is ready</h1>
|
||||||
|
<p class="intro">
|
||||||
|
Save the private-key image now. You need both this image and your
|
||||||
|
encryption key to recover the wallet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="image-preview">
|
||||||
|
<img id="wallet-image-preview" alt="Encrypted private-key wallet image" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="save-image" class="primary" type="button">↓ Save wallet image</button>
|
||||||
|
<label class="confirmation">
|
||||||
|
<input id="backup-confirmed" type="checkbox" disabled />
|
||||||
|
<span>I saved the image and stored my encryption key separately.</span>
|
||||||
|
</label>
|
||||||
|
<button id="open-wallet" class="secondary" type="button" disabled>Open wallet</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="locked-screen" class="screen" hidden>
|
||||||
|
<form id="unlock-form" class="centered-content">
|
||||||
|
<img class="large-logo" src="./assets/contractless-logo.png" alt="Contractless" />
|
||||||
|
<div class="network-choice" role="group" aria-label="Wallet network">
|
||||||
|
<button type="button" data-select-network="testnet">Testnet</button>
|
||||||
|
<button type="button" data-select-network="mainnet">Mainnet</button>
|
||||||
|
</div>
|
||||||
|
<h1>Unlock wallet</h1>
|
||||||
|
<p class="intro">Enter your encryption key to continue.</p>
|
||||||
|
<label for="unlock-password">Encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="unlock-password" type="password" autocomplete="current-password" />
|
||||||
|
<button class="reveal" type="button" data-input="unlock-password">Show</button>
|
||||||
|
</div>
|
||||||
|
<button id="unlock-wallet" class="primary" type="submit">Unlock wallet</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="provider-approval-screen" class="screen provider-approval-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<img src="./assets/contractless-logo.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small>Wallet request</small></div>
|
||||||
|
<span>Review</span>
|
||||||
|
</header>
|
||||||
|
<div class="screen-content">
|
||||||
|
<div class="approval-context">
|
||||||
|
<span class="eyebrow" id="provider-approval-origin"></span>
|
||||||
|
<span id="provider-approval-risk" class="risk-label"></span>
|
||||||
|
</div>
|
||||||
|
<h1 id="provider-approval-title">Review request</h1>
|
||||||
|
<p class="intro" id="provider-approval-intro">Review this request before approving it.</p>
|
||||||
|
<dl id="provider-approval-details" class="approval-details"></dl>
|
||||||
|
</div>
|
||||||
|
<div class="approval-actions">
|
||||||
|
<button id="reject-provider-approval" class="secondary" type="button">Reject</button>
|
||||||
|
<button id="approve-provider-approval" class="primary" type="button">Approve</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="dashboard-screen" class="screen" hidden>
|
||||||
|
<header class="wallet-header dashboard-header">
|
||||||
|
<img src="./assets/contractless-logo.png" alt="" />
|
||||||
|
<div><strong>Contractless</strong><small data-network-name>Testnet</small></div>
|
||||||
|
<button
|
||||||
|
id="account-menu-button"
|
||||||
|
class="account-button"
|
||||||
|
type="button"
|
||||||
|
title="Wallets and settings"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="account-drawer"
|
||||||
|
>
|
||||||
|
<span class="network-dot"></span>
|
||||||
|
<span id="wallet-address-short">Account</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="assets-dashboard">
|
||||||
|
<nav class="wallet-actions" aria-label="Wallet actions">
|
||||||
|
<button id="open-send" type="button">
|
||||||
|
<span class="action-icon">↗</span>
|
||||||
|
<span>Send</span>
|
||||||
|
</button>
|
||||||
|
<button id="open-receive" type="button">
|
||||||
|
<span class="action-icon">↙</span>
|
||||||
|
<span>Receive</span>
|
||||||
|
</button>
|
||||||
|
<button id="open-nfts" type="button">
|
||||||
|
<span class="action-icon">#</span>
|
||||||
|
<span>NFTs/RWAs</span>
|
||||||
|
</button>
|
||||||
|
<button id="open-activity" type="button">
|
||||||
|
<span class="action-icon">◷</span>
|
||||||
|
<span>Activity</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section class="portfolio-balance" aria-labelledby="portfolio-title">
|
||||||
|
<span id="portfolio-title">Available balance</span>
|
||||||
|
<strong><span id="base-balance">0.00000000</span> <small data-network-symbol>CLTC</small></strong>
|
||||||
|
<p id="wallet-address"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="asset-ledger" aria-labelledby="assets-title">
|
||||||
|
<div class="asset-ledger-heading">
|
||||||
|
<h1 id="assets-title">Assets</h1>
|
||||||
|
<button id="refresh-balances" type="button" title="Refresh balances" aria-label="Refresh balances">↻</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="asset-filter" role="group" aria-label="Token visibility">
|
||||||
|
<button class="active" data-token-filter="held" type="button">Held tokens</button>
|
||||||
|
<button data-token-filter="all" type="button">All tokens</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="asset-row base-asset">
|
||||||
|
<div class="asset-symbol contractless-symbol">
|
||||||
|
<img src="./assets/contractless-logo.png" alt="" />
|
||||||
|
</div>
|
||||||
|
<div class="asset-name">
|
||||||
|
<strong id="base-asset-name">Contractless Testnet</strong>
|
||||||
|
<span data-network-symbol>CLTC</span>
|
||||||
|
</div>
|
||||||
|
<div class="asset-value">
|
||||||
|
<strong id="base-asset-balance">0.00000000</strong>
|
||||||
|
<span data-network-symbol>CLTC</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="token-list" class="token-list">
|
||||||
|
<div class="empty-assets">
|
||||||
|
<strong>Loading token balances...</strong>
|
||||||
|
<span>Balances are retrieved from the selected Contractless API.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="nfts-screen" class="screen nfts-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-nfts" class="header-back" type="button" title="Back to assets" aria-label="Back to assets">←</button>
|
||||||
|
<div><strong>NFTs and RWAs</strong><small>Assets owned by this wallet</small></div>
|
||||||
|
<button id="refresh-nfts" class="header-refresh" type="button" title="Refresh assets" aria-label="Refresh assets">↻</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="nfts-content">
|
||||||
|
<div class="nfts-summary">
|
||||||
|
<span id="nfts-count">Loading ownership...</span>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</div>
|
||||||
|
<div id="nfts-list" class="nfts-list"></div>
|
||||||
|
<div id="nfts-pagination" class="nfts-pagination" hidden>
|
||||||
|
<button id="nfts-previous" type="button" aria-label="Previous NFT page">←</button>
|
||||||
|
<span>Page <strong id="nfts-page">1</strong> of <strong id="nfts-pages">1</strong></span>
|
||||||
|
<button id="nfts-next" type="button" aria-label="Next NFT page">→</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="nft-detail-screen" class="screen nft-detail-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-nft-detail" class="header-back" type="button" title="Back to NFTs and RWAs" aria-label="Back to NFTs and RWAs">←</button>
|
||||||
|
<div><strong id="nft-detail-title">NFT/RWA</strong><small id="nft-detail-kind">On-chain asset</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="nft-detail-content">
|
||||||
|
<div class="nft-detail-media">
|
||||||
|
<img id="nft-detail-image" alt="" />
|
||||||
|
</div>
|
||||||
|
<div class="nft-detail-heading">
|
||||||
|
<div>
|
||||||
|
<span id="nft-detail-badge">NFT</span>
|
||||||
|
<h1 id="nft-detail-name">NFT/RWA</h1>
|
||||||
|
</div>
|
||||||
|
<strong id="nft-detail-ownership"></strong>
|
||||||
|
</div>
|
||||||
|
<p id="nft-detail-description" class="nft-detail-description"></p>
|
||||||
|
<div id="nft-detail-attributes" class="nft-detail-attributes"></div>
|
||||||
|
<dl id="nft-detail-fields" class="nft-detail-fields"></dl>
|
||||||
|
<div class="nft-detail-actions">
|
||||||
|
<button id="transfer-nft" class="primary" type="button">Transfer asset</button>
|
||||||
|
</div>
|
||||||
|
<section class="nft-provenance">
|
||||||
|
<h2>Provenance</h2>
|
||||||
|
<div id="nft-detail-history"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="send-screen" class="screen send-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-send" class="header-back" type="button" title="Back to assets" aria-label="Back to assets">←</button>
|
||||||
|
<div><strong>Send</strong><small>Create a transfer</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form id="send-form" class="send-content">
|
||||||
|
<div>
|
||||||
|
<label for="send-asset">Asset</label>
|
||||||
|
<select id="send-asset"></select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="send-recipient">Receiving address</label>
|
||||||
|
<input id="send-recipient" type="text" autocomplete="off" spellcheck="false" placeholder="Wallet or vanity address" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="send-amount">Amount</label>
|
||||||
|
<div class="send-amount-row">
|
||||||
|
<input id="send-amount" type="text" inputmode="decimal" autocomplete="off" placeholder="0.00000000" />
|
||||||
|
<span id="send-amount-symbol" data-network-symbol>CLTC</span>
|
||||||
|
</div>
|
||||||
|
<span id="send-available" class="field-note">Available: 0.00000000 CLTC</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="send-fee">Transaction fee</label>
|
||||||
|
<div class="send-amount-row">
|
||||||
|
<input id="send-fee" type="text" inputmode="decimal" autocomplete="off" placeholder="0.00000000" />
|
||||||
|
<span data-network-symbol>CLTC</span>
|
||||||
|
</div>
|
||||||
|
<span id="send-fee-note" class="field-note">Minimum fee: 1% of the transfer amount.</span>
|
||||||
|
</div>
|
||||||
|
<button class="primary send-continue" type="submit">Review transfer</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="send-review-screen" class="screen send-review-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="edit-send" class="header-back" type="button" title="Edit transfer" aria-label="Edit transfer">←</button>
|
||||||
|
<div><strong>Review transfer</strong><small>Confirm before signing</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="send-review-content">
|
||||||
|
<div class="send-review-amount">
|
||||||
|
<span>You are sending</span>
|
||||||
|
<strong><span id="review-amount">0.00000000</span> <small id="review-asset">CLTC</small></strong>
|
||||||
|
</div>
|
||||||
|
<dl class="send-review-details">
|
||||||
|
<div><dt>From</dt><dd id="review-sender"></dd></div>
|
||||||
|
<div><dt>To</dt><dd id="review-recipient"></dd></div>
|
||||||
|
<div><dt>Resolved address</dt><dd id="review-resolved-recipient"></dd></div>
|
||||||
|
<div><dt>Transaction fee</dt><dd><span id="review-fee"></span> <span data-network-symbol>CLTC</span></dd></div>
|
||||||
|
<div><dt>Total deducted</dt><dd id="review-total"></dd></div>
|
||||||
|
</dl>
|
||||||
|
<p class="signing-notice">Your private key remains inside this wallet. The transaction is signed locally and only the completed transaction is sent to the API.</p>
|
||||||
|
<button id="sign-broadcast-transfer" class="primary" type="button">Sign and broadcast</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="send-success-screen" class="screen send-success-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<div><strong>Transfer broadcast</strong><small>Submitted to Contractless</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
<div class="send-success-content">
|
||||||
|
<span class="success-mark">✓</span>
|
||||||
|
<h1>Transaction sent</h1>
|
||||||
|
<p>The signed transfer was accepted for broadcast.</p>
|
||||||
|
<div class="send-txid">
|
||||||
|
<span>Transaction ID</span>
|
||||||
|
<strong id="send-result-txid"></strong>
|
||||||
|
</div>
|
||||||
|
<button id="copy-send-txid" class="secondary" type="button">Copy transaction ID</button>
|
||||||
|
<button id="finish-send" class="primary" type="button">Return to assets</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="activity-screen" class="screen activity-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-activity" class="header-back" type="button" title="Back to assets" aria-label="Back to assets">←</button>
|
||||||
|
<div><strong>Activity</strong><small>Latest 25 transactions</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="activity-content">
|
||||||
|
<div class="activity-summary">
|
||||||
|
<span>Newest transactions first</span>
|
||||||
|
<div>
|
||||||
|
<strong id="activity-count">0 shown</strong>
|
||||||
|
<button id="refresh-activity" type="button" title="Refresh activity" aria-label="Refresh activity">↻</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="activity-list" class="activity-list"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="transaction-screen" class="screen transaction-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-transaction" class="header-back" type="button" title="Back to activity" aria-label="Back to activity">←</button>
|
||||||
|
<div><strong id="transaction-title">Transaction</strong><small>Complete on-chain details</small></div>
|
||||||
|
<span id="transaction-header-status"></span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="transaction-content">
|
||||||
|
<section class="transaction-identity">
|
||||||
|
<div>
|
||||||
|
<span>Transaction ID</span>
|
||||||
|
<strong id="transaction-full-txid"></strong>
|
||||||
|
</div>
|
||||||
|
<button id="copy-transaction-txid" type="button">Copy TXID</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="transaction-overview" class="transaction-fields"></div>
|
||||||
|
|
||||||
|
<section class="transaction-section">
|
||||||
|
<h2>Transaction Fields</h2>
|
||||||
|
<div id="transaction-fields" class="transaction-fields"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="miner-earnings-section" class="transaction-section" hidden>
|
||||||
|
<h2>Miner Earnings</h2>
|
||||||
|
<div id="transaction-miner-earnings" class="transaction-fields"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<details class="transaction-technical">
|
||||||
|
<summary>Technical Details</summary>
|
||||||
|
<div id="transaction-technical-fields" class="transaction-fields"></div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="receive-screen" class="screen receive-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-receive" class="header-back" type="button" title="Back to assets" aria-label="Back to assets">←</button>
|
||||||
|
<div><strong>Receive</strong><small>Contractless wallet address</small></div>
|
||||||
|
<span data-network-name>Testnet</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="receive-content">
|
||||||
|
<span class="receive-label">Receive <span data-network-symbol>CLTC</span> and Contractless assets</span>
|
||||||
|
<h1>Share your address</h1>
|
||||||
|
<p>
|
||||||
|
Scan the QR code or copy the address below. Only send assets issued on
|
||||||
|
the Contractless <span data-network-name>Testnet</span> network to this address.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="qr-frame">
|
||||||
|
<canvas id="receive-qr" width="220" height="220" aria-label="Wallet address QR code"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="receive-address">
|
||||||
|
<span id="receive-address-label">Wallet address</span>
|
||||||
|
<strong id="receive-address-value"></strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="copy-receive-address" class="primary receive-copy" type="button">
|
||||||
|
Copy address
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="drawer-backdrop" class="drawer-backdrop" hidden></div>
|
||||||
|
<aside id="account-drawer" class="account-drawer" aria-hidden="true">
|
||||||
|
<header class="drawer-header">
|
||||||
|
<div>
|
||||||
|
<strong>Wallets</strong>
|
||||||
|
<span>Accounts and settings</span>
|
||||||
|
</div>
|
||||||
|
<button id="close-account-drawer" type="button" title="Close menu" aria-label="Close menu">×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="drawer-section">
|
||||||
|
<span class="drawer-label">Network</span>
|
||||||
|
<div class="network-choice drawer-network" role="group" aria-label="Wallet network">
|
||||||
|
<button type="button" data-select-network="testnet">Testnet</button>
|
||||||
|
<button type="button" data-select-network="mainnet">Mainnet</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="drawer-section">
|
||||||
|
<span class="drawer-label">Known addresses</span>
|
||||||
|
<div id="known-addresses" class="known-addresses"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav class="drawer-navigation" aria-label="Wallet menu">
|
||||||
|
<button id="menu-create-wallet" type="button">
|
||||||
|
<span class="menu-icon">+</span>
|
||||||
|
<span><strong>Create another address</strong><small>Generate a new Contractless wallet</small></span>
|
||||||
|
<span class="menu-arrow">›</span>
|
||||||
|
</button>
|
||||||
|
<button id="menu-import-wallet" type="button">
|
||||||
|
<span class="menu-icon">↓</span>
|
||||||
|
<span><strong>Import another address</strong><small>Use an image or private key</small></span>
|
||||||
|
<span class="menu-arrow">›</span>
|
||||||
|
</button>
|
||||||
|
<button id="menu-settings" type="button">
|
||||||
|
<span class="menu-icon">⚙</span>
|
||||||
|
<span><strong>Settings</strong><small>API endpoint and lock timeout</small></span>
|
||||||
|
<span class="menu-arrow">›</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<button id="drawer-lock-wallet" class="drawer-lock" type="button">
|
||||||
|
<span>⌁</span>
|
||||||
|
Lock wallet
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section id="settings-screen" class="screen settings-screen" hidden>
|
||||||
|
<header class="wallet-header">
|
||||||
|
<button id="close-settings" class="header-back" type="button" title="Back to assets" aria-label="Back to assets">←</button>
|
||||||
|
<div><strong>Settings</strong><small>Wallet preferences and backup</small></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="settings-content">
|
||||||
|
<section class="settings-group">
|
||||||
|
<div class="settings-heading">
|
||||||
|
<strong>Security</strong>
|
||||||
|
<span>Control how long the active wallet stays unlocked.</span>
|
||||||
|
</div>
|
||||||
|
<label for="settings-lock-timeout">Automatic lock</label>
|
||||||
|
<select id="settings-lock-timeout">
|
||||||
|
<option value="15">15 minutes</option>
|
||||||
|
<option value="30">30 minutes</option>
|
||||||
|
<option value="60">60 minutes</option>
|
||||||
|
<option value="240">4 hours</option>
|
||||||
|
<option value="720">12 hours</option>
|
||||||
|
<option value="1440">24 hours</option>
|
||||||
|
</select>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-group">
|
||||||
|
<div class="settings-heading">
|
||||||
|
<strong>Contractless API</strong>
|
||||||
|
<span>Choose the API used for lookups and transaction broadcasts.</span>
|
||||||
|
</div>
|
||||||
|
<label for="settings-testnet-api-url">Testnet API URL</label>
|
||||||
|
<input id="settings-testnet-api-url" type="url" value="https://api.contractless.dev" />
|
||||||
|
<label for="settings-mainnet-api-url">Mainnet API URL</label>
|
||||||
|
<input id="settings-mainnet-api-url" type="url" placeholder="Not configured" />
|
||||||
|
<span class="settings-help">Mainnet wallets can be created and backed up before a mainnet API is configured.</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button id="save-wallet-settings" class="primary settings-save" type="button">Save settings</button>
|
||||||
|
|
||||||
|
<section class="settings-group backup-settings">
|
||||||
|
<div class="settings-heading">
|
||||||
|
<strong>Active wallet backup</strong>
|
||||||
|
<span>These actions require the active wallet encryption key.</span>
|
||||||
|
</div>
|
||||||
|
<button class="settings-action" data-backup-action="image" type="button">
|
||||||
|
<span class="settings-action-icon">▧</span>
|
||||||
|
<span><strong>Save wallet image</strong><small>Download an encrypted PNG backup</small></span>
|
||||||
|
<span>›</span>
|
||||||
|
</button>
|
||||||
|
<button class="settings-action" data-backup-action="private" type="button">
|
||||||
|
<span class="settings-action-icon">⌘</span>
|
||||||
|
<span><strong>View private key</strong><small>Reveal and copy the active private key</small></span>
|
||||||
|
<span>›</span>
|
||||||
|
</button>
|
||||||
|
<button class="settings-action" data-backup-action="print" type="button">
|
||||||
|
<span class="settings-action-icon">▤</span>
|
||||||
|
<span><strong>Print wallet backup</strong><small>Private key and encryption key</small></span>
|
||||||
|
<span>›</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="private-key-result" class="private-key-result" hidden>
|
||||||
|
<div>
|
||||||
|
<strong>Private key</strong>
|
||||||
|
<button id="clear-private-key" type="button" title="Clear private key" aria-label="Clear private key">×</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="revealed-private-key" readonly></textarea>
|
||||||
|
<button id="copy-private-key" class="secondary" type="button">Copy private key</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="backup-key-backdrop" class="modal-backdrop" hidden></div>
|
||||||
|
<section id="backup-key-modal" class="backup-key-modal" hidden aria-modal="true" role="dialog" aria-labelledby="backup-key-title">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<strong id="backup-key-title">Confirm encryption key</strong>
|
||||||
|
<span>Required to decrypt this wallet locally.</span>
|
||||||
|
</div>
|
||||||
|
<button id="close-backup-key" type="button" title="Cancel" aria-label="Cancel">×</button>
|
||||||
|
</header>
|
||||||
|
<div>
|
||||||
|
<label for="backup-key-input">Encryption key</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="backup-key-input" type="password" autocomplete="current-password" />
|
||||||
|
<button class="reveal" type="button" data-input="backup-key-input">Show</button>
|
||||||
|
</div>
|
||||||
|
<button id="confirm-backup-key" class="primary" type="button">Continue</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p id="notice" role="alert" aria-live="polite"></p>
|
||||||
|
</main>
|
||||||
|
<script type="module" src="./popup.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 34px;
|
||||||
|
color: #111820;
|
||||||
|
font: 14px/1.45 Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
padding-bottom: 18px;
|
||||||
|
border-bottom: 3px solid #2167a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
header img {
|
||||||
|
width: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header p {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
color: #5c6972;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
margin: 22px 0;
|
||||||
|
border: 2px solid #9b1c1c;
|
||||||
|
padding: 12px;
|
||||||
|
color: #7a1111;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 7px;
|
||||||
|
color: #42515b;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
border: 1px solid #aebac2;
|
||||||
|
padding: 11px;
|
||||||
|
font: 10px/1.35 Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-error {
|
||||||
|
color: #9b1c1c;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
max-width: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Contractless Wallet Backup</title>
|
||||||
|
<link rel="stylesheet" href="./print.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<img src="./assets/contractless-logo.png" alt="" />
|
||||||
|
<div>
|
||||||
|
<h1>Contractless Wallet Backup</h1>
|
||||||
|
<p>Private recovery information</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p class="warning">
|
||||||
|
Keep this page private. Anyone with the private key and encryption key can
|
||||||
|
control this wallet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Wallet</h2>
|
||||||
|
<pre id="wallet-label"></pre>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Address</h2>
|
||||||
|
<pre id="wallet-address"></pre>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Private Key</h2>
|
||||||
|
<pre id="wallet-private-key"></pre>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Encryption Key</h2>
|
||||||
|
<pre id="wallet-encryption-key"></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p id="print-error" class="print-error"></p>
|
||||||
|
<script type="module" src="./print.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
type PrintableBackup = {
|
||||||
|
label: string;
|
||||||
|
address: string;
|
||||||
|
privateKey: string;
|
||||||
|
encryptionKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function initialize(): Promise<void> {
|
||||||
|
const id = new URLSearchParams(location.search).get("id");
|
||||||
|
if (!id) throw new Error("Missing printable wallet backup.");
|
||||||
|
|
||||||
|
const storageKey = `print-backup:${id}`;
|
||||||
|
const stored = await chrome.storage.session.get(storageKey);
|
||||||
|
await chrome.storage.session.remove(storageKey);
|
||||||
|
const backup = stored[storageKey] as PrintableBackup | undefined;
|
||||||
|
if (!backup) throw new Error("The printable wallet backup has expired.");
|
||||||
|
|
||||||
|
document.getElementById("wallet-label")!.textContent = backup.label;
|
||||||
|
document.getElementById("wallet-address")!.textContent = backup.address;
|
||||||
|
document.getElementById("wallet-private-key")!.textContent = backup.privateKey;
|
||||||
|
document.getElementById("wallet-encryption-key")!.textContent = backup.encryptionKey;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.focus();
|
||||||
|
window.print();
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize().catch((error: unknown) => {
|
||||||
|
document.getElementById("print-error")!.textContent =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
type Pending = {
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (reason?: unknown) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pending = new Map<string, Pending>();
|
||||||
|
|
||||||
|
window.addEventListener("message", (event) => {
|
||||||
|
if (event.source !== window || event.data?.source !== "contractless-wallet") return;
|
||||||
|
const request = pending.get(event.data.id);
|
||||||
|
if (!request) return;
|
||||||
|
pending.delete(event.data.id);
|
||||||
|
if (event.data.response?.ok) request.resolve(event.data.response.result);
|
||||||
|
else request.reject(new Error(event.data.response?.error ?? "Wallet request failed."));
|
||||||
|
});
|
||||||
|
|
||||||
|
const provider = Object.freeze({
|
||||||
|
isContractless: true,
|
||||||
|
request({ method, params }: { method: string; params?: unknown }): Promise<unknown> {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pending.set(id, { resolve, reject });
|
||||||
|
window.postMessage(
|
||||||
|
{ source: "contractless-page", id, method, params },
|
||||||
|
window.location.origin
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(window, "contractless", {
|
||||||
|
value: provider,
|
||||||
|
writable: false,
|
||||||
|
configurable: false
|
||||||
|
});
|
||||||
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
export type ContractlessNetwork = "testnet" | "mainnet";
|
||||||
|
|
||||||
|
export type StoredSettings = {
|
||||||
|
selectedNetwork: ContractlessNetwork;
|
||||||
|
testnetApiUrl: string;
|
||||||
|
mainnetApiUrl: string;
|
||||||
|
apiUrl: string;
|
||||||
|
autoLockMinutes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WalletStatus = {
|
||||||
|
exists: boolean;
|
||||||
|
unlocked: boolean;
|
||||||
|
activeWalletId?: string;
|
||||||
|
activeWalletLabel?: string;
|
||||||
|
address?: string;
|
||||||
|
publicKey?: string;
|
||||||
|
wallets: WalletSummary[];
|
||||||
|
selectedNetwork: ContractlessNetwork;
|
||||||
|
networkName: "Testnet" | "Mainnet";
|
||||||
|
networkSymbol: "CLTC" | "CLC";
|
||||||
|
testnetApiUrl: string;
|
||||||
|
mainnetApiUrl: string;
|
||||||
|
apiUrl: string;
|
||||||
|
autoLockMinutes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WalletSummary = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
address: string;
|
||||||
|
publicKey: string;
|
||||||
|
network: ContractlessNetwork;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProviderRequest = {
|
||||||
|
id: string;
|
||||||
|
method: string;
|
||||||
|
params?: unknown;
|
||||||
|
origin: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApprovalRecord = ProviderRequest & {
|
||||||
|
createdAt: number;
|
||||||
|
risk: "safe" | "low" | "high";
|
||||||
|
display?: Record<string, unknown>;
|
||||||
|
};
|
||||||