373 lines
11 KiB
JavaScript
373 lines
11 KiB
JavaScript
import { PublicKey, Transaction, VersionedTransaction } from './js/lib/vendor/solana-publickey-bundle.js';
|
|
|
|
const PAGE_REQUEST = 'shine-wallet-page-request';
|
|
const PAGE_RESPONSE = 'shine-wallet-page-response';
|
|
const STANDARD_REGISTER_EVENT = 'wallet-standard:register-wallet';
|
|
const STANDARD_APP_READY_EVENT = 'wallet-standard:app-ready';
|
|
const SOLANA_CHAINS = ['solana:mainnet', 'solana:devnet', 'solana:testnet'];
|
|
const SOLANA_STANDARD_FEATURES = ['solana:signTransaction'];
|
|
const WALLET_ICON = `data:image/svg+xml;base64,${btoa(
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" fill="none"><rect width="96" height="96" rx="20" fill="#101722"/><path d="M23 28h50l-8 10H31l-8-10Z" fill="#3FB0FF"/><path d="M31 44h34l8 10H23l8-10Z" fill="#77D67A"/><path d="M23 68h50l-8-10H31l-8 10Z" fill="#A881FF"/></svg>'
|
|
)}`;
|
|
|
|
function bytesToBase64(bytes) {
|
|
let binary = '';
|
|
const chunk = 0x8000;
|
|
for (let i = 0; i < bytes.length; i += chunk) {
|
|
const slice = bytes.subarray(i, i + chunk);
|
|
binary += String.fromCharCode(...slice);
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
|
|
function base64ToBytes(value) {
|
|
const binary = atob(String(value || '').trim());
|
|
const out = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i += 1) {
|
|
out[i] = binary.charCodeAt(i);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function createProviderError(message, code = '') {
|
|
const error = new Error(String(message || 'Wallet provider error'));
|
|
if (code === 'USER_REJECTED' || code === 'NOT_TRUSTED') {
|
|
error.code = 4001;
|
|
} else if (code) {
|
|
error.code = code;
|
|
}
|
|
return error;
|
|
}
|
|
|
|
function createRequest(method, params = {}) {
|
|
const id = `shine-wallet-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
|
return new Promise((resolve, reject) => {
|
|
const onMessage = (event) => {
|
|
if (event.source !== window) return;
|
|
const data = event.data || {};
|
|
if (data?.target !== PAGE_RESPONSE || String(data?.id || '') !== id) return;
|
|
window.removeEventListener('message', onMessage);
|
|
if (!data?.ok) {
|
|
reject(createProviderError(data?.error || 'Wallet request failed', String(data?.code || '')));
|
|
return;
|
|
}
|
|
resolve(data?.result || {});
|
|
};
|
|
window.addEventListener('message', onMessage);
|
|
window.postMessage({
|
|
target: PAGE_REQUEST,
|
|
id,
|
|
method,
|
|
params,
|
|
}, window.location.origin);
|
|
});
|
|
}
|
|
|
|
function serializeTransactionBase64(transaction) {
|
|
if (!transaction || typeof transaction.serialize !== 'function') {
|
|
throw createProviderError('Unsupported transaction object', 'UNSUPPORTED_TRANSACTION');
|
|
}
|
|
let raw;
|
|
try {
|
|
raw = transaction.serialize({ requireAllSignatures: false, verifySignatures: false });
|
|
} catch {
|
|
raw = transaction.serialize();
|
|
}
|
|
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw);
|
|
return bytesToBase64(bytes);
|
|
}
|
|
|
|
function deserializeSignedTransaction(base64, originalTransaction) {
|
|
const bytes = base64ToBytes(base64);
|
|
const ctor = originalTransaction?.constructor;
|
|
if (ctor && typeof ctor.deserialize === 'function') {
|
|
return ctor.deserialize(bytes);
|
|
}
|
|
if (ctor && typeof ctor.from === 'function') {
|
|
return ctor.from(bytes);
|
|
}
|
|
if (typeof originalTransaction?.version === 'number') {
|
|
return VersionedTransaction.deserialize(bytes);
|
|
}
|
|
return Transaction.from(bytes);
|
|
}
|
|
|
|
class ShineWalletAccount {
|
|
constructor(publicKeyBase58) {
|
|
this.address = publicKeyBase58;
|
|
this.publicKey = new Uint8Array(new PublicKey(publicKeyBase58).toBytes());
|
|
this.chains = SOLANA_CHAINS.slice();
|
|
this.features = SOLANA_STANDARD_FEATURES.slice();
|
|
this.label = 'SHiNE Wallet';
|
|
this.icon = WALLET_ICON;
|
|
}
|
|
}
|
|
|
|
class ShineProviderCore {
|
|
constructor() {
|
|
this.publicKey = null;
|
|
this.isConnected = false;
|
|
this._legacyListeners = new Map();
|
|
this._standardListeners = new Set();
|
|
this._accounts = [];
|
|
}
|
|
|
|
get publicKeyBase58() {
|
|
return this.publicKey?.toBase58?.() || '';
|
|
}
|
|
|
|
get standardAccounts() {
|
|
return this._accounts.slice();
|
|
}
|
|
|
|
async connect(options = {}) {
|
|
const onlyIfTrusted = !!options?.onlyIfTrusted || !!options?.silent;
|
|
if (!onlyIfTrusted) {
|
|
const confirmed = window.confirm(`Connect SHiNE Wallet to ${window.location.origin}?`);
|
|
if (!confirmed) {
|
|
throw createProviderError('User rejected wallet connection', 'USER_REJECTED');
|
|
}
|
|
}
|
|
const result = await createRequest('connect', { onlyIfTrusted });
|
|
const nextKey = new PublicKey(String(result?.publicKeyBase58 || '').trim());
|
|
this.publicKey = nextKey;
|
|
this.isConnected = true;
|
|
this._accounts = [new ShineWalletAccount(nextKey.toBase58())];
|
|
this.emitLegacy('connect', nextKey);
|
|
this.emitLegacy('accountChanged', nextKey);
|
|
this.emitStandardChange();
|
|
return {
|
|
publicKey: nextKey,
|
|
accounts: this.standardAccounts,
|
|
};
|
|
}
|
|
|
|
async disconnect() {
|
|
await createRequest('disconnect', {});
|
|
this.isConnected = false;
|
|
this.publicKey = null;
|
|
this._accounts = [];
|
|
this.emitLegacy('disconnect');
|
|
this.emitLegacy('accountChanged', null);
|
|
this.emitStandardChange();
|
|
}
|
|
|
|
async signTransaction(transaction, comment = '') {
|
|
if (!this.publicKey) {
|
|
await this.connect();
|
|
}
|
|
const transactionBase64 = serializeTransactionBase64(transaction);
|
|
const result = await createRequest('signTransaction', {
|
|
publicKeyBase58: this.publicKeyBase58,
|
|
transactionBase64,
|
|
comment: String(comment || '').trim() || `Site ${window.location.origin} requested transaction signature`,
|
|
});
|
|
return deserializeSignedTransaction(String(result?.signedTransactionBase64 || ''), transaction);
|
|
}
|
|
|
|
async signTransactionBytes(transactionBytes, comment = '') {
|
|
if (!this.publicKey) {
|
|
await this.connect();
|
|
}
|
|
const result = await createRequest('signTransaction', {
|
|
publicKeyBase58: this.publicKeyBase58,
|
|
transactionBase64: bytesToBase64(transactionBytes),
|
|
comment: String(comment || '').trim() || `Site ${window.location.origin} requested transaction signature`,
|
|
});
|
|
return base64ToBytes(String(result?.signedTransactionBase64 || '').trim());
|
|
}
|
|
|
|
onLegacy(event, handler) {
|
|
const key = String(event || '');
|
|
if (!this._legacyListeners.has(key)) {
|
|
this._legacyListeners.set(key, new Set());
|
|
}
|
|
this._legacyListeners.get(key).add(handler);
|
|
return this;
|
|
}
|
|
|
|
offLegacy(event, handler) {
|
|
const key = String(event || '');
|
|
const bucket = this._legacyListeners.get(key);
|
|
if (!bucket) return this;
|
|
bucket.delete(handler);
|
|
if (!bucket.size) this._legacyListeners.delete(key);
|
|
return this;
|
|
}
|
|
|
|
emitLegacy(event, payload) {
|
|
const bucket = this._legacyListeners.get(String(event || ''));
|
|
if (!bucket?.size) return;
|
|
for (const handler of [...bucket]) {
|
|
try {
|
|
handler(payload);
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
onStandardChange(listener) {
|
|
this._standardListeners.add(listener);
|
|
return () => {
|
|
this._standardListeners.delete(listener);
|
|
};
|
|
}
|
|
|
|
emitStandardChange() {
|
|
const properties = { accounts: this.standardAccounts };
|
|
for (const listener of [...this._standardListeners]) {
|
|
try {
|
|
listener(properties);
|
|
} catch {}
|
|
}
|
|
}
|
|
}
|
|
|
|
class ShineSolanaProvider {
|
|
constructor(core) {
|
|
this.core = core;
|
|
this.isSHiNE = true;
|
|
this.isPhantom = true;
|
|
}
|
|
|
|
get publicKey() {
|
|
return this.core.publicKey;
|
|
}
|
|
|
|
get isConnected() {
|
|
return this.core.isConnected;
|
|
}
|
|
|
|
on(event, handler) {
|
|
return this.core.onLegacy(event, handler);
|
|
}
|
|
|
|
off(event, handler) {
|
|
return this.core.offLegacy(event, handler);
|
|
}
|
|
|
|
removeListener(event, handler) {
|
|
return this.off(event, handler);
|
|
}
|
|
|
|
async connect(options = {}) {
|
|
const result = await this.core.connect(options);
|
|
return { publicKey: result.publicKey };
|
|
}
|
|
|
|
async disconnect() {
|
|
await this.core.disconnect();
|
|
}
|
|
|
|
async signTransaction(transaction) {
|
|
return this.core.signTransaction(transaction);
|
|
}
|
|
|
|
async request(args = {}) {
|
|
const method = String(args?.method || '');
|
|
const params = args?.params;
|
|
if (method === 'connect') {
|
|
return this.connect(Array.isArray(params) ? params[0] : params || {});
|
|
}
|
|
if (method === 'disconnect') {
|
|
return this.disconnect();
|
|
}
|
|
if (method === 'signTransaction') {
|
|
const tx = Array.isArray(params) ? params[0] : params?.transaction || params;
|
|
return this.signTransaction(tx);
|
|
}
|
|
throw createProviderError(`Unsupported request method: ${method}`, 'UNSUPPORTED_METHOD');
|
|
}
|
|
}
|
|
|
|
class ShineStandardWallet {
|
|
constructor(core) {
|
|
this.core = core;
|
|
this.version = '1.0.0';
|
|
this.name = 'SHiNE Wallet';
|
|
this.icon = WALLET_ICON;
|
|
this.chains = SOLANA_CHAINS.slice();
|
|
this.features = {
|
|
'standard:connect': {
|
|
version: '1.0.0',
|
|
connect: async (input = {}) => {
|
|
const result = await this.core.connect({ silent: !!input?.silent });
|
|
return { accounts: result.accounts };
|
|
},
|
|
},
|
|
'standard:disconnect': {
|
|
version: '1.0.0',
|
|
disconnect: async () => {
|
|
await this.core.disconnect();
|
|
},
|
|
},
|
|
'standard:events': {
|
|
version: '1.0.0',
|
|
on: (event, listener) => {
|
|
if (event !== 'change' || typeof listener !== 'function') {
|
|
return () => {};
|
|
}
|
|
return this.core.onStandardChange(listener);
|
|
},
|
|
},
|
|
'solana:signTransaction': {
|
|
version: '1.0.0',
|
|
supportedTransactionVersions: ['legacy', 0],
|
|
signTransaction: async (...inputs) => {
|
|
const outputs = [];
|
|
for (const input of inputs) {
|
|
const accountAddress = String(input?.account?.address || '').trim();
|
|
if (accountAddress && this.core.publicKeyBase58 && accountAddress !== this.core.publicKeyBase58) {
|
|
throw createProviderError('Requested account does not match current wallet account', 'ACCOUNT_MISMATCH');
|
|
}
|
|
const comment = `Site ${window.location.origin} requested transaction signature`;
|
|
const signedTransaction = await this.core.signTransactionBytes(new Uint8Array(input.transaction), comment);
|
|
outputs.push({ signedTransaction });
|
|
}
|
|
return outputs;
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
get accounts() {
|
|
return this.core.standardAccounts;
|
|
}
|
|
}
|
|
|
|
function registerStandardWallet(wallet) {
|
|
const callback = ({ register }) => register(wallet);
|
|
try {
|
|
window.dispatchEvent(new CustomEvent(STANDARD_REGISTER_EVENT, { detail: callback }));
|
|
} catch (error) {
|
|
console.error('wallet-standard register dispatch failed', error);
|
|
}
|
|
try {
|
|
window.addEventListener(STANDARD_APP_READY_EVENT, ({ detail }) => {
|
|
try {
|
|
callback(detail);
|
|
} catch (error) {
|
|
console.error('wallet-standard app-ready callback failed', error);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('wallet-standard app-ready listener failed', error);
|
|
}
|
|
try {
|
|
window.navigator.wallets = window.navigator.wallets || [];
|
|
window.navigator.wallets.push(callback);
|
|
} catch {}
|
|
}
|
|
|
|
const core = new ShineProviderCore();
|
|
const legacyProvider = new ShineSolanaProvider(core);
|
|
const standardWallet = new ShineStandardWallet(core);
|
|
|
|
registerStandardWallet(standardWallet);
|
|
|
|
if (!window.solana) {
|
|
window.solana = legacyProvider;
|
|
window.phantom = window.phantom || {};
|
|
window.phantom.solana = legacyProvider;
|
|
window.dispatchEvent(new Event('solana#initialized'));
|
|
}
|