UNPKG

@wuwei-labs/srsly

Version:
242 lines 11.5 kB
/** * @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