Добавить диагностику server PDA и баланс device (не проверено)

This commit is contained in:
AidarKC 2026-06-03 16:12:40 +04:00
parent ee3721dfa4
commit eeb115584d
10 changed files with 218 additions and 8 deletions

View File

@ -0,0 +1,17 @@
# Диагностика ключей server PDA и баланс device
- статус: pending
- кратко: на странице обновления server PDA добавлена сверка ожидаемых ключей с уже загруженной PDA, предупреждение о неверном пароле, кнопка показа баланса device-аккаунта и уточнение, что create/update оплачиваются с deviceKey.
## Что проверять
- На `update-server-pda.html` загрузить существующую PDA и убедиться, что видны ожидаемые `root/blockchain/device` public key.
- Ввести правильный пароль и нажать `Сгенерировать`: должно появиться сообщение, что ключи совпадают.
- Ввести неверный пароль и нажать `Сгенерировать`: должно появиться сообщение, что ключи не совпали и пароль, вероятно, неверный.
- На `create-server-pda.html` и `update-server-pda.html` нажать `Показать / обновить баланс device` и убедиться, что баланс читается по текущему `devPub`.
- Повторить `update_user_pda` после увеличения `heap frame` и проверить, ушла ли ошибка `memory allocation failed`.
## Ожидаемый результат
- Пользователь видит, какие именно public key должны получиться для загруженной PDA.
- Ошибка неправильного пароля выявляется до отправки транзакции.
- Баланс device-кошелька читается прямо со страницы.
- Если проблема `OOM` была только в размере heap frame/compute budget клиента, `update_user_pda` начинает проходить.

View File

@ -82,10 +82,17 @@ anchor deploy -p shine_users
- seed: `shine_users_economy_config` - seed: `shine_users_economy_config`
- program: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm` - program: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm`
## Кто оплачивает create/update user_pda
- И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с `deviceKey`.
- В UI это означает, что Solana fee payer всегда берётся из `device`-ключа пользователя/сервера.
- `rootKey` нужен для подписи unsigned PDA-записи, но не оплачивает транзакцию.
- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес `deviceKey`.
## Важно ## Важно
- `init_users_economy_config` выполняется один раз на программу. - `init_users_economy_config` выполняется один раз на программу.
Если PDA уже создан, повторный вызов вернёт ошибку "already initialized" (это нормальное поведение). Если PDA уже создан, повторный вызов вернёт ошибку "already initialized" (это нормальное поведение).
- Серверные приватные ключи для Solana не используются: подписание делается кошельком пользователя в UI. - Серверные приватные ключи для Solana не используются как отдельный backend-wallet: в UI/server UI транзакцию оплачивает именно `deviceKey`, а содержимое записи подписывает `rootKey`.
- `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина. - `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина.
Несовпадение адреса приведёт к ошибке регистрации. Несовпадение адреса приведёт к ошибке регистрации.

View File

@ -1,2 +1,2 @@
client.version=1.2.120 client.version=1.2.121
server.version=1.2.112 server.version=1.2.113

View File

@ -913,8 +913,8 @@ export async function updateShineUserPdaOnSolana({
], ],
data: ixData, data: ixData,
}); });
const computeIx = solana.ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 }); const computeIx = solana.ComputeBudgetProgram.setComputeUnitLimit({ units: 800_000 });
const heapIx = solana.ComputeBudgetProgram.requestHeapFrame({ bytes: 131_072 }); const heapIx = solana.ComputeBudgetProgram.requestHeapFrame({ bytes: 262_144 });
const signature = await solana.sendAndConfirmTransaction( const signature = await solana.sendAndConfirmTransaction(
connection, connection,

View File

@ -29,6 +29,8 @@
.sol-adr { font-family:monospace; font-size:12px; word-break:break-all; margin-top:4px; } .sol-adr { font-family:monospace; font-size:12px; word-break:break-all; margin-top:4px; }
.sol-ht { font-size:11px; color:var(--text-muted); margin-top:4px; } .sol-ht { font-size:11px; color:var(--text-muted); margin-top:4px; }
.sol-topup-btn { margin-top:10px; width:100%; } .sol-topup-btn { margin-top:10px; width:100%; }
.sol-balance-btn { margin-top:10px; width:100%; }
.sol-balance { font-size:11px; color:var(--text-muted); margin-top:8px; word-break:break-all; }
</style> </style>
</head> </head>
<body> <body>
@ -112,8 +114,10 @@
<div class="sol-box" id="solBox"> <div class="sol-box" id="solBox">
<div class="sol-ttl">Положите SOL на этот адрес перед регистрацией:</div> <div class="sol-ttl">Положите SOL на этот адрес перед регистрацией:</div>
<div class="sol-adr" id="solAdr"></div> <div class="sol-adr" id="solAdr"></div>
<div class="sol-ht">Это Solana-адрес device-ключа (public key в base58 = Solana-адрес). С него оплачивается создание PDA.</div> <div class="sol-ht">Это Solana-адрес device-ключа (public key в base58 = Solana-адрес). С него оплачиваются и создание, и обновление PDA.</div>
<button class="btn-secondary sol-topup-btn" id="btnTopupDevnet" type="button">Открыть пополнение DEVNET</button> <button class="btn-secondary sol-topup-btn" id="btnTopupDevnet" type="button">Открыть пополнение DEVNET</button>
<button class="btn-secondary sol-balance-btn" id="btnRefreshBalance" type="button">Показать / обновить баланс device</button>
<div class="sol-balance" id="deviceBalance">Баланс device ещё не запрашивался.</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -8,6 +8,7 @@ import {
deriveKeyBundleFromPassword, deriveKeyBundleFromPassword,
fillKeyFields, fillKeyFields,
parseLoginList, parseLoginList,
refreshDeviceBalance,
setGenMessage, setGenMessage,
setStatus, setStatus,
setupPasswordEye, setupPasswordEye,
@ -40,6 +41,21 @@ $('btnTopupDevnet').addEventListener('click', () => {
} }
}); });
$('btnRefreshBalance').addEventListener('click', async () => {
try {
$('btnRefreshBalance').disabled = true;
await refreshDeviceBalance({
endpoint: $('endpoint').value,
deviceAddress: $('devPub').value,
targetNode: $('deviceBalance'),
});
} catch (error) {
setStatus($('status'), error?.message || String(error), 'error');
} finally {
$('btnRefreshBalance').disabled = false;
}
});
$('btnGen').addEventListener('click', async () => { $('btnGen').addEventListener('click', async () => {
clearGenMessage($('genMsg')); clearGenMessage($('genMsg'));
clearStatus($('status')); clearStatus($('status'));
@ -53,7 +69,8 @@ $('btnGen').addEventListener('click', async () => {
}); });
fillKeyFields(fieldMap, keyBundle, masterSecret32); fillKeyFields(fieldMap, keyBundle, masterSecret32);
updateSolAddress(fieldMap); updateSolAddress(fieldMap);
setGenMessage($('genMsg'), 'Ключи и master secret сгенерированы из логина и пароля.', 'ok'); $('deviceBalance').textContent = 'Теперь можно проверить баланс device-аккаунта.';
setGenMessage($('genMsg'), 'Ключи и master secret сгенерированы из логина и пароля. Транзакцию оплатит device-ключ.', 'ok');
} catch (error) { } catch (error) {
setGenMessage($('genMsg'), error?.message || String(error), 'err'); setGenMessage($('genMsg'), error?.message || String(error), 'err');
} finally { } finally {

View File

@ -7,6 +7,7 @@ import {
deriveMasterSecretFromPassword, deriveMasterSecretFromPassword,
publicKeyB64FromPkcs8Ed25519, publicKeyB64FromPkcs8Ed25519,
} from '../../js/services/crypto-utils.js'; } from '../../js/services/crypto-utils.js';
import { formatSol, getBalanceSol } from '../../js/services/solana-wallet-service.js';
const LOGIN_RE = /^[a-z0-9_]{1,20}$/; const LOGIN_RE = /^[a-z0-9_]{1,20}$/;
const ED25519_PKCS8_PREFIX = new Uint8Array([ const ED25519_PKCS8_PREFIX = new Uint8Array([
@ -189,12 +190,56 @@ export function updateSolAddress(fieldMap) {
} }
} }
export function setText(idOrNode, value) {
const node = typeof idOrNode === 'string' ? $(idOrNode) : idOrNode;
if (node) node.textContent = String(value || '');
}
export function wireDeviceAddressPreview(fieldMap) { export function wireDeviceAddressPreview(fieldMap) {
const update = () => updateSolAddress(fieldMap); const update = () => updateSolAddress(fieldMap);
$(fieldMap.devPub).addEventListener('input', update); $(fieldMap.devPub).addEventListener('input', update);
update(); update();
} }
export function publicKeyBytesToBase58(value) {
return bytesToBase58(value instanceof Uint8Array ? value : new Uint8Array(value || []));
}
export function compareExpectedPublicKeys(expected, actual) {
const exp = String(expected || '').trim();
const act = String(actual || '').trim();
return {
matches: Boolean(exp) && Boolean(act) && exp === act,
expected: exp,
actual: act,
};
}
export function summarizeKeyComparison(resultMap) {
const labels = {
root: 'root',
blockchain: 'blockchain',
device: 'device',
};
const mismatches = Object.entries(resultMap)
.filter(([, result]) => !result.matches)
.map(([key]) => labels[key] || key);
return {
allMatch: mismatches.length === 0,
mismatches,
};
}
export async function refreshDeviceBalance({ endpoint, deviceAddress, targetNode }) {
const cleanEndpoint = String(endpoint || '').trim();
const cleanAddress = String(deviceAddress || '').trim();
if (!cleanEndpoint) throw new Error('Укажите Solana endpoint');
if (!cleanAddress) throw new Error('Сначала укажите device-адрес');
const balance = await getBalanceSol({ endpoint: cleanEndpoint, address: cleanAddress });
setText(targetNode, `Баланс device: ${formatSol(balance.sol, 6)} SOL (${balance.lamports} lamports)`);
return balance;
}
export function buildDevnetTopupUrl(walletAddress) { export function buildDevnetTopupUrl(walletAddress) {
const cleanWallet = String(walletAddress || '').trim(); const cleanWallet = String(walletAddress || '').trim();
const url = new URL('../devnet-topup-view', window.location.href); const url = new URL('../devnet-topup-view', window.location.href);

View File

@ -4,15 +4,20 @@ import {
buildKeyBundleFromForm, buildKeyBundleFromForm,
clearGenMessage, clearGenMessage,
clearStatus, clearStatus,
compareExpectedPublicKeys,
deriveKeyBundleFromPassword, deriveKeyBundleFromPassword,
fillKeyFields, fillKeyFields,
formatBigInt, formatBigInt,
formatTimestamp, formatTimestamp,
openDevnetTopup, openDevnetTopup,
parseLoginList, parseLoginList,
publicKeyBytesToBase58,
refreshDeviceBalance,
setGenMessage, setGenMessage,
setStatus, setStatus,
setText,
setupPasswordEye, setupPasswordEye,
summarizeKeyComparison,
updateSolAddress, updateSolAddress,
validateLoginOrThrow, validateLoginOrThrow,
wireDeviceAddressPreview, wireDeviceAddressPreview,
@ -32,9 +37,62 @@ const fieldMap = {
let currentPda = null; let currentPda = null;
function resetExpectedKeysUi() {
$('expectedKeysBox').style.display = 'none';
setText('expectedRootPub', '');
setText('expectedBchPub', '');
setText('expectedDevPub', '');
clearGenMessage($('expectedKeysStatus'));
setText('deviceBalance', 'Баланс device ещё не запрашивался.');
}
function renderExpectedKeys(parsed) {
$('expectedKeysBox').style.display = 'block';
setText('expectedRootPub', publicKeyBytesToBase58(parsed.rootKey));
setText('expectedBchPub', publicKeyBytesToBase58(parsed.blockchain.blockchainPublicKey));
setText('expectedDevPub', publicKeyBytesToBase58(parsed.deviceKey));
setGenMessage($('expectedKeysStatus'), 'После генерации ключей этот блок покажет, совпадают ли они с уже записанной PDA.', 'warn');
}
function compareCurrentFormKeysWithPda() {
if (!currentPda) throw new Error('Сначала загрузите PDA');
const blockchainActual = String($('bchPub').value || '').trim();
return {
resultMap: {
root: compareExpectedPublicKeys(publicKeyBytesToBase58(currentPda.rootKey), $('rootPub').value),
blockchain: blockchainActual
? compareExpectedPublicKeys(publicKeyBytesToBase58(currentPda.blockchain.blockchainPublicKey), blockchainActual)
: { matches: true, expected: publicKeyBytesToBase58(currentPda.blockchain.blockchainPublicKey), actual: '' },
device: compareExpectedPublicKeys(publicKeyBytesToBase58(currentPda.deviceKey), $('devPub').value),
},
};
}
function renderComparisonStatus() {
if (!currentPda) return false;
try {
const { resultMap } = compareCurrentFormKeysWithPda();
const summary = summarizeKeyComparison(resultMap);
if (summary.allMatch) {
setGenMessage($('expectedKeysStatus'), 'Ключи совпадают с загруженной PDA. Похоже, пароль верный.', 'ok');
return true;
}
setGenMessage(
$('expectedKeysStatus'),
`Не совпали ключи: ${summary.mismatches.join(', ')}. Похоже, пароль неверный или введены не те ключи.`,
'err',
);
return false;
} catch (error) {
setGenMessage($('expectedKeysStatus'), error?.message || String(error), 'err');
return false;
}
}
setupPasswordEye($('btnEye'), $('password')); setupPasswordEye($('btnEye'), $('password'));
wireDeviceAddressPreview(fieldMap); wireDeviceAddressPreview(fieldMap);
$('password').value = ''; $('password').value = '';
resetExpectedKeysUi();
$('btnTopupDevnet').addEventListener('click', () => { $('btnTopupDevnet').addEventListener('click', () => {
try { try {
@ -44,6 +102,21 @@ $('btnTopupDevnet').addEventListener('click', () => {
} }
}); });
$('btnRefreshBalance').addEventListener('click', async () => {
try {
$('btnRefreshBalance').disabled = true;
await refreshDeviceBalance({
endpoint: $('endpoint').value,
deviceAddress: $('devPub').value,
targetNode: $('deviceBalance'),
});
} catch (error) {
setStatus($('status'), error?.message || String(error), 'error');
} finally {
$('btnRefreshBalance').disabled = false;
}
});
$('btnGen').addEventListener('click', async () => { $('btnGen').addEventListener('click', async () => {
clearGenMessage($('genMsg')); clearGenMessage($('genMsg'));
clearStatus($('status')); clearStatus($('status'));
@ -54,7 +127,15 @@ $('btnGen').addEventListener('click', async () => {
const { masterSecret32, keyBundle } = await deriveKeyBundleFromPassword({ login, password }); const { masterSecret32, keyBundle } = await deriveKeyBundleFromPassword({ login, password });
fillKeyFields(fieldMap, keyBundle, masterSecret32); fillKeyFields(fieldMap, keyBundle, masterSecret32);
updateSolAddress(fieldMap); updateSolAddress(fieldMap);
setGenMessage($('genMsg'), 'Ключи и master secret сгенерированы из логина и пароля.', 'ok'); $('deviceBalance').textContent = 'Теперь можно проверить баланс device-аккаунта.';
const keysOk = renderComparisonStatus();
setGenMessage(
$('genMsg'),
keysOk
? 'Ключи и master secret сгенерированы из логина и пароля. Device-ключ оплатит транзакцию.'
: 'Ключи сгенерированы, но не совпали с уже загруженной PDA. Скорее всего, пароль неверный.',
keysOk ? 'ok' : 'err',
);
} catch (error) { } catch (error) {
setGenMessage($('genMsg'), error?.message || String(error), 'err'); setGenMessage($('genMsg'), error?.message || String(error), 'err');
} finally { } finally {
@ -69,6 +150,7 @@ $('btnLoad').addEventListener('click', async () => {
currentPda = null; currentPda = null;
$('pdaInfo').style.display = 'none'; $('pdaInfo').style.display = 'none';
$('updateForm').style.display = 'none'; $('updateForm').style.display = 'none';
resetExpectedKeysUi();
try { try {
const login = validateLoginOrThrow($('login').value); const login = validateLoginOrThrow($('login').value);
const endpoint = String($('endpoint').value || '').trim(); const endpoint = String($('endpoint').value || '').trim();
@ -93,6 +175,7 @@ $('btnLoad').addEventListener('click', async () => {
$('syncServers').value = parsed.syncServers.join('\n'); $('syncServers').value = parsed.syncServers.join('\n');
$('pdaInfo').style.display = 'block'; $('pdaInfo').style.display = 'block';
$('updateForm').style.display = 'block'; $('updateForm').style.display = 'block';
renderExpectedKeys(parsed);
setStatus($('status'), 'PDA загружена. Можно менять адрес или sync_servers.', 'success'); setStatus($('status'), 'PDA загружена. Можно менять адрес или sync_servers.', 'success');
} catch (error) { } catch (error) {
setStatus($('status'), error?.message || String(error), 'error'); setStatus($('status'), error?.message || String(error), 'error');
@ -121,6 +204,9 @@ $('btnUpdate').addEventListener('click', async () => {
$('devPub').value = normalized.devPubB58; $('devPub').value = normalized.devPubB58;
$('devPriv').value = normalized.devPrivB58; $('devPriv').value = normalized.devPrivB58;
updateSolAddress(fieldMap); updateSolAddress(fieldMap);
if (!renderComparisonStatus()) {
throw new Error('Ключи не совпадают с загруженной PDA. Проверьте пароль или введённые ключи.');
}
setStatus($('status'), 'Отправка update_user_pda в Solana...', 'info'); setStatus($('status'), 'Отправка update_user_pda в Solana...', 'info');
const result = await updateServerOnSolana({ const result = await updateServerOnSolana({

View File

@ -30,6 +30,15 @@
.sol-ht { font-size:11px; color:var(--text-muted); margin-top:4px; } .sol-ht { font-size:11px; color:var(--text-muted); margin-top:4px; }
.muted { font-size:12px; color:var(--text-muted); margin-bottom:14px; line-height:1.6; } .muted { font-size:12px; color:var(--text-muted); margin-bottom:14px; line-height:1.6; }
.sol-topup-btn { margin-top:10px; width:100%; } .sol-topup-btn { margin-top:10px; width:100%; }
.sol-balance-btn { margin-top:10px; width:100%; }
.sol-balance { font-size:11px; color:var(--text-muted); margin-top:8px; word-break:break-all; }
.expected-card { margin-top:14px; background:#111520; border:1px solid #243147; border-radius:var(--radius); padding:12px 14px; }
.expected-ttl { font-size:12px; font-weight:600; color:#9dc4ff; margin-bottom:8px; }
.expected-row { margin-bottom:8px; }
.expected-row:last-child { margin-bottom:0; }
.expected-lbl { font-size:11px; color:var(--text-muted); margin-bottom:4px; }
.expected-val { font-family:monospace; font-size:11px; word-break:break-all; }
.gen-msg.warn { display:block; background:#2f2614; border:1px solid #5f4b22; color:#ffd37a; }
</style> </style>
</head> </head>
<body> <body>
@ -128,8 +137,27 @@
<div class="sol-adr" id="solAdr"></div> <div class="sol-adr" id="solAdr"></div>
<div class="sol-ht">Это Solana-адрес (base58) device-ключа. С него оплачивается транзакция.</div> <div class="sol-ht">Это Solana-адрес (base58) device-ключа. С него оплачивается транзакция.</div>
<button class="btn-secondary sol-topup-btn" id="btnTopupDevnet" type="button">Открыть пополнение DEVNET</button> <button class="btn-secondary sol-topup-btn" id="btnTopupDevnet" type="button">Открыть пополнение DEVNET</button>
<button class="btn-secondary sol-balance-btn" id="btnRefreshBalance" type="button">Показать / обновить баланс device</button>
<div class="sol-balance" id="deviceBalance">Баланс device ещё не запрашивался.</div>
</div> </div>
</div> </div>
<div class="expected-card" id="expectedKeysBox" style="display:none">
<div class="expected-ttl">Какие ключи ожидаются по уже загруженной PDA</div>
<div class="expected-row">
<div class="expected-lbl">Ожидаемый root public key</div>
<div class="expected-val" id="expectedRootPub"></div>
</div>
<div class="expected-row">
<div class="expected-lbl">Ожидаемый blockchain public key</div>
<div class="expected-val" id="expectedBchPub"></div>
</div>
<div class="expected-row">
<div class="expected-lbl">Ожидаемый device public key</div>
<div class="expected-val" id="expectedDevPub"></div>
</div>
<div class="gen-msg" id="expectedKeysStatus"></div>
</div>
</div> </div>
<div class="btn-row"> <div class="btn-row">

View File

@ -32,6 +32,12 @@
Один логин соответствует одной `user_pda`. Один логин соответствует одной `user_pda`.
## 2.1. Кто оплачивает create/update PDA
- Инструкции `create_user_pda` и `update_user_pda` оплачиваются с `device_key`.
- `root_key` используется для подписи unsigned части записи через Ed25519 instruction и не является fee payer.
- Для server PDA это правило то же самое: пополнять SOL нужно на адрес `device_key`.
## 3. Общие правила кодирования ## 3. Общие правила кодирования
- Числа кодируются в Little Endian. - Числа кодируются в Little Endian.