Skip to content

Commit 68df1d3

Browse files
committed
improvements
1 parent d991994 commit 68df1d3

11 files changed

Lines changed: 202 additions & 347 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
"@runonflux/aa-schnorr-multisig-sdk": "1.2.1",
4040
"@runonflux/flux-sdk": "1.1.0",
4141
"@runonflux/react-native-step-indicator": "1.0.0",
42-
"@runonflux/solana-multisig": "0.3.0",
42+
"@runonflux/solana-multisig": "0.8.1",
4343
"@runonflux/utxo-lib": "1.0.2",
4444
"@scure/bip32": "2.0.1",
4545
"@scure/bip39": "2.0.1",

src/components/SyncSuccess/SyncSuccess.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
import Icon from 'react-native-vector-icons/Feather';
1111
import { useTranslation } from 'react-i18next';
1212
import { useTheme } from '../../hooks';
13-
import { backends } from '@storage/backends';
13+
import { explorerAddressUrl } from '../../lib/explorerUrl';
1414
import { cryptos } from '../../types';
1515
import * as Keychain from 'react-native-keychain';
1616
import Toast from 'react-native-toast-message';
@@ -95,10 +95,7 @@ const SyncSuccess = (props: {
9595

9696
const openExplorer = () => {
9797
console.log('Open Explorer');
98-
const backendConfig = backends()[props.chain];
99-
Linking.openURL(
100-
`https://${backendConfig.explorer ?? backendConfig.node}/address/${chainAddress}`,
101-
);
98+
Linking.openURL(explorerAddressUrl(props.chain, chainAddress));
10299
};
103100

104101
return (

src/components/TxSent/TxSent.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
import Icon from 'react-native-vector-icons/Feather';
1111
import { useTranslation } from 'react-i18next';
1212
import { useTheme } from '../../hooks';
13-
import { backends } from '@storage/backends';
13+
import { explorerTxUrl } from '../../lib/explorerUrl';
1414
import BlurOverlay from '../../BlurOverlay';
1515

1616
import { cryptos } from '../../types';
@@ -31,10 +31,7 @@ const TxSent = (props: {
3131

3232
const openExplorer = () => {
3333
console.log('Open Explorer');
34-
const backendConfig = backends()[props.chain];
35-
Linking.openURL(
36-
`https://${backendConfig.explorer ?? backendConfig.node}/tx/${props.txid}`,
37-
);
34+
Linking.openURL(explorerTxUrl(props.chain, props.txid));
3835
};
3936

4037
return (

src/lib/constructTx.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -392,22 +392,26 @@ export async function signAndBroadcastEVM(
392392
// ============================================================================
393393

394394
/**
395-
* Co-sign a partially-signed Solana tx and submit to the relay's paymaster
396-
* broadcast endpoint (which adds the feePayer signature and broadcasts).
395+
* UTXO/EVM-style Solana co-sign + broadcast on the Key device.
397396
*
398-
* Used by ssp-key when it receives an action `tx` for `chainType === 'sol'`.
399-
* Wallet has already partial-signed; key adds its own partial-sig (member
400-
* ix authorization). The relay paymaster pays tx fees + auto-tops-up
401-
* member signers for proposal rent.
397+
* Wallet partial-signs the outer tx with its leaf and posts the serialized
398+
* tx as a bare base64 string in the standard `tx` action. Key adds its own
399+
* leaf signature and broadcasts via the relay's paymaster endpoint,
400+
* mirroring how UTXO/EVM `tx` actions are handled.
402401
*
403-
* Returns the broadcast signature.
402+
* Same flow for first / subsequent sends — when the multisig PDA isn't
403+
* initialized yet, wallet's tx contains a leading permissionless
404+
* `initialize_multisig` ix (no member sigs required), and Key never has
405+
* to know the difference.
406+
*
407+
* Returns the broadcast signature (used as txid for the existing TxSent UI).
404408
*/
405409
export async function cosignAndBroadcastSOLTransaction(opts: {
406410
chain: keyof cryptos;
407411
serializedTxBase64: string;
408412
keyPubkeyBase58: string;
409-
keyPrivKeyHex: string; // 64-byte Ed25519 secret key (hex)
410-
relayHost: string; // e.g., 'relay.sspwallet.com'
413+
keyPrivKeyHex: string;
414+
relayHost: string;
411415
}): Promise<string> {
412416
const { Transaction, Keypair } = await import('@solana/web3.js');
413417

@@ -420,8 +424,6 @@ export async function cosignAndBroadcastSOLTransaction(opts: {
420424
const tx = Transaction.from(Buffer.from(opts.serializedTxBase64, 'base64'));
421425
tx.partialSign(keyKeypair);
422426

423-
// Submit to relay's paymaster broadcast endpoint instead of direct RPC.
424-
// The relay adds the feePayer (paymaster) signature and broadcasts.
425427
const serializedTxBase64 = tx
426428
.serialize({ requireAllSignatures: false, verifySignatures: false })
427429
.toString('base64');

src/lib/explorerUrl.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Build explorer URLs for tx and address links.
3+
*
4+
* Solana Explorer requires a `?cluster=<network>` query param to look up
5+
* non-mainnet entries — without it, devnet txs/addresses 404 silently
6+
* because the page defaults to `mainnet-beta`. UTXO/EVM explorers don't
7+
* need any extra query params.
8+
*/
9+
import { backends } from '@storage/backends';
10+
import { blockchains } from '@storage/blockchains';
11+
12+
function solanaCluster(chain: string): string | null {
13+
if (chain === 'solDevnet') return 'devnet';
14+
// future-proofing: 'sol' would be mainnet (no cluster param needed),
15+
// 'solTestnet' would be 'testnet', etc.
16+
return null;
17+
}
18+
19+
function withSolanaCluster(url: string, chain: string): string {
20+
const cluster = solanaCluster(chain);
21+
if (!cluster) return url;
22+
const sep = url.includes('?') ? '&' : '?';
23+
return `${url}${sep}cluster=${cluster}`;
24+
}
25+
26+
function explorerHost(chain: string): string {
27+
const cfg = backends()[chain];
28+
return cfg.explorer ?? cfg.node ?? '';
29+
}
30+
31+
/** Explorer URL for a transaction by signature/hash. */
32+
export function explorerTxUrl(chain: string, txid: string): string {
33+
const base = `https://${explorerHost(chain)}/tx/${txid}`;
34+
return withSolanaCluster(base, chain);
35+
}
36+
37+
/** Explorer URL for an address. */
38+
export function explorerAddressUrl(chain: string, address: string): string {
39+
const base = `https://${explorerHost(chain)}/address/${address}`;
40+
return withSolanaCluster(base, chain);
41+
}
42+
43+
export function isSolanaChain(chain: string): boolean {
44+
return blockchains[chain]?.chainType === 'sol';
45+
}

src/lib/transactions.ts

Lines changed: 57 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import BigNumber from 'bignumber.js';
2+
import QuickCrypto from 'react-native-quick-crypto';
23
import utxolib from '@runonflux/utxo-lib';
34
import { decodeFunctionData, erc20Abi } from 'viem';
45
import * as abi from '@runonflux/aa-schnorr-multisig-sdk/dist/abi';
@@ -267,7 +268,21 @@ export async function decodeTransactionForApproval(
267268
return decodedTx;
268269
}
269270
if (blockchains[chain].chainType === 'sol') {
270-
const decodedTx = await decodeSOLTransactionForApproval(rawTx, chain);
271+
// Single-roundtrip flow: payload is JSON wrapping the unsigned tx
272+
// plus init metadata. Older callers pass a bare base64 tx string.
273+
let serializedForDecode = rawTx;
274+
try {
275+
const parsed = JSON.parse(rawTx) as { unsignedTxBase64?: string };
276+
if (parsed && typeof parsed.unsignedTxBase64 === 'string') {
277+
serializedForDecode = parsed.unsignedTxBase64;
278+
}
279+
} catch {
280+
// Not JSON — fall through to decode rawTx directly.
281+
}
282+
const decodedTx = await decodeSOLTransactionForApproval(
283+
serializedForDecode,
284+
chain,
285+
);
271286
return decodedTx;
272287
}
273288
const libID = getLibId(chain);
@@ -525,34 +540,17 @@ export async function decodeEVMTransactionForApproval(
525540
}
526541
}
527542

528-
/**
529-
* Decode an SSP Solana Multisig transaction for the key device's approval
530-
* screen. The wallet hands the key a base64-encoded outer Solana Transaction
531-
* containing a `create_transaction` ix; that ix's data carries the proposal
532-
* `TransactionMessage` inline (borsh-encoded) describing the user's intended
533-
* transfer.
534-
*
535-
* Two transfers are typically present in the proposal:
536-
* 1. user transfer: vault → recipient (this is what the user is sending)
537-
* 2. paymaster reimbursement: vault → feePayer (this is the fee — the
538-
* paymaster signs the outer tx's feePayer slot and gets reimbursed
539-
* in-tx so its balance stays flat)
540-
*
541-
* We identify the fee transfer by matching destination = outer tx feePayer.
542-
* Anything else SystemProgram.transfer'd from the vault is the user's send.
543-
*
544-
* For SPL token transfers (TOKEN_PROGRAM ix), the recipient shown is the
545-
* destination ATA — Solana explorers resolve ATAs to their owner so the
546-
* user can verify on-chain that the ATA belongs to who they intended.
547-
*/
543+
// Decode an SSP Solana proposal for the approval screen. Splits the
544+
// proposal's transfers into the user's send (vault → recipient) and the
545+
// fee (vault → outer feePayer = paymaster). SPL transfers show the
546+
// destination ATA as the receiver; explorers resolve ATAs to owners.
548547
async function decodeSOLTransactionForApproval(
549548
rawTxBase64: string,
550549
chain: keyof cryptos,
551550
): Promise<tokenInfo> {
552551
try {
553552
const { Transaction, PublicKey, SystemProgram } =
554553
await import('@solana/web3.js');
555-
const { createHash } = require('crypto') as typeof import('crypto');
556554
const decimals = blockchains[chain].decimals;
557555
const tokenSymbol = blockchains[chain].symbol;
558556

@@ -562,25 +560,35 @@ async function decodeSOLTransactionForApproval(
562560
}
563561
const paymasterPubkey = tx.feePayer;
564562

565-
// Find the create_transaction ix (matches Anchor discriminator
566-
// sha256("global:create_transaction")[:8]).
567-
const createIxDiscriminator: Buffer = createHash('sha256')
568-
.update('global:create_transaction')
569-
.digest()
570-
.subarray(0, 8);
571-
const createIx = tx.instructions.find(
572-
(ix) =>
573-
ix.data.length >= 8 &&
574-
ix.data.subarray(0, 8).equals(createIxDiscriminator),
575-
);
563+
// Compute the create_transaction discriminator (first 8 bytes of
564+
// sha256("global:create_transaction")) using react-native-quick-crypto
565+
// — Node's `crypto.createHash` isn't available in RN, and utxolib's
566+
// sha256 wasn't reachable from this RN bundle either.
567+
const createIxDiscriminator: Buffer = Buffer.from(
568+
QuickCrypto.createHash('sha256')
569+
.update('global:create_transaction')
570+
.digest(),
571+
).subarray(0, 8);
572+
console.log(createIxDiscriminator);
573+
console.log(tx.instructions);
574+
// `ix.data` from `Transaction.from(...)` is typed as Buffer but the
575+
// RN runtime can deliver a plain JS array of numbers (no `.subarray`,
576+
// no `.readUInt32LE`, no `.equals`). Compare byte-by-byte using only
577+
// index access, then `Buffer.from(...)` the matched ix's data so all
578+
// the parser helpers below have a real Buffer to work with.
579+
const createIx = tx.instructions.find((ix) => {
580+
if (!ix.data || ix.data.length < 8) return false;
581+
for (let i = 0; i < 8; i++) {
582+
if ((ix.data as ArrayLike<number>)[i] !== createIxDiscriminator[i])
583+
return false;
584+
}
585+
return true;
586+
});
576587
if (!createIx) {
577588
throw new Error('Solana tx does not contain a create_transaction ix');
578589
}
579-
580-
// Borsh-decode the proposal message inline from ix data:
581-
// 8 bytes discriminator + 1 byte vault_index + TransactionMessage
582-
const data = createIx.data;
583-
let off = 8 + 1 + 3; // skip disc + vault_index + 3-byte header
590+
const data = Buffer.from(createIx.data as ArrayLike<number>);
591+
let off = 8 + 1 + 3; // skip discriminator + vault_index + 3-byte header
584592
const accountKeysLen = data.readUInt32LE(off);
585593
off += 4;
586594
const accountKeys: InstanceType<typeof PublicKey>[] = [];
@@ -605,71 +613,51 @@ async function decodeSOLTransactionForApproval(
605613
off += 1;
606614
const aiLen = data.readUInt32LE(off);
607615
off += 4;
608-
const accountIdxs = data.subarray(off, off + aiLen);
616+
const accountIdxs = Buffer.from(data.subarray(off, off + aiLen));
609617
off += aiLen;
610618
const ixDataLen = data.readUInt32LE(off);
611619
off += 4;
612-
const ixData = data.subarray(off, off + ixDataLen);
620+
const ixData = Buffer.from(data.subarray(off, off + ixDataLen));
613621
off += ixDataLen;
614622

615623
const ixProgram = accountKeys[programIdIdx];
616624
if (!ixProgram) continue;
617625

618-
// SystemProgram.transfer (native SOL).
619626
if (ixProgram.equals(SystemProgram.programId)) {
620627
if (ixData.length < 12 || ixData.readUInt32LE(0) !== 2) continue;
621628
if (accountIdxs.length < 2) continue;
622629
const fromIdx = accountIdxs[0];
623-
const toIdx = accountIdxs[1];
624-
const toPubkey = accountKeys[toIdx];
630+
const toPubkey = accountKeys[accountIdxs[1]];
625631
if (!toPubkey) continue;
626632
const amountLamports = ixData.readBigUInt64LE(4).toString();
627633
if (toPubkey.equals(paymasterPubkey)) {
628-
// Reimbursement to paymaster — this is the fee.
629634
feeBase = new BigNumber(feeBase).plus(amountLamports).toFixed();
630635
} else {
631-
// User's actual transfer.
632636
vaultPubkey = accountKeys[fromIdx];
633637
userReceiver = toPubkey.toBase58();
634638
userAmountBase = amountLamports;
635639
}
636640
continue;
637641
}
638642

639-
// SPL token transfer (TOKEN_PROGRAM):
640-
// accountIndexes: [source_ata, dest_ata, authority]
641-
// data tag 3 = Transfer (legacy), tag 12 = TransferChecked
643+
// SPL Transfer (tag 3) or TransferChecked (tag 12); accountIndexes
644+
// are [source_ata, dest_ata, authority]. Mint isn't in the proposal,
645+
// so symbol stays generic — explorer resolves ATA → mint → symbol.
642646
if (ixProgram.toBase58() === TOKEN_PROGRAM) {
643647
const tag = ixData.readUInt8(0);
644-
let tokenAmount: string | null = null;
645-
if (tag === 3 && ixData.length >= 9) {
646-
tokenAmount = ixData.readBigUInt64LE(1).toString();
647-
} else if (tag === 12 && ixData.length >= 9) {
648-
tokenAmount = ixData.readBigUInt64LE(1).toString();
649-
} else {
650-
continue;
651-
}
648+
if ((tag !== 3 && tag !== 12) || ixData.length < 9) continue;
652649
if (accountIdxs.length < 2) continue;
653-
const destAtaIdx = accountIdxs[1];
654-
const destAta = accountKeys[destAtaIdx];
650+
const destAta = accountKeys[accountIdxs[1]];
655651
if (!destAta) continue;
656652
userReceiver = destAta.toBase58();
657-
userAmountBase = tokenAmount;
658-
659-
// Look up token metadata from chain spec for symbol/decimals/contract.
660-
// The proposal doesn't carry the mint address directly in the ix
661-
// accountIndexes for legacy Transfer (only source ATA, dest ATA,
662-
// authority). Inferring the mint requires either an RPC call or
663-
// off-chain knowledge from chain spec. For now: leave userTokenSymbol
664-
// generic; explorer link resolves ATA → mint → symbol.
653+
userAmountBase = ixData.readBigUInt64LE(1).toString();
665654
userTokenSymbol = '(token)';
666655
continue;
667656
}
668657
}
669658

670-
// Convert base units to display units. For native SOL the chain decimals
671-
// apply; for SPL we'd need the mint's decimals (not retrievable from the
672-
// proposal alone — show raw base units in that case).
659+
// SPL amounts stay in base units since the proposal doesn't carry the
660+
// mint's decimals; native SOL converts via the chain's decimals.
673661
const isNative = userTokenSymbol === tokenSymbol;
674662
const displayAmount = isNative
675663
? new BigNumber(userAmountBase).dividedBy(10 ** decimals).toFixed()

src/lib/wallet.ts

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import { PublicKey } from '@solana/web3.js';
1111
import {
1212
deriveMultisigAddress as deriveSolanaMultisigAddress,
1313
deriveVaultAddress as deriveSolanaVaultAddress,
14-
createInitializationMessage,
1514
} from '@runonflux/solana-multisig';
1615
import {
1716
keyPair,
@@ -450,26 +449,6 @@ export function generateMultisigAddressSOL(
450449
};
451450
}
452451

453-
/**
454-
* Sign the SSP Solana Multisig init message off-chain. Returns base64
455-
* Ed25519 signature. See ssp-wallet's wallet.ts for the message format.
456-
*/
457-
export function signSolanaInitMessage(
458-
privKeyHex: string,
459-
walletPubkeyBase58: string,
460-
keyPubkeyBase58: string,
461-
): string {
462-
const members = [
463-
new PublicKey(walletPubkeyBase58),
464-
new PublicKey(keyPubkeyBase58),
465-
];
466-
const threshold = 2;
467-
const message = createInitializationMessage(members, threshold);
468-
const secretKey = new Uint8Array(Buffer.from(privKeyHex, 'hex'));
469-
const signature = nacl.sign.detached(message, secretKey);
470-
return Buffer.from(signature).toString('base64');
471-
}
472-
473452
// given xpriv of our party, generate keypair consisting of privateKey in WIF format and public key belonging to it
474453
export function generateAddressKeypair(
475454
xpriv: string,

0 commit comments

Comments
 (0)