Initial Contractless Web3 wallet releases

This commit is contained in:
viraladmin 2026-08-02 15:08:45 -06:00
commit cd531a2218
108 changed files with 20488 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
core/target/
extension/node_modules/
extension/web-ext-artifacts/
tests/storage/dual-signatures/

131
README.md Normal file
View File

@ -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.

1066
core/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

40
core/Cargo.toml Normal file
View File

@ -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"

84
core/README.md Normal file
View File

@ -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.

131
core/src/address.rs Normal file
View File

@ -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());
}
}

54
core/src/crypto.rs Normal file
View File

@ -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)
}

30
core/src/error.rs Normal file
View File

@ -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())
}
}

264
core/src/legacy_wallet.rs Normal file
View File

@ -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());
}
}

347
core/src/lib.rs Normal file
View File

@ -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(&registry::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(&registry::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(&registry::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(&registry::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())
}

263
core/src/message.rs Normal file
View File

@ -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());
}
}

1459
core/src/registry.rs Normal file

File diff suppressed because it is too large Load Diff

216
core/src/transfer.rs Normal file
View File

@ -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);
}
}

338
core/src/wallet.rs Normal file
View File

@ -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());
}
}

206
extension/README.md Normal file
View File

@ -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
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -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}}

3
extension/dist/background.js vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@ -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}}

File diff suppressed because one or more lines are too long

View File

@ -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}}

3
extension/dist/chrome/background.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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)}})();

1
extension/dist/chrome/content.js vendored Normal file
View File

@ -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)})});

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 707 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@ -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">&#10003;</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>

View File

@ -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());

45
extension/dist/chrome/manifest.json vendored Normal file
View File

@ -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/*"]
}
]
}

654
extension/dist/chrome/popup.html vendored Normal file
View File

@ -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">&larr;</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">&#8635;</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">&larr;</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">&rarr;</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">&larr;</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>

8
extension/dist/chrome/popup.js vendored Normal file

File diff suppressed because one or more lines are too long

44
extension/dist/chrome/print.html vendored Normal file
View File

@ -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>

1
extension/dist/chrome/print.js vendored Normal file
View File

@ -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)});

1
extension/dist/chrome/provider.js vendored Normal file
View File

@ -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});

File diff suppressed because one or more lines are too long

View File

@ -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)}})();

1
extension/dist/content.js vendored Normal file
View File

@ -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)})});

BIN
extension/dist/contractless-logo.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@ -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}}

File diff suppressed because one or more lines are too long

View File

@ -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}}

3
extension/dist/firefox/background.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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)}})();

1
extension/dist/firefox/content.js vendored Normal file
View File

@ -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)})});

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 707 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@ -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">&#10003;</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>

View File

@ -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());

57
extension/dist/firefox/manifest.json vendored Normal file
View File

@ -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/*"]
}
]
}

654
extension/dist/firefox/popup.html vendored Normal file
View File

@ -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">&larr;</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">&#8635;</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">&larr;</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">&rarr;</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">&larr;</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>

8
extension/dist/firefox/popup.js vendored Normal file

File diff suppressed because one or more lines are too long

44
extension/dist/firefox/print.html vendored Normal file
View File

@ -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>

1
extension/dist/firefox/print.js vendored Normal file
View File

@ -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)});

1
extension/dist/firefox/provider.js vendored Normal file
View File

@ -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});

45
extension/dist/manifest.json vendored Normal file
View File

@ -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/*"]
}
]
}

635
extension/dist/popup.html vendored Normal file
View File

@ -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">&larr;</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">&#8635;</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">&larr;</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">&rarr;</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">&larr;</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>

8
extension/dist/popup.js vendored Normal file

File diff suppressed because one or more lines are too long

44
extension/dist/print.html vendored Normal file
View File

@ -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>

1
extension/dist/print.js vendored Normal file
View File

@ -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)});

1
extension/dist/provider.js vendored Normal file
View File

@ -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});

29
extension/package.json Normal file
View File

@ -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"
}
}

3418
extension/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,4 @@
allowBuilds:
esbuild: true
onlyBuiltDependencies:
- esbuild

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 707 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

1655
extension/src/background.ts Normal file

File diff suppressed because it is too large Load Diff

25
extension/src/content.ts Normal file
View File

@ -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
);
}
);
});

12
extension/src/core.ts Normal file
View File

@ -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;
}

View File

@ -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; }
}

View File

@ -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">&#10003;</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>

View File

@ -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());

View File

@ -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/*"]
}
]
}

View File

@ -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/*"]
}
]
}

652
extension/src/popup.html Normal file
View File

@ -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">&larr;</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">&#8635;</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">&larr;</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">&rarr;</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">&larr;</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>

2293
extension/src/popup.ts Normal file

File diff suppressed because it is too large Load Diff

73
extension/src/print.css Normal file
View File

@ -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;
}
}

43
extension/src/print.html Normal file
View File

@ -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>

32
extension/src/print.ts Normal file
View File

@ -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);
});

36
extension/src/provider.ts Normal file
View File

@ -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
});

48
extension/src/types.ts Normal file
View File

@ -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>;
};

2076
extension/src/wallet.css Normal file

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More