UNPKG

@keyban/sdk-base

Version:

Keyban Javascript SDK provides core functionalities for the MPC wallet solution, supporting web and Node.js apps with TypeScript, custom storage, and Ethereum blockchain integration.

483 lines (480 loc) 16 kB
import { KeybanClientBase, KeybanAccount } from './chunk-QXHXMZHW.js'; import './chunk-4U56B37J.js'; import { SdkError } from './chunk-7H2SLR6W.js'; import { StrKey, rpc, Horizon, xdr, TransactionBuilder, BASE_FEE, Operation, Asset, nativeToScVal } from '@stellar/stellar-sdk'; import { Buffer } from 'buffer'; import { BigNumber } from 'bignumber.js'; import { ed25519 } from '@noble/curves/ed25519.js'; var TX_VALIDITY_DURATION = 25; var STELLAR_DECIMALS = 7; BigNumber.config({ DECIMAL_PLACES: STELLAR_DECIMALS }); var StellarAccount = class extends KeybanAccount { address; publicKey; #signer; #rpcProvider; #horizonServer; constructor(api, rpcProvider, horizonServer, signer, publicKey) { super(api); this.#rpcProvider = rpcProvider; this.#horizonServer = horizonServer; this.#signer = signer; this.address = publicKey; this.publicKey = publicKey; } /** * Sign a message and return a base64-encoded signature. * @param message - The message to sign. * @returns Base64 signature string. * @example * const sig = await account.signMessage("hello"); * console.log(sig); */ async signMessage(message) { const hash = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(message) ); const signedMessage = await this.#signer.sign(Buffer.from(hash)); return signedMessage.toString("base64"); } /** * Transfer native XLM to a Stellar address. * If the recipient doesn't exist, a createAccount operation is submitted instead. * @param to - Recipient public key. * @param value - Amount in stroops (1 XLM = 10^7 stroops). * @param [fee] - Optional fee per operation in stroops; defaults to network base fee. * @returns Transaction hash string. * @throws {SdkError} {@link SdkError.types.AmountInvalid} | {@link SdkError.types.InsufficientFunds} * @example * const tx = await account.transfer("G...DEST", 100_00000n); * console.log(tx); */ async transfer(to, value, fee) { if (value <= 0n) { throw new SdkError( SdkError.types.AmountInvalid, "KeybanAccount.transfer" ); } const [account, networkDetails] = await Promise.all([ this.#rpcProvider.getAccount(this.address), this.#rpcProvider.getNetwork() ]).catch((err) => { if (err.message.includes("Account not found")) { throw new SdkError( SdkError.types.InsufficientFunds, "KeybanAccount.transfer" ); } throw new SdkError( SdkError.types.UnknownTransactionError, "KeybanAccount.transfer", err ); }); const paymentTxBuilder = new TransactionBuilder(account, { fee: fee ?? BASE_FEE, networkPassphrase: networkDetails.passphrase }); const formattedAmount = BigNumber(value).dividedBy(10 ** STELLAR_DECIMALS).toString(); const recipientExists = await this.#isAccountCreated(to); if (recipientExists) { paymentTxBuilder.addOperation( Operation.payment({ destination: to, asset: Asset.native(), amount: formattedAmount }) ); } else { paymentTxBuilder.addOperation( Operation.createAccount({ destination: to, startingBalance: formattedAmount }) ); } const paymentTx = paymentTxBuilder.setTimeout(TX_VALIDITY_DURATION).build(); const signature = await this.#signer.signDecorated(paymentTx.hash()); paymentTx.addDecoratedSignature(signature); return this.#submitTransaction(paymentTx, "transfer"); } /** * Estimate fees for a native XLM transfer. * @param _to - Recipient address (unused for fee computation). * @returns Estimation where `details` is the fee per operation as a string. */ async estimateTransfer(_to) { const operationCount = 1n; const feesPerOperation = await this.#estimateInclusionFeePerOperation(); const maxFees = feesPerOperation * operationCount; return { maxFees, details: feesPerOperation.toString() }; } /** * Transfer a Soroban token (contract) using `transfer` entrypoint. * @param params - Object with `contractAddress`, `to`, `value`, and optional `fees` (stroops per op). * @returns Transaction hash string. * @throws {SdkError} {@link SdkError.types.AddressInvalid} | {@link SdkError.types.RecipientAddressEqualsSender} | {@link SdkError.types.AmountInvalid} | {@link SdkError.types.InsufficientFunds} * @example * const tx = await account.transferERC20({ contractAddress: "C...", to: "G...DEST", value: 1_000_000n }); */ async transferERC20(params) { const { contractAddress, to, value, fees } = params; if (!StrKey.isValidEd25519PublicKey(to)) { throw new SdkError( SdkError.types.AddressInvalid, "KeybanAccount.transferERC20" ); } if (!/^[a-zA-Z0-9]+$/.test(contractAddress)) { throw new SdkError( SdkError.types.AddressInvalid, "KeybanAccount.transferERC20" ); } if (to === this.address) { throw new SdkError( SdkError.types.RecipientAddressEqualsSender, "KeybanAccount.transferERC20" ); } if (value <= 0n) { throw new SdkError( SdkError.types.AmountInvalid, "KeybanAccount.transferERC20" ); } const [account, networkDetails] = await Promise.all([ this.#rpcProvider.getAccount(this.address), this.#rpcProvider.getNetwork() ]).catch((err) => { if (err.message.includes("Account not found")) { throw new SdkError( SdkError.types.InsufficientFunds, "KeybanAccount.transferERC20" ); } throw new SdkError( SdkError.types.UnknownTransactionError, "KeybanAccount.transferERC20", err ); }); const paymentTx = new TransactionBuilder(account, { fee: fees ?? BASE_FEE, networkPassphrase: networkDetails.passphrase }).addOperation( Operation.invokeContractFunction({ contract: contractAddress, function: "transfer", args: [ nativeToScVal(this.address, { type: "address" }), // from nativeToScVal(to, { type: "address" }), // to nativeToScVal(value, { type: "i128" }) // Amount ] }) ).setTimeout(TX_VALIDITY_DURATION).build(); const preparedTransaction = await this.#rpcProvider.prepareTransaction(paymentTx).catch((err) => { throw new SdkError( SdkError.types.UnknownTransactionError, "KeybanAccount.transferERC20", err ); }); const signature = await this.#signer.signDecorated( preparedTransaction.hash() ); preparedTransaction.addDecoratedSignature(signature); return this.#submitTransaction(preparedTransaction, "transferERC20"); } /** * Estimate fees for a Soroban token transfer. * @param params - Object with `contractAddress`, `to`, `value`. * @returns Estimation where `details` is the fee per operation as a string. */ async estimateERC20Transfer(params) { const { contractAddress, to, value } = params; const [account, networkDetails] = await Promise.all([ this.#rpcProvider.getAccount(this.address), this.#rpcProvider.getNetwork() ]).catch((err) => { if (err.message.includes("Account not found")) { throw new SdkError( SdkError.types.InsufficientFunds, "KeybanAccount.estimateERC20Transfer" ); } throw new SdkError( SdkError.types.UnknownTransactionError, "KeybanAccount.estimateERC20Transfer", err ); }); const paymentTx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: networkDetails.passphrase }).addOperation( Operation.invokeContractFunction({ contract: contractAddress, function: "transfer", args: [ nativeToScVal(this.address, { type: "address" }), // from nativeToScVal(to, { type: "address" }), // to nativeToScVal(value, { type: "i128" }) // Amount ] }) ).setTimeout(TX_VALIDITY_DURATION).build(); const simulation = await this.#rpcProvider.simulateTransaction(paymentTx).catch((err) => { throw new SdkError( SdkError.types.UnknownTransactionError, "KeybanAccount.estimateERC20Transfer", err ); }); if (simulation.error) { throw new SdkError( SdkError.types.UnknownTransactionError, "KeybanAccount.estimateERC20Transfer", { simulationError: simulation.error } ); } const successfulSimulation = simulation; const inclusionFees = await this.#estimateSorobanInclusionFee(); return { maxFees: BigInt(successfulSimulation.minResourceFee) + BigInt(successfulSimulation.restorePreamble?.minResourceFee ?? 0) + inclusionFees, details: inclusionFees.toString() }; } /** * Not implemented on Stellar. * @param _params - NFT transfer parameters. * @throws {Error} Always throws because NFTs aren't supported here. */ transferNft(_params) { throw new Error("transferNft not implemented."); } /** * Not implemented on Stellar. * @param _params - NFT estimation parameters. * @throws {Error} Always throws because NFTs aren't supported here. */ estimateNftTransfer(_params) { throw new Error("estimateNftTransfer not implemented."); } async #submitTransaction(tx, functionName) { return this.#rpcProvider.sendTransaction(tx).then(async (response) => { if (response.status !== "PENDING") { if (JSON.stringify(response).includes("txInsufficientBalance")) { throw new SdkError( SdkError.types.InsufficientFunds, `KeybanAccount.${functionName}` ); } throw new SdkError( SdkError.types.UnknownTransactionError, `KeybanAccount.${functionName}`, { SendTransactionResponse: response.errorResult?.result() ?? response } ); } return this.#rpcProvider.pollTransaction(response.hash); }).then((finalResponse) => { if (JSON.stringify(finalResponse).includes("txInsufficientBalance")) { throw new SdkError( SdkError.types.InsufficientFunds, `KeybanAccount.${functionName}` ); } switch (finalResponse.status) { case rpc.Api.GetTransactionStatus.NOT_FOUND: throw new SdkError( SdkError.types.UnknownTransactionError, `KeybanAccount.${functionName}`, { transactionResponse: JSON.stringify(finalResponse) } ); case rpc.Api.GetTransactionStatus.FAILED: throw new SdkError( SdkError.types.UnknownTransactionError, `KeybanAccount.${functionName}`, { transactionResponse: finalResponse.resultXdr.result() } ); case rpc.Api.GetTransactionStatus.SUCCESS: return finalResponse.txHash; } }).catch((err) => { throw new SdkError( SdkError.types.UnknownTransactionError, `KeybanAccount.${functionName}`, err ); }); } async #isAccountCreated(address) { return this.#rpcProvider.getAccount(address).then(() => true).catch((err) => { if (err.message.includes("Account not found")) { return false; } throw err; }); } async #estimateSorobanInclusionFee() { const { sorobanInclusionFee } = await this.#rpcProvider.getFeeStats(); return BigInt(sorobanInclusionFee.p90); } async #estimateInclusionFeePerOperation() { const { fee_charged: feeCharged, ledger_capacity_usage: ledgerCapacityUsage } = await this.#horizonServer.feeStats(); const capacityUsage = parseFloat(ledgerCapacityUsage); if (capacityUsage < 0.7) { return BigInt(feeCharged.p50); } else if (capacityUsage < 0.9) { return BigInt(feeCharged.p70); } else { return BigInt(feeCharged.p90); } } }; var StellarSigner = class { type = "ed25519"; #api; #clientShare; #publicKey; constructor(api, clientShare, publicKey) { this.#api = api; this.#clientShare = clientShare; this.#publicKey = publicKey; } publicKey() { return StrKey.encodeEd25519PublicKey(this.rawPublicKey()); } rawPublicKey() { const publicKey = this.#publicKey.replace(/^0x/, ""); return Buffer.from(publicKey, "hex"); } async sign(data) { const hexSignature = await this.#api.eddsa.sign( this.#clientShare, data.toString("hex") ); return Buffer.from(hexSignature, "hex"); } async signDecorated(data) { const signature = await this.sign(data); const hint = this.signatureHint(); return new xdr.DecoratedSignature({ hint, signature }); } async signPayloadDecorated(data) { const signature = await this.sign(data); const keyHint = this.signatureHint(); let hint = Buffer.from(data.slice(-4)); if (hint.length < 4) { hint = Buffer.concat([hint, Buffer.alloc(4 - data.length, 0)]); } return new xdr.DecoratedSignature({ hint: hint.map((byte, i) => byte ^ keyHint[i]), signature }); } signatureHint() { const a = this.xdrAccountId().toXDR(); return a.slice(a.length - 4); } verify(data, signature) { try { return ed25519.verify(signature, data, this.rawPublicKey()); } catch { return false; } } xdrAccountId() { return xdr.PublicKey.publicKeyTypeEd25519(this.rawPublicKey()); } xdrPublicKey() { return xdr.PublicKey.publicKeyTypeEd25519(this.rawPublicKey()); } xdrMuxedAccount(id) { if (typeof id !== "undefined") { if (typeof id !== "string") { throw new TypeError(`expected string for ID, got ${typeof id}`); } return xdr.MuxedAccount.keyTypeMuxedEd25519( new xdr.MuxedAccountMed25519({ id: xdr.Uint64.fromString(id), ed25519: this.rawPublicKey() }) ); } return xdr.MuxedAccount.keyTypeEd25519(this.rawPublicKey()); } }; // src/stellar/client.ts var StellarClient = class extends KeybanClientBase { #metadataConfig; constructor(config, metadataConfig) { super(config, metadataConfig); this.#metadataConfig = metadataConfig ?? Promise.reject(new Error("metadataConfig is required")); } /** * Initialize the Stellar account for the configured network. * Requires `horizonUrl` in network metadata. * @returns A ready-to-use {@link StellarAccount} bound to Stellar RPC and Horizon. * @throws {Error} If `horizonUrl` is missing from network config. * @example * const account = await stellarClient.initialize(); * console.log(account.publicKey); */ async initialize() { const key = `eddsa:${this.network}`; let clientShare = await this.clientShareProvider.get(key); if (!clientShare) { clientShare = await this.api.eddsa.dkg(); await this.clientShareProvider.set(key, clientShare); } const [config, publicKey] = await Promise.all([ this.#metadataConfig, this.api.eddsa.publicKey(clientShare) ]); if (!config.network.horizonUrl) { throw new Error("Missing required horizonUrl in network config"); } const signer = new StellarSigner(this.api, clientShare, publicKey); const stellarPubKey = StrKey.encodeEd25519PublicKey( Buffer.from(publicKey, "hex") ); return new StellarAccount( this.api, new rpc.Server(config.network.rpcUrl), new Horizon.Server(config.network.horizonUrl), signer, stellarPubKey ); } }; export { StellarClient }; //# sourceMappingURL=stellar-RFTNRJS2.js.map //# sourceMappingURL=stellar-RFTNRJS2.js.map