Add Wallet Standard registration for browser wallet

This commit is contained in:
AidarKC 2026-06-22 01:35:26 +04:00
parent ce2d310e8c
commit ba348dafb3
4 changed files with 249 additions and 48 deletions

View File

@ -0,0 +1,18 @@
# Wallet Standard support
- Краткое описание:
расширение `SHiNE Wallet` теперь не только внедряет legacy `window.solana`, но и регистрирует себя как `Wallet Standard` wallet для Solana dapp.
- Что проверять:
1. Перезагрузить unpacked extension.
2. Открыть dapp, который использует Wallet Standard, например `app.realms.today` на `devnet`.
3. Открыть список кошельков и убедиться, что `SHiNE Wallet` появился отдельным вариантом.
4. Проверить `connect`.
5. Проверить подпись транзакции через сценарий dapp, который использует стандартный wallet interface.
- Ожидаемый результат:
- dapp видит `SHiNE Wallet` как standard wallet, а не только как legacy Phantom-style provider.
- connect и подпись работают через тот же ESP32 approval-flow.
- Статус:
`pending`

View File

@ -12,6 +12,8 @@ Chrome-compatible Manifest V3 plugin for SHiNE wallet-session login.
- восстанавливать session через `SessionChallenge -> SessionLogin`;
- держать wallet-state в `background service worker`, а side panel использовать как UI.
- принимать не адрес сервера, а логин серверного аккаунта SHiNE и находить точный `https://...` / `wss://...` адрес через его PDA.
- внедрять legacy `window.solana` / `window.phantom.solana` provider для сайтов.
- регистрировать кошелёк как `Wallet Standard` wallet для dapp, которые ищут стандартные кошельки.
## Как загрузить локально
@ -28,6 +30,7 @@ Chrome-compatible Manifest V3 plugin for SHiNE wallet-session login.
- запросы на подпись будут следующим этапом.
- pairing-пароль, если он используется, должен генерироваться в формате `sha256$<hex>` от строки `shine-pairing|loginLower|password`.
- сторона side panel в Chromium выбирается самим браузером/пользователем; extension не закрепляет панель принудительно слева.
- для совместимости с некоторыми dapp расширение одновременно держит и legacy provider, и Wallet Standard регистрацию.
## Сборка crypto bundle

View File

@ -2,6 +2,13 @@ import { PublicKey, Transaction, VersionedTransaction } from './js/lib/vendor/so
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 = '';
@ -85,50 +92,36 @@ function deserializeSignedTransaction(base64, originalTransaction) {
return Transaction.from(bytes);
}
class ShineSolanaProvider {
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.isSHiNE = true;
this.isPhantom = true;
this.publicKey = null;
this.isConnected = false;
this._listeners = new Map();
this._legacyListeners = new Map();
this._standardListeners = new Set();
this._accounts = [];
}
on(event, handler) {
const key = String(event || '');
if (!this._listeners.has(key)) {
this._listeners.set(key, new Set());
}
this._listeners.get(key).add(handler);
return this;
get publicKeyBase58() {
return this.publicKey?.toBase58?.() || '';
}
off(event, handler) {
const bucket = this._listeners.get(String(event || ''));
if (!bucket) return this;
bucket.delete(handler);
if (!bucket.size) {
this._listeners.delete(String(event || ''));
}
return this;
}
removeListener(event, handler) {
return this.off(event, handler);
}
emit(event, payload) {
const bucket = this._listeners.get(String(event || ''));
if (!bucket?.size) return;
for (const handler of [...bucket]) {
try {
handler(payload);
} catch {}
}
get standardAccounts() {
return this._accounts.slice();
}
async connect(options = {}) {
const onlyIfTrusted = !!options?.onlyIfTrusted;
const onlyIfTrusted = !!options?.onlyIfTrusted || !!options?.silent;
if (!onlyIfTrusted) {
const confirmed = window.confirm(`Connect SHiNE Wallet to ${window.location.origin}?`);
if (!confirmed) {
@ -139,33 +132,136 @@ class ShineSolanaProvider {
const nextKey = new PublicKey(String(result?.publicKeyBase58 || '').trim());
this.publicKey = nextKey;
this.isConnected = true;
this.emit('connect', nextKey);
this.emit('accountChanged', nextKey);
return { publicKey: nextKey };
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.emit('disconnect');
this.emit('accountChanged', null);
this._accounts = [];
this.emitLegacy('disconnect');
this.emitLegacy('accountChanged', null);
this.emitStandardChange();
}
async signTransaction(transaction) {
async signTransaction(transaction, comment = '') {
if (!this.publicKey) {
await this.connect();
}
const transactionBase64 = serializeTransactionBase64(transaction);
const comment = `Site ${window.location.origin} requested transaction signature`;
const result = await createRequest('signTransaction', {
publicKeyBase58: this.publicKey?.toBase58?.() || '',
publicKeyBase58: this.publicKeyBase58,
transactionBase64,
comment,
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;
@ -183,10 +279,94 @@ class ShineSolanaProvider {
}
}
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) {
const provider = new ShineSolanaProvider();
window.solana = provider;
window.solana = legacyProvider;
window.phantom = window.phantom || {};
window.phantom.solana = provider;
window.phantom.solana = legacyProvider;
window.dispatchEvent(new Event('solana#initialized'));
}

View File

@ -1,2 +1,2 @@
client.version=1.2.231
server.version=1.2.217
client.version=1.2.232
server.version=1.2.218