@wuwei-labs/srsly
Version:
TypeScript SDK for SRSLY
199 lines • 8.77 kB
JavaScript
/**
* @purpose Simplified closeContract instruction wrapper
*
* Thin convenience wrapper around Codama-generated closeContract instruction.
* Closes a contract with no active rentals and claims remaining payments.
*/
import { AccountRole, address } from '@solana/kit';
import { getCloseContractInstructionAsync, getCloseContractThreadInstructionAsync, } from '../generated/codama/instructions';
import { mergeConfig, getAddresses } from '../utils/config';
import { toTransactionSigner } from '../utils/signer';
import { fetchContract } from '../accounts/contract';
import { fetchConfig } from '../accounts/config';
import { fetchRental } from '../accounts/rental';
import { fetchThreadByAddress } from '../accounts/thread';
import { deriveAssociatedTokenAccount } from '../pda/token';
import { deriveContract, deriveBorrowerState, deriveRentalAuthority } from '../pda/srsly';
import { deriveContractThread, deriveFiber } from '../pda/thread';
import { prepareInstructions } from '../utils/instructions';
/**
* Close a rental contract and refund rent to the owner. Idempotent —
* recovers cleanly from partial prior closes. When no rental is active,
* the contract's automation thread is closed in the same transaction.
*
* @param params - Contract closing parameters
* @param config - Optional SDK configuration overrides
* @returns Kit instruction (or web3.js if PublicKey in config)
*
* @example
* ```typescript
* // Option 1: Provide fleet only (RECOMMENDED - idempotent)
* const ix = await closeContract({
* owner: wallet,
* fleet: fleetAddress
* });
*
* // Option 2: Provide contract only (contract MUST exist)
* const ix2 = await closeContract({
* owner: wallet,
* contract: contractAddress
* });
*
* // Option 3: Provide both (most explicit, no fetching)
* const ix3 = await closeContract({
* owner: wallet,
* fleet: fleetAddress,
* contract: contractAddress
* });
* ```
*/
export async function closeContract(params, config) {
// Merge config
const finalConfig = mergeConfig(config);
// Validate required params
if (!params.owner) {
throw new Error('owner is required');
}
if (!params.fleet && !params.contract) {
throw new Error('Either fleet or contract must be provided');
}
// Convert owner to transaction signer
const ownerSigner = toTransactionSigner(params.owner);
// Get atlasMint from on-chain config
const configAccount = await fetchConfig(finalConfig.rpcUrl);
const atlasMint = configAccount.data.atlasMint;
const addresses = getAddresses(config);
const programAddress = address(addresses.srsly);
// Resolve fleet and contract addresses
let fleetAddress;
let contractAddress;
if (params.fleet && params.contract) {
// Both provided - use as-is
fleetAddress = address(params.fleet);
contractAddress = address(params.contract);
}
else if (params.fleet) {
// Only fleet provided - derive contract from fleet PDA
fleetAddress = address(params.fleet);
contractAddress = await deriveContract(fleetAddress, programAddress);
}
else if (params.contract) {
// Only contract provided - fetch contract to get fleet
contractAddress = address(params.contract);
}
else {
// Should never reach here due to validation above
throw new Error('Either fleet or contract must be provided');
}
// Always fetch contract — needed for fleet (if not provided) and thread check
const contractAccount = await fetchContract(contractAddress, finalConfig.rpcUrl);
if (!params.fleet) {
fleetAddress = contractAccount.data.fleet;
}
// Get sageProgram from config if not provided
const sageProgram = params.sageProgram
? address(params.sageProgram)
: address(addresses.sage);
// Derive contract token account (ATA of contract PDA for ATLAS)
const contractTokenAccount = await deriveAssociatedTokenAccount(contractAddress, atlasMint);
// Check contract state
const SYSTEM_PROGRAM_ID = '11111111111111111111111111111111';
const hasThread = contractAccount.data.thread !== SYSTEM_PROGRAM_ID;
const hasActiveSlot = contractAccount.data.activeRental !== SYSTEM_PROGRAM_ID;
const hasQueuedSlot = contractAccount.data.queuedRental !== SYSTEM_PROGRAM_ID;
// Check if active rental is expired
let activeExpired = false;
if (hasActiveSlot) {
const activeRental = await fetchRental(contractAccount.data.activeRental, finalConfig.rpcUrl);
const now = Math.floor(Date.now() / 1000);
if (activeRental.data.endTime <= BigInt(now)) {
activeExpired = true;
}
}
// Resolve queued borrower accounts when a queued rental exists
let queuedBorrowerState;
let queuedBorrowerTokenAccount;
if (hasQueuedSlot) {
let queuedBorrower;
if (params.queuedBorrower) {
queuedBorrower = address(params.queuedBorrower);
}
else {
const queuedRental = await fetchRental(contractAccount.data.queuedRental, finalConfig.rpcUrl);
queuedBorrower = queuedRental.data.borrower;
}
queuedBorrowerState = await deriveBorrowerState(queuedBorrower);
queuedBorrowerTokenAccount = await deriveAssociatedTokenAccount(queuedBorrower, atlasMint);
}
// Helper: build a closeContractThread instruction
const buildThreadCloseIx = async () => {
const rentalAuthority = await deriveRentalAuthority();
const contractThread = await deriveContractThread(contractAddress, rentalAuthority);
const threadAccount = await fetchThreadByAddress(contractThread, finalConfig.rpcUrl);
const fiberIds = Array.from(threadAccount.data.fiberIds);
const fiberAccounts = await Promise.all(fiberIds.map(async (id) => ({
address: await deriveFiber(contractThread, id),
role: AccountRole.WRITABLE,
})));
const ix = await getCloseContractThreadInstructionAsync({ owner: ownerSigner, contract: contractAddress, thread: contractThread }, { programAddress });
ix.accounts.push(...fiberAccounts);
return ix;
};
// Helper: build a closeContract instruction with optional queued borrower accounts
const buildCloseContractIx = async (includeQueued) => {
return getCloseContractInstructionAsync({
owner: ownerSigner,
contract: contractAddress,
mint: atlasMint,
fleet: fleetAddress,
contractTokenAccount: contractTokenAccount,
sageProgram,
...(includeQueued && queuedBorrowerState && queuedBorrowerTokenAccount
? { queuedBorrowerState, queuedBorrowerTokenAccount }
: {}),
}, { programAddress });
};
// Compose instructions based on scenario
const ixs = [];
if (activeExpired && hasQueuedSlot) {
// Expired active + queued: multi-instruction composition
// 1. closeRental — settles expired active
const { closeRental } = await import('./closeRental');
const closeRentalResult = await closeRental({ payer: params.owner, contract: contractAddress }, config);
ixs.push(...closeRentalResult.instructions);
// 2. closeContractThread (if thread exists)
if (hasThread) {
ixs.push(await buildThreadCloseIx());
}
// 3. closeContract — force-closes queued, sets to_close
ixs.push(await buildCloseContractIx(true));
// 4. closeContract — final close (no active, no queued, no thread)
ixs.push(await buildCloseContractIx(false));
}
else if (activeExpired && !hasQueuedSlot) {
// Expired active only, no queued — settle then close
const { closeRental } = await import('./closeRental');
const closeRentalResult = await closeRental({ payer: params.owner, contract: contractAddress }, config);
ixs.push(...closeRentalResult.instructions);
if (hasThread) {
ixs.push(await buildThreadCloseIx());
}
ixs.push(await buildCloseContractIx(false));
}
else if (!activeExpired && hasQueuedSlot) {
// Non-expired active + queued — deferred close with queued borrower accounts
ixs.push(await buildCloseContractIx(true));
}
else {
// No active rental (or no rental at all) — simple close
if (hasThread) {
ixs.push(await buildThreadCloseIx());
}
ixs.push(await buildCloseContractIx(false));
}
return prepareInstructions(ixs, {
computeUnits: params.computeUnits,
PublicKey: finalConfig.PublicKey,
});
}
//# sourceMappingURL=closeContract.js.map