# Zebec Stake Sdk

An SDK for interacting with the Zebec Network staking program on Solana. Supports creating and managing staking lockup pools, staking/unstaking tokens, and querying on-chain staking data.

## Table of Contents

- [Installation](#installation)
- [Quick Setup](#quick-setup)
- [API Documentation](#api-documentation)
  - [StakeServiceBuilder](#stakeservicebuilder)
  - [StakeService](#stakeservice)
  - [Providers](#providers)
  - [PDA Utilities](#pda-utilities)
  - [Types](#types)
  - [Constants](#constants)
- [Usage Examples](#usage-examples)
  - [Read-only queries](#read-only-queries)
  - [Initialize a lockup pool](#initialize-a-lockup-pool)
  - [Update a lockup pool](#update-a-lockup-pool)
  - [Stake tokens](#stake-tokens)
  - [Unstake tokens](#unstake-tokens)
  - [Fetch stake data](#fetch-stake-data)
- [Development](#development)

---

## Installation

```bash
npm install @zebec-network/zebec-stake-sdk
```

```bash
yarn add @zebec-network/zebec-stake-sdk
```

```bash
pnpm add @zebec-network/zebec-stake-sdk
```

---

## Quick Setup

### Read-only (no wallet required)

Use this setup for querying on-chain data without signing transactions.

```ts
import { Connection } from "@solana/web3.js";
import { createReadonlyProvider, StakeServiceBuilder } from "@zebec-network/zebec-stake-sdk";

const connection = new Connection("https://api.mainnet-beta.solana.com", "confirmed");
const provider = createReadonlyProvider(connection);

const service = new StakeServiceBuilder()
  .setNetwork("mainnet-beta")
  .setProvider(provider)
  .setProgram()
  .build();
```

### With wallet (for signing transactions)

Use this setup when you need to send transactions (stake, unstake, create/update lockups).

```ts
import { Connection, Keypair } from "@solana/web3.js";
import { Wallet } from "@coral-xyz/anchor";
import {
  createAnchorProvider,
  StakeServiceBuilder,
} from "@zebec-network/zebec-stake-sdk";

const connection = new Connection("https://api.mainnet-beta.solana.com", "confirmed");
const keypair = Keypair.fromSecretKey(/* your secret key bytes */);
const wallet = new Wallet(keypair);

const provider = createAnchorProvider(connection, wallet, { commitment: "confirmed" });

const service = new StakeServiceBuilder()
  .setNetwork("mainnet-beta")
  .setProvider(provider)
  .setProgram()
  .build();
```

### Default setup (mainnet, no wallet)

All builder methods are optional — calling them without arguments uses sensible defaults.

```ts
import { StakeServiceBuilder } from "@zebec-network/zebec-stake-sdk";

// Defaults: mainnet-beta network, ReadonlyProvider with public RPC
const service = new StakeServiceBuilder()
  .setNetwork()
  .setProvider()
  .setProgram()
  .build();
```

---

## API Documentation

### StakeServiceBuilder

A fluent builder for constructing a `StakeService`. Methods must be called in order: `setNetwork` → `setProvider` → `setProgram` → `build`.

```ts
class StakeServiceBuilder {
  setNetwork(network?: "mainnet-beta" | "devnet"): StakeServiceBuilder
  setProvider(provider?: ReadonlyProvider | AnchorProvider): StakeServiceBuilder
  setProgram(createProgram?: (provider) => Program<ZebecStakeIdlV1>): StakeServiceBuilder
  build(): StakeService
}
```

| Method | Description |
| ------ | ----------- |
| `setNetwork(network?)` | Set the target network. Defaults to `"mainnet-beta"`. Must be called before `setProvider`. |
| `setProvider(provider?)` | Set the provider. Accepts `ReadonlyProvider` or `AnchorProvider`. Defaults to `ReadonlyProvider` using the public cluster RPC. Must be called before `setProgram`. |
| `setProgram(createProgram?)` | Set the Anchor program. Optionally accepts a factory `(provider) => Program`. Defaults to the built-in IDL. |
| `build()` | Validates all settings and returns a `StakeService` instance. Throws if any step was skipped. |

**Errors thrown by the builder:**

- `"InvalidOperation: Network is set twice."` — `setNetwork` called more than once.
- `"InvalidOperation: Provider is set twice."` — `setProvider` called more than once.
- `"InvalidOperation: Program is set twice."` — `setProgram` called more than once.
- `"InvalidOperation: Network is not set."` — `setProvider`/`setProgram` called before `setNetwork`.
- `"InvalidOperation: Provider is not set."` — `setProgram` called before `setProvider`.
- Network mismatch error if the provider's RPC endpoint does not match the set network.

---

### StakeService

The main service class. Exposes methods for managing lockup pools, staking, and reading on-chain data. All transaction methods return a `TransactionPayload` that must be executed separately.

#### `initLockup(params)`

Creates a new staking lockup pool. Only the `creator` can later update it.

```ts
service.initLockup(params: {
  stakeToken: Address;       // Mint address of the token to be staked
  rewardToken: Address;      // Mint address of the reward token
  name: string;              // Unique name for the lockup (used to derive its address)
  fee: Numeric;              // Fee amount per stake (in token units, e.g. 5 = 5 tokens)
  feeVault: Address;         // Public key of the account that collects fees
  rewardSchemes: RewardScheme[]; // Array of lock durations and annual reward rates
  minimumStake: Numeric;     // Minimum stake amount (in token units)
  creator?: Address;         // Defaults to provider.publicKey
}): Promise<TransactionPayload>
```

#### `updateLockup(params)`

Updates an existing lockup pool. Only callable by the original creator.

```ts
service.updateLockup(params: {
  lockupName: string;        // Name of the lockup to update
  fee: Numeric;              // New fee amount
  feeVault: Address;         // New fee vault address
  rewardSchemes: RewardScheme[]; // Updated reward schemes
  minimumStake: Numeric;     // Updated minimum stake amount
  updater?: Address;         // Defaults to provider.publicKey
}): Promise<TransactionPayload>
```

#### `stake(params)`

Stakes tokens into a lockup pool for a specified lock period.

```ts
service.stake(params: {
  lockupName: string;        // Name of the lockup to stake into
  amount: Numeric;           // Amount to stake (in token units, e.g. 100 = 100 tokens)
  lockPeriod: number;        // Lock duration in seconds — must match an existing reward scheme
  nonce: bigint;             // Current user nonce (use getUserNonceInfo to retrieve)
  feePayer?: Address;        // Defaults to staker
  staker?: Address;          // Defaults to provider.publicKey
}): Promise<TransactionPayload>
```

#### `unstake(params)`

Unstakes tokens and claims the accrued reward after the lock period has elapsed.

```ts
service.unstake(params: {
  lockupName: string;        // Name of the lockup
  nonce: bigint;             // Nonce of the stake to unstake
  feePayer?: Address;        // Defaults to staker
  staker?: Address;          // Defaults to provider.publicKey
}): Promise<TransactionPayload>
```

#### `getLockupInfo(lockupAddress)`

Fetches and returns human-readable information about a lockup pool.

```ts
service.getLockupInfo(lockupAddress: Address): Promise<LockupInfo | null>
```

Returns `null` if the lockup does not exist.

#### `getStakeInfo(stakeAddress, lockupAddress)`

Fetches information about a specific stake position.

```ts
service.getStakeInfo(stakeAddress: Address, lockupAddress: Address): Promise<StakeInfo | null>
```

Returns `null` if the stake account does not exist. Throws if the lockup does not exist.

#### `getUserNonceInfo(userNonceAddress)`

Returns the user's current nonce for a given lockup. The nonce is used to derive stake addresses and must be passed when calling `stake()`.

```ts
service.getUserNonceInfo(userNonceAddress: Address): Promise<UserNonceInfo | null>
```

Returns `null` if the user has never staked in this lockup (nonce is implicitly `0n`).

#### `getAllStakesInfoOfUser(userAddress, lockupAddress, options?)`

Fetches all historical stake positions for a user in a given lockup pool, including the transaction signature for each stake.

```ts
service.getAllStakesInfoOfUser(
  userAddress: Address,
  lockupAddress: Address,
  options?: {
    minDelayMs?: number;    // Minimum ms between RPC calls (default: 400)
    maxConcurrent?: number; // Max concurrent RPC calls (default: 3)
  }
): Promise<StakeInfoWithHash[]>
```

#### `getAllStakesInfo(lockupAddress)`

Fetches all stake positions across all users in a lockup pool.

```ts
service.getAllStakesInfo(lockupAddress: Address): Promise<StakeInfo[]>
```

#### `getAllStakesCount(lockupAddress)`

Returns the total number of stake accounts associated with a lockup pool.

```ts
service.getAllStakesCount(lockupAddress: Address): Promise<number>
```

#### `getStakeSignatureForStake(stakeInfo)`

Retrieves the on-chain transaction signature for a given stake position.

```ts
service.getStakeSignatureForStake(stakeInfo: StakeInfo): Promise<string | null>
```

#### Properties

| Property | Type | Description |
| -------- | ---- | ----------- |
| `programId` | `PublicKey` | The deployed staking program's public key |
| `connection` | `Connection` | The active Solana RPC connection |
| `provider` | `Provider` | The underlying Anchor/Readonly provider |
| `program` | `Program<ZebecStakeIdlV1>` | The Anchor program instance |

---

### Providers

Two provider types are available depending on your use case.

#### `createReadonlyProvider(connection, walletAddress?)`

Creates a lightweight provider for read-only operations. Does not require a wallet.

```ts
import { createReadonlyProvider } from "@zebec-network/zebec-stake-sdk";

const provider = createReadonlyProvider(connection);
// or with an optional wallet address for context
const provider = createReadonlyProvider(connection, "YourWalletPublicKey...");
```

#### `createAnchorProvider(connection, wallet, options?)`

Creates a full Anchor provider capable of signing and sending transactions.

```ts
import { createAnchorProvider } from "@zebec-network/zebec-stake-sdk";

const provider = createAnchorProvider(connection, wallet, {
  commitment: "confirmed",
});
```

The `wallet` must implement the `AnchorWallet` interface:

```ts
interface AnchorWallet {
  publicKey: PublicKey;
  signTransaction<T extends Transaction | VersionedTransaction>(tx: T): Promise<T>;
  signAllTransactions<T extends Transaction | VersionedTransaction>(txs: T[]): Promise<T[]>;
}
```

---

### PDA Utilities

Helper functions for deriving program-derived addresses. All `programId` parameters default to the mainnet program ID.

```ts
import {
  deriveLockupAddress,
  deriveStakeAddress,
  deriveUserNonceAddress,
  deriveStakeVaultAddress,
  deriveRewardVaultAddress,
} from "@zebec-network/zebec-stake-sdk";
```

| Function | Description |
| -------- | ----------- |
| `deriveLockupAddress(name, programId?)` | Derives the lockup PDA from its unique name |
| `deriveStakeAddress(staker, lockup, nonce, programId?)` | Derives a stake PDA for a given staker, lockup, and nonce |
| `deriveUserNonceAddress(user, lockup, programId?)` | Derives the user nonce PDA tracking a user's total stake count |
| `deriveStakeVaultAddress(lockup, programId?)` | Derives the vault PDA that holds staked tokens |
| `deriveRewardVaultAddress(lockup, programId?)` | Derives the vault PDA that holds reward tokens |

---

### Types

```ts
// Human-readable reward scheme: duration in seconds and annual rate as a percentage
type RewardScheme = {
  duration: number;       // Lock period in seconds (e.g., 2592000 = 30 days)
  rewardRate: Numeric;    // Annual reward rate as a percentage (e.g., "5.00" = 5%)
};

// Returned by getLockupInfo()
type LockupInfo = {
  address: string;
  feeInfo: {
    fee: string;           // Fee amount in token units
    feeVault: string;      // Fee collector address
  };
  rewardToken: {
    tokenAddress: string;
  };
  stakeToken: {
    tokenAdress: string;   // Note: single 'd' in 'adress' (matches on-chain field)
    totalStaked: string;   // Total tokens currently staked in this lockup
  };
  stakeInfo: {
    name: string;
    creator: string;
    rewardSchemes: RewardScheme[];
    minimumStake: string;
  };
};

// Returned by getStakeInfo() and getAllStakesInfo()
type StakeInfo = {
  address: string;         // Stake PDA address
  nonce: bigint;           // Stake nonce (index within this user's stakes)
  createdTime: number;     // Unix timestamp of when the stake was created
  stakedAmount: string;    // Amount staked in token units
  rewardAmount: string;    // Accrued reward in reward token units
  stakeClaimed: boolean;   // Whether the stake has been unstaked
  lockPeriod: number;      // Lock duration in seconds
  staker: string;          // Staker's public key
  lockup: string;          // Lockup PDA address
};

// Returned by getAllStakesInfoOfUser()
type StakeInfoWithHash = StakeInfo & {
  hash: string;            // Transaction signature of the original stake
};

// Returned by getUserNonceInfo()
type UserNonceInfo = {
  address: string;         // User nonce PDA address
  nonce: bigint;           // Current nonce (equals total number of stakes made)
};

// Accepted wherever amounts are passed
type Numeric = string | number;
```

---

### Constants

```ts
import { ZEBEC_STAKE_PROGRAM, STAKE_LOOKUP_TABLE_ADDRESS } from "@zebec-network/zebec-stake-sdk";

// Program IDs
ZEBEC_STAKE_PROGRAM.mainnet  // "zSTKzGLiN6T6EVzhBiL6sjULXMahDavAS2p4R62afGv"
ZEBEC_STAKE_PROGRAM.devnet   // "zSTKzGLiN6T6EVzhBiL6sjULXMahDavAS2p4R62afGv"

// Address Lookup Table accounts for versioned transactions
STAKE_LOOKUP_TABLE_ADDRESS["mainnet-beta"]  // "EoKjJejKr4XsBdtUuYwzZcYd6tpGNijxCGgQocxtxQ8t"
STAKE_LOOKUP_TABLE_ADDRESS["devnet"]        // "C4R2sL6yj7bzKfbdfwCfH68DZZ3QnzdmedE9wQqTfAAA"
```

---

## Usage Examples

### Read-only queries

```ts
import { Connection } from "@solana/web3.js";
import {
  createReadonlyProvider,
  deriveLockupAddress,
  deriveUserNonceAddress,
  StakeServiceBuilder,
} from "@zebec-network/zebec-stake-sdk";

const connection = new Connection("https://api.mainnet-beta.solana.com", "confirmed");
const provider = createReadonlyProvider(connection);

const service = new StakeServiceBuilder()
  .setNetwork("mainnet-beta")
  .setProvider(provider)
  .setProgram()
  .build();

// Derive the lockup address from its name
const lockupName = "ZBCN_Lockup_003";
const lockupAddress = deriveLockupAddress(lockupName, service.programId);

// Fetch lockup pool info
const lockupInfo = await service.getLockupInfo(lockupAddress);
console.log(lockupInfo);
// {
//   address: "...",
//   feeInfo: { fee: "5", feeVault: "..." },
//   rewardToken: { tokenAddress: "..." },
//   stakeToken: { tokenAdress: "...", totalStaked: "1234567" },
//   stakeInfo: {
//     name: "ZBCN_Lockup_003",
//     creator: "...",
//     rewardSchemes: [
//       { duration: 2592000, rewardRate: "3.00" },
//       { duration: 7776000, rewardRate: "5.00" },
//     ],
//     minimumStake: "1"
//   }
// }

// Fetch all stakes in a lockup
const allStakes = await service.getAllStakesInfo(lockupAddress);
console.log(`Total active positions: ${allStakes.length}`);

// Fetch total stake count
const count = await service.getAllStakesCount(lockupAddress);
console.log(`Total stake accounts: ${count}`);
```

### Initialize a lockup pool

Requires an `AnchorProvider` (wallet with signing capability).

```ts
import { Connection, Keypair } from "@solana/web3.js";
import { Wallet } from "@coral-xyz/anchor";
import {
  createAnchorProvider,
  deriveLockupAddress,
  StakeServiceBuilder,
} from "@zebec-network/zebec-stake-sdk";

const connection = new Connection("https://api.devnet.solana.com", "confirmed");
const keypair = Keypair.fromSecretKey(/* your secret key bytes */);
const wallet = new Wallet(keypair);
const provider = createAnchorProvider(connection, wallet, { commitment: "confirmed" });

const service = new StakeServiceBuilder()
  .setNetwork("devnet")
  .setProvider(provider)
  .setProgram()
  .build();

const ZBCN_MINT = "ZBCNpuD7YMXzTHB2fhGkGi78MNsHGLRXUhRewNRm9RU";

const payload = await service.initLockup({
  stakeToken: ZBCN_MINT,
  rewardToken: ZBCN_MINT,
  name: "My_Lockup_001",
  fee: 0,                        // 0 token fee per stake
  feeVault: "FeeVaultPublicKey...",
  minimumStake: 1,               // Minimum 1 token to stake
  rewardSchemes: [
    { duration: 2592000, rewardRate: "3.00" },  // 30 days @ 3% APR
    { duration: 7776000, rewardRate: "5.00" },  // 90 days @ 5% APR
    { duration: 15552000, rewardRate: "7.00" }, // 180 days @ 7% APR
  ],
});

const signature = await payload.execute({ commitment: "confirmed" });
console.log("Lockup created:", signature);

const lockupAddress = deriveLockupAddress("My_Lockup_001", service.programId);
const lockupInfo = await service.getLockupInfo(lockupAddress);
console.log(lockupInfo);
```

### Update a lockup pool

Only the original creator can update a lockup.

```ts
const payload = await service.updateLockup({
  lockupName: "My_Lockup_001",
  fee: 5,
  feeVault: "NewFeeVaultPublicKey...",
  minimumStake: 10,
  rewardSchemes: [
    { duration: 2592000, rewardRate: "5.00" },  // 30 days @ 5% APR
    { duration: 7776000, rewardRate: "8.00" },  // 90 days @ 8% APR
    { duration: 15552000, rewardRate: "12.00" }, // 180 days @ 12% APR
  ],
});

const signature = await payload.execute({ commitment: "confirmed" });
console.log("Lockup updated:", signature);
```

### Stake tokens

```ts
import {
  deriveUserNonceAddress,
  deriveLockupAddress,
} from "@zebec-network/zebec-stake-sdk";

const lockupName = "My_Lockup_001";
const lockupAddress = deriveLockupAddress(lockupName, service.programId);

// Get the user's current nonce (determines the next stake account address)
const userNonceAddress = deriveUserNonceAddress(
  wallet.publicKey,
  lockupAddress,
  service.programId,
);
const nonceInfo = await service.getUserNonceInfo(userNonceAddress);
const nonce = nonceInfo ? nonceInfo.nonce : 0n;

// Stake 1000 tokens for 30 days (2592000 seconds)
const payload = await service.stake({
  lockupName,
  amount: 1000,
  lockPeriod: 2592000, // must match a duration in the lockup's rewardSchemes
  nonce,               // current nonce; incremented on-chain after staking
});

const signature = await payload.execute({ commitment: "confirmed" });
console.log("Stake signature:", signature);
```

### Unstake tokens

Unstaking returns the original staked tokens plus any accrued reward. Can only be called after the lock period has elapsed.

```ts
import { deriveStakeAddress } from "@zebec-network/zebec-stake-sdk";

// The nonce used when staking identifies which stake position to unstake
const stakeNonce = 0n;

const payload = await service.unstake({
  lockupName: "My_Lockup_001",
  nonce: stakeNonce,
});

const signature = await payload.execute({ commitment: "confirmed" });
console.log("Unstake signature:", signature);

// Verify the stake was claimed
const lockupAddress = deriveLockupAddress("My_Lockup_001", service.programId);
const stakeAddress = deriveStakeAddress(
  wallet.publicKey,
  lockupAddress,
  stakeNonce,
  service.programId,
);
const stakeInfo = await service.getStakeInfo(stakeAddress, lockupAddress);
console.log("Stake claimed:", stakeInfo?.stakeClaimed); // true
console.log("Reward received:", stakeInfo?.rewardAmount);
```

### Fetch stake data

```ts
// All stakes for a specific user in a lockup
const userStakes = await service.getAllStakesInfoOfUser(
  wallet.publicKey,
  lockupAddress,
  {
    maxConcurrent: 3,  // max parallel RPC calls (default: 3)
    minDelayMs: 400,   // ms between requests to avoid rate limits (default: 400)
  }
);

for (const stake of userStakes) {
  console.log({
    nonce: stake.nonce.toString(),
    amount: stake.stakedAmount,
    reward: stake.rewardAmount,
    claimed: stake.stakeClaimed,
    lockPeriod: stake.lockPeriod,
    txHash: stake.hash,
  });
}

// Single stake by address
const stakeAddress = deriveStakeAddress(
  wallet.publicKey,
  lockupAddress,
  0n,
  service.programId,
);
const stakeInfo = await service.getStakeInfo(stakeAddress, lockupAddress);
```

---

## Development

### Environment setup

Create a `.env` file at the project root for running tests:

```env
RPC_URL=https://your-mainnet-rpc-url
DEVNET_RPC_URL=https://your-devnet-rpc-url

# JSON arrays of base58-encoded secret keys
MAINNET_SECRET_KEYS=["base58SecretKey1","base58SecretKey2"]
DEVNET_SECRET_KEYS=["base58SecretKey1","base58SecretKey2"]
```

### Commands

```bash
# Build the package
npm run build

# Run all tests
npm test

# Run a single test file
npm run test:single ./test/e2e/getLockupInfo.test.ts

# Run a specific test by name pattern
npm run test:single ./test/e2e/stakeAndUnstake.test.ts -- -f "stake()"

# Format code
npm run format
```

### Publish

Build and bump the version in `package.json`, then:

```bash
npm publish --access public
```
