Add Wallet Standard registration for browser wallet
This commit is contained in:
parent
ce2d310e8c
commit
ba348dafb3
@ -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`
|
||||||
@ -12,6 +12,8 @@ Chrome-compatible Manifest V3 plugin for SHiNE wallet-session login.
|
|||||||
- восстанавливать session через `SessionChallenge -> SessionLogin`;
|
- восстанавливать session через `SessionChallenge -> SessionLogin`;
|
||||||
- держать wallet-state в `background service worker`, а side panel использовать как UI.
|
- держать wallet-state в `background service worker`, а side panel использовать как UI.
|
||||||
- принимать не адрес сервера, а логин серверного аккаунта SHiNE и находить точный `https://...` / `wss://...` адрес через его PDA.
|
- принимать не адрес сервера, а логин серверного аккаунта 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`.
|
- pairing-пароль, если он используется, должен генерироваться в формате `sha256$<hex>` от строки `shine-pairing|loginLower|password`.
|
||||||
- сторона side panel в Chromium выбирается самим браузером/пользователем; extension не закрепляет панель принудительно слева.
|
- сторона side panel в Chromium выбирается самим браузером/пользователем; extension не закрепляет панель принудительно слева.
|
||||||
|
- для совместимости с некоторыми dapp расширение одновременно держит и legacy provider, и Wallet Standard регистрацию.
|
||||||
|
|
||||||
## Сборка crypto bundle
|
## Сборка crypto bundle
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,13 @@ import { PublicKey, Transaction, VersionedTransaction } from './js/lib/vendor/so
|
|||||||
|
|
||||||
const PAGE_REQUEST = 'shine-wallet-page-request';
|
const PAGE_REQUEST = 'shine-wallet-page-request';
|
||||||
const PAGE_RESPONSE = 'shine-wallet-page-response';
|
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) {
|
function bytesToBase64(bytes) {
|
||||||
let binary = '';
|
let binary = '';
|
||||||
@ -85,50 +92,36 @@ function deserializeSignedTransaction(base64, originalTransaction) {
|
|||||||
return Transaction.from(bytes);
|
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() {
|
constructor() {
|
||||||
this.isSHiNE = true;
|
|
||||||
this.isPhantom = true;
|
|
||||||
this.publicKey = null;
|
this.publicKey = null;
|
||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
this._listeners = new Map();
|
this._legacyListeners = new Map();
|
||||||
|
this._standardListeners = new Set();
|
||||||
|
this._accounts = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
on(event, handler) {
|
get publicKeyBase58() {
|
||||||
const key = String(event || '');
|
return this.publicKey?.toBase58?.() || '';
|
||||||
if (!this._listeners.has(key)) {
|
|
||||||
this._listeners.set(key, new Set());
|
|
||||||
}
|
|
||||||
this._listeners.get(key).add(handler);
|
|
||||||
return this;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
off(event, handler) {
|
get standardAccounts() {
|
||||||
const bucket = this._listeners.get(String(event || ''));
|
return this._accounts.slice();
|
||||||
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 {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(options = {}) {
|
async connect(options = {}) {
|
||||||
const onlyIfTrusted = !!options?.onlyIfTrusted;
|
const onlyIfTrusted = !!options?.onlyIfTrusted || !!options?.silent;
|
||||||
if (!onlyIfTrusted) {
|
if (!onlyIfTrusted) {
|
||||||
const confirmed = window.confirm(`Connect SHiNE Wallet to ${window.location.origin}?`);
|
const confirmed = window.confirm(`Connect SHiNE Wallet to ${window.location.origin}?`);
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
@ -139,33 +132,136 @@ class ShineSolanaProvider {
|
|||||||
const nextKey = new PublicKey(String(result?.publicKeyBase58 || '').trim());
|
const nextKey = new PublicKey(String(result?.publicKeyBase58 || '').trim());
|
||||||
this.publicKey = nextKey;
|
this.publicKey = nextKey;
|
||||||
this.isConnected = true;
|
this.isConnected = true;
|
||||||
this.emit('connect', nextKey);
|
this._accounts = [new ShineWalletAccount(nextKey.toBase58())];
|
||||||
this.emit('accountChanged', nextKey);
|
this.emitLegacy('connect', nextKey);
|
||||||
return { publicKey: nextKey };
|
this.emitLegacy('accountChanged', nextKey);
|
||||||
|
this.emitStandardChange();
|
||||||
|
return {
|
||||||
|
publicKey: nextKey,
|
||||||
|
accounts: this.standardAccounts,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnect() {
|
async disconnect() {
|
||||||
await createRequest('disconnect', {});
|
await createRequest('disconnect', {});
|
||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
this.publicKey = null;
|
this.publicKey = null;
|
||||||
this.emit('disconnect');
|
this._accounts = [];
|
||||||
this.emit('accountChanged', null);
|
this.emitLegacy('disconnect');
|
||||||
|
this.emitLegacy('accountChanged', null);
|
||||||
|
this.emitStandardChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
async signTransaction(transaction) {
|
async signTransaction(transaction, comment = '') {
|
||||||
if (!this.publicKey) {
|
if (!this.publicKey) {
|
||||||
await this.connect();
|
await this.connect();
|
||||||
}
|
}
|
||||||
const transactionBase64 = serializeTransactionBase64(transaction);
|
const transactionBase64 = serializeTransactionBase64(transaction);
|
||||||
const comment = `Site ${window.location.origin} requested transaction signature`;
|
|
||||||
const result = await createRequest('signTransaction', {
|
const result = await createRequest('signTransaction', {
|
||||||
publicKeyBase58: this.publicKey?.toBase58?.() || '',
|
publicKeyBase58: this.publicKeyBase58,
|
||||||
transactionBase64,
|
transactionBase64,
|
||||||
comment,
|
comment: String(comment || '').trim() || `Site ${window.location.origin} requested transaction signature`,
|
||||||
});
|
});
|
||||||
return deserializeSignedTransaction(String(result?.signedTransactionBase64 || ''), transaction);
|
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 = {}) {
|
async request(args = {}) {
|
||||||
const method = String(args?.method || '');
|
const method = String(args?.method || '');
|
||||||
const params = args?.params;
|
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) {
|
if (!window.solana) {
|
||||||
const provider = new ShineSolanaProvider();
|
window.solana = legacyProvider;
|
||||||
window.solana = provider;
|
|
||||||
window.phantom = window.phantom || {};
|
window.phantom = window.phantom || {};
|
||||||
window.phantom.solana = provider;
|
window.phantom.solana = legacyProvider;
|
||||||
window.dispatchEvent(new Event('solana#initialized'));
|
window.dispatchEvent(new Event('solana#initialized'));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.231
|
client.version=1.2.232
|
||||||
server.version=1.2.217
|
server.version=1.2.218
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user