@wuwei-labs/srsly
Version:
TypeScript SDK for SRSLY
242 lines • 11.5 kB
JavaScript
/**
* @purpose Simplified acceptRental instruction wrapper
*
* Thin convenience wrapper around Codama-generated acceptRental instruction.
* Accepts a rental contract and begins the rental period.
*/
import { AccountRole, address, isOption, unwrapOption } from '@solana/kit';
import { getAcceptRentalInstructionAsync } from '../generated/codama/instructions';
import { mergeConfig, getAddresses } from '../utils/config';
import { toTransactionSigner } from '../utils/signer';
import { convertAmount } from '../params/amount';
import { convertDuration } from '../params/duration';
import { fetchFleet } from '../accounts/fleet';
import { fetchContract } from '../accounts/contract';
import { fetchConfig } from '../accounts/config';
import { fetchRental } from '../accounts/rental';
import { fetchBorrower } from '../accounts/borrower';
import { deriveGameAccounts, deriveProfileFaction } from '../pda/sage';
import { deriveActiveRental, deriveBorrowerState, deriveQueuedRental, deriveRentalAuthority, } from '../pda/srsly';
import { deriveAssociatedTokenAccount } from '../pda/token';
import { deriveContractThread, deriveFiber } from '../pda/thread';
import { prepareInstructions } from '../utils/instructions';
import { RentalStatus } from '../generated/codama/types';
import { deserializeDiscountAuth } from '../utils/discountAuth';
/**
* Activate a rental: transfer fleet control to the borrower for the
* chosen duration and pay the first period's rate.
*
* @param params - Rental acceptance parameters
* @param config - Optional SDK configuration overrides
* @returns Array of instructions (Kit format or web3.js if PublicKey in config)
*
* @example
* ```typescript
* const ixs = await acceptRental({
* borrower: wallet,
* borrowerProfile: "BORROWER_PROFILE_ADDRESS",
* contract: "CONTRACT_ADDRESS",
* duration: { days: 7 },
* computeUnits: 400_000
* });
* ```
*/
export async function acceptRental(params, config) {
// Merge config
const finalConfig = mergeConfig(config);
// Validate required params
if (!params.borrower) {
throw new Error('borrower is required');
}
if (!params.contract) {
throw new Error('contract is required');
}
if (!params.duration) {
throw new Error('duration is required');
}
if (!params.borrowerProfile) {
throw new Error('borrowerProfile is required');
}
// acceptRental issues several CPIs (SAGE rental_open, SLYvault deposit,
// optional discount verify, optional thread + queued-rental cleanup),
// so the 200k default budget reliably runs out. Force the caller to
// size it explicitly.
if (!params.computeUnits || params.computeUnits < 400_000) {
throw new Error(`computeUnits must be at least 400,000 for acceptRental (got ${params.computeUnits ?? 'undefined'}).`);
}
// Convert borrower to transaction signer
const borrowerSigner = toTransactionSigner(params.borrower);
// Fetch config
const configAccount = await fetchConfig(finalConfig.rpcUrl);
// Fetch contract to get fleet, gameId
const contractAccount = await fetchContract(params.contract, finalConfig.rpcUrl);
const fleet = contractAccount.data.fleet;
const gameId = contractAccount.data.gameId;
// Client-side rate safety check
if (params.rate != null) {
const expectedRate = convertAmount(params.rate);
const contractRate = BigInt(contractAccount.data.rate);
if (expectedRate !== contractRate) {
throw new Error(`Contract rate mismatch: expected ${expectedRate} stardust but contract has ${contractRate} stardust. The rate may have changed since you last fetched it.`);
}
}
// Fetch fleet data for faction (needed to derive SAGE accounts)
const fleetState = await fetchFleet(fleet, finalConfig.rpcUrl);
const faction = fleetState.faction;
const borrowerProfile = address(params.borrowerProfile);
// Derive all SAGE game accounts (profile faction, starbase, starbase player)
const gameAccounts = await deriveGameAccounts(borrowerProfile, faction, gameId);
// Derive active rental PDA (no more slot logic)
const rentalState = await deriveActiveRental(params.contract);
// Resolve expiredBorrowerState: use param if provided, otherwise auto-detect
// When the same borrower re-rents their own expired contract, skip passing
// expiredBorrowerState to avoid Anchor's duplicate mutable account error.
const borrowerStatePda = await deriveBorrowerState(borrowerSigner);
let expiredBorrowerState;
let activeExpired = false;
let expiredReferrer;
let expiredDiscountBps = 0;
let expiredBorrowerManagedTA;
if (params.expiredBorrowerState) {
const explicit = address(params.expiredBorrowerState);
// Don't pass if it matches the current borrower's state (same-borrower re-rent)
if (explicit !== borrowerStatePda) {
expiredBorrowerState = explicit;
}
}
try {
const activeRental = await fetchRental(rentalState, finalConfig.rpcUrl);
if (activeRental.data.status !== RentalStatus.Available) {
// Check if expired (end_time in the past)
const now = Math.floor(Date.now() / 1000);
if (now >= Number(activeRental.data.endTime)) {
activeExpired = true;
expiredReferrer = isOption(activeRental.data.referrer)
? (unwrapOption(activeRental.data.referrer) ?? undefined)
: undefined;
expiredDiscountBps = activeRental.data.discountBps;
// Fetch expired borrower's managed ATA for discount refund
if (expiredDiscountBps > 0 && expiredReferrer) {
try {
const expiredBorrowerAccount = await fetchBorrower(activeRental.data.borrowerState, finalConfig.rpcUrl);
expiredBorrowerManagedTA = expiredBorrowerAccount.data.managedTokenAccount;
}
catch {
// Can't fetch — skip discount refund account
}
}
}
// Auto-detect expiredBorrowerState if not explicitly provided
if (!params.expiredBorrowerState) {
if (activeRental.data.borrowerState !== borrowerStatePda) {
expiredBorrowerState = activeRental.data.borrowerState;
}
}
}
}
catch {
// Rental state doesn't exist or can't be fetched — no expired rental to replace
}
// Derive rental authority PDA
const rentalAuthority = await deriveRentalAuthority();
// Derive contract thread and close_rental fiber (fiber index 1) — only if thread configured
const thread = address(contractAccount.data.thread);
const hasThread = thread !== '11111111111111111111111111111111';
const contractThread = hasThread
? await deriveContractThread(params.contract, rentalAuthority)
: undefined;
const closeRentalFiber = hasThread && contractThread ? await deriveFiber(contractThread, 1) : undefined;
// Convert duration parameter
const durationSeconds = convertDuration(params.duration);
// Get network-specific addresses (atlasMint sourced from on-chain config)
const atlasMint = configAccount.data.atlasMint;
const addresses = getAddresses(config);
const sageProgram = address(addresses.sage);
const programAddress = address(addresses.srsly);
const referrer = params.referrer
? address(params.referrer)
: undefined;
// Derive contract token account (ATA of contract PDA)
const contractAddress = address(params.contract);
const contractTokenAccount = await deriveAssociatedTokenAccount(contractAddress, atlasMint);
// Derive vault accounts for fee flush
const vaultAddress = address(configAccount.data.slyvault);
const vaultTokenAccount = await deriveAssociatedTokenAccount(vaultAddress, atlasMint);
// Derive borrower token account (ATA)
const borrowerTokenAccount = await deriveAssociatedTokenAccount(borrowerSigner.address, atlasMint);
// Resolve queued rental accounts (named optional accounts)
const SYSTEM_PROGRAM_ID = '11111111111111111111111111111111';
const queuedRentalKey = contractAccount.data.queuedRental;
let queuedBorrowerProfile;
let queuedBorrowerProfileFaction;
if (queuedRentalKey !== SYSTEM_PROGRAM_ID) {
const queuedRentalPda = await deriveQueuedRental(params.contract);
try {
const queuedRentalState = await fetchRental(queuedRentalPda, finalConfig.rpcUrl);
if (queuedRentalState.data.status === RentalStatus.Queued) {
queuedBorrowerProfile = queuedRentalState.data.borrowerProfile;
queuedBorrowerProfileFaction = await deriveProfileFaction(queuedBorrowerProfile);
}
}
catch {
// Queued rental can't be fetched — skip
}
}
// Call Codama-generated acceptRental instruction
const acceptRentalIx = await getAcceptRentalInstructionAsync({
borrower: borrowerSigner,
borrowerTokenAccount,
fleet: address(fleet),
expiredBorrowerState,
contract: contractAddress,
contractTokenAccount,
vault: vaultAddress,
vaultTokenAccount,
...(contractThread && { contractThread }),
...(closeRentalFiber && { closeRentalFiber }),
referrer,
// Named optional accounts for expired fee flush
...(activeExpired && expiredReferrer && { expiredReferrer }),
...(activeExpired &&
expiredDiscountBps > 0 &&
expiredBorrowerManagedTA && {
expiredBorrowerTokenAccount: expiredBorrowerManagedTA,
}),
// Named optional accounts for queued activation
...(queuedBorrowerProfile && { queuedBorrowerProfile }),
...(queuedBorrowerProfileFaction && { queuedBorrowerProfileFaction }),
sageProgram,
duration: BigInt(durationSeconds),
...(params.discountAuth
? (() => {
const d = deserializeDiscountAuth(params.discountAuth);
return {
discountSignature: d.discountSignature,
discountMemberNonce: d.discountMemberNonce,
discountBps: d.discountBps,
discountExpiresAt: d.discountExpiresAt,
};
})()
: {
discountSignature: null,
discountMemberNonce: null,
discountBps: null,
discountExpiresAt: null,
}),
}, { programAddress });
// Append SAGE base accounts as remaining accounts [0..4]
const sageRemainingAccounts = [
{ address: borrowerProfile, role: AccountRole.READONLY },
{ address: gameAccounts.profileFaction, role: AccountRole.READONLY },
{ address: address(gameId), role: AccountRole.READONLY },
{ address: gameAccounts.starbase, role: AccountRole.READONLY },
{ address: gameAccounts.starbasePlayer, role: AccountRole.WRITABLE },
];
acceptRentalIx.accounts.push(...sageRemainingAccounts);
// Prepare instruction with optional compute budget
return prepareInstructions(acceptRentalIx, {
computeUnits: params.computeUnits,
PublicKey: finalConfig.PublicKey,
});
}
//# sourceMappingURL=acceptRental.js.map