@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
JavaScript
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