Skip to content
Integration guide View as Markdown

Build owner actionsLink to this section

TL;DRLink to this section

  • Claims and merges are signed by the Miner's owner. Your app or agent checks eligibility, simulates, and hands the owner's wallet an unsigned call to sign.
  • Never hold or request a private key, and never retry with different IDs after a failure; report it.
  • A merge permanently burns the donor NFT, so show that before the owner signs.
  • Checkpoints are different: any account can run one, and it has no owner check.

Before you startLink to this section

This guide is for wallets, marketplaces, dashboards and agents acting for a Miner owner. Your code prepares; the owner's wallet signs, pays gas and submits.

Input Where it comes from
Verified graph Verify the deployment; the examples import its setup.ts and verify.ts
Owner address The connected wallet; a contract wallet must itself be the NFT owner
Token IDs The owner's selection; for a merge, the owner also chooses which ID survives
Explicit authorization The owner, after seeing the exact action, IDs and irreversible effects
Action Who signs Requirements Effect
claim(0, ids) Owner of every ID 1–32 strictly ascending live IDs One SQK transfer to the owner; fractional credit stays on the lowest ID
merge(0, survivor, donor) Owner of both IDs Same level below the maximum, no pending power, both cooldowns reached Donor burns; survivor gains one level and keeps both Miners' unclaimed SQK
checkpoint() Any account SqueekCore unpaused with a newly settled round Processes settled rounds for every Miner

Approved NFT operators cannot claim or merge. A SqueekCore pause blocks checkpoints, not claims of already-funded SQK.

StepsLink to this section

Claim rewardsLink to this section

  1. Sort the IDs ascending, remove duplicates and split them into batches of at most 32.
  2. Read ownerOf for every ID. If one is not owned by the connected address, stop and say which; do not silently drop it.
  3. Read claimable(0, ids) for the estimate. It ignores ownership and can change before the claim executes.
  4. Simulate claim(0, ids) from the owner's address and present the payout.
  5. Hand the unsigned call to the owner's wallet.

Claims need no approval, permit or checkpoint, and they do not read SqueekCore.

Merge MinersLink to this section

  1. Ask the owner which ID survives. The survivor keeps its token ID and image; the donor is burned and cannot be restored.
  2. At one block, read ownerOf and position(0, id) for both IDs, collectionPolicy(0), and SqueekCore state().blockHeight.
  3. Stop with the reason if the IDs are the same, the levels differ, either is at the maximum level, either has pendingRound set, or either nextMergeBlock is above the height. Time passing does not count toward a cooldown; completed Squeek blocks do.
  4. Simulate merge(0, survivor, donor) from the owner's address.
  5. Present the burn and the new level, then hand the unsigned call to the owner's wallet.

Hand over unsigned callsLink to this section

The examples end by printing a call bundle instead of sending anything:

{
  "version": "2.0.0",
  "chainId": "0xaa36a7",
  "from": "0xOwner…",
  "atomicRequired": true,
  "calls": [{ "to": "0x3beE588fC20dE788Bac17d33b41Da7346cb27EC4", "data": "0x…", "value": "0x0" }]
}

It is the parameter of an EIP-5792 wallet_sendCalls request, so a supporting wallet can submit it directly; for other wallets, send each call as an ordinary transaction. An agent passes the bundle to the owner's wallet or user and waits for the receipt; it never signs.

The shared ABI fragments and bundle helper:

// mining-abi.ts
import { getAddress, parseAbi, toHex, type Address, type Hex } from "viem";

export const SQUEEKS = 0;
export const miningAbi = parseAbi([
  "function claim(uint8 collection, uint256[] tokenIds) returns (uint256 paid)",
  "function merge(uint8 collection, uint256 survivorId, uint256 donorId)",
  "function checkpoint()",
  "function checkpointStatus() view returns (bool corePaused, uint64 nextRound, bool roundSettled)",
  "function position(uint8 collection, uint256 tokenId) view returns ((uint64 activeWeight, uint64 pendingWeight, uint40 pendingRound, uint48 nextMergeBlock, uint8 level, uint256 lastIndex, uint256 accruedScaled))",
  "function collectionPolicy(uint8 collection) view returns ((address nft, uint16 maxSupply, uint8 maximumLevel, (uint64 weight, uint64 cooldownBlocks)[] levels))",
  "error Unauthorized()",
  "error InvalidClaim()",
  "error InvalidPosition()",
  "error InvalidMerge()",
  "error PendingActivation(uint256 tokenId, uint64 round)",
  "error CooldownActive(uint256 tokenId, uint64 nextMergeBlock)",
  "error RoundNotSettled(uint64 round)",
  "error CorePaused()",
]);
export const ownerAbi = parseAbi(["function ownerOf(uint256 tokenId) view returns (address)"]);

export function callBundle(chainId: bigint, from: Address, to: Address, data: Hex) {
  return { version: "2.0.0", chainId: toHex(chainId), from: getAddress(from), atomicRequired: true, calls: [{ to, data, value: "0x0" }] };
}

Claim: check ownership of every ID, simulate, then print the bundle.

// prepare-claim.ts
import { encodeFunctionData, getAddress } from "viem";
import { blockNumber, client, required } from "./setup";
import { verifyGraph } from "./verify";
import { callBundle, miningAbi, ownerAbi, SQUEEKS } from "./mining-abi";

const { mining, nft } = await verifyGraph();
const owner = getAddress(required("OWNER_ADDRESS"));
const ids = [...new Set(required("TOKEN_IDS").split(",").map(BigInt))].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
if (ids.length === 0 || ids.length > 32) throw new Error("Claim 1 to 32 IDs per batch");
for (const id of ids) {
  const actual = await client.readContract({ address: nft, abi: ownerAbi, functionName: "ownerOf", args: [id], blockNumber });
  if (getAddress(actual) !== owner) throw new Error(`${owner} does not own ${id}`);
}
const { result } = await client.simulateContract({
  address: mining, abi: miningAbi, functionName: "claim", args: [SQUEEKS, ids], account: owner,
});
const data = encodeFunctionData({ abi: miningAbi, functionName: "claim", args: [SQUEEKS, ids] });
console.log(JSON.stringify({ simulatedPayoutBaseUnits: result.toString(), bundle: callBundle(BigInt(required("EXPECTED_CHAIN_ID")), owner, mining, data) }, null, 2));
// Present the payout, then let the owner's wallet sign the bundle. This script never sends a transaction.

Merge: check ownership, levels, pending power and cooldowns, simulate, then print the bundle.

// prepare-merge.ts
import { encodeFunctionData, getAddress, parseAbi } from "viem";
import { blockNumber, client, required } from "./setup";
import { verifyGraph } from "./verify";
import { callBundle, miningAbi, ownerAbi, SQUEEKS } from "./mining-abi";

const { core, mining, nft } = await verifyGraph();
const owner = getAddress(required("OWNER_ADDRESS"));
const survivor = BigInt(required("SURVIVOR_ID"));
const donor = BigInt(required("DONOR_ID"));
const coreAbi = parseAbi([
  "function state() view returns ((uint64 settledThroughRound, uint64 cumulativeWork, uint96 cumulativeBlockUnits, uint64 blockHeight, uint96 blockProgress, uint32 workPerThousandBlocks, uint256 authorizationNonce, bool paused))",
]);
const read = { address: mining, abi: miningAbi, blockNumber } as const;
const [height, policy, positions, owners] = await Promise.all([
  client.readContract({ address: core, abi: coreAbi, functionName: "state", blockNumber }).then((state) => state.blockHeight),
  client.readContract({ ...read, functionName: "collectionPolicy", args: [SQUEEKS] }),
  Promise.all([survivor, donor].map((id) => client.readContract({ ...read, functionName: "position", args: [SQUEEKS, id] }))),
  Promise.all([survivor, donor].map((id) => client.readContract({ address: nft, abi: ownerAbi, functionName: "ownerOf", args: [id], blockNumber }))),
]);
const problems: string[] = [];
if (survivor === donor) problems.push("survivor and donor are the same NFT");
owners.forEach((actual, i) => { if (getAddress(actual) !== owner) problems.push(`${owner} does not own ${[survivor, donor][i]}`); });
const [a, b] = positions;
if (!a || !b || a.level === 0 || b.level === 0) problems.push("both IDs need a live position");
else {
  if (a.level !== b.level) problems.push(`levels differ: ${a.level} and ${b.level}`);
  if (a.level >= policy.maximumLevel) problems.push("already at the maximum level");
  for (const [id, p] of [[survivor, a], [donor, b]] as const) {
    if (p.pendingRound !== 0) problems.push(`${id} has power pending for round ${p.pendingRound}`);
    if (BigInt(p.nextMergeBlock) > height) problems.push(`${id} can merge at Squeek block ${p.nextMergeBlock}; height is ${height}`);
  }
}
if (problems.length > 0) throw new Error(`Not mergeable:\n- ${problems.join("\n- ")}`);
await client.simulateContract({
  address: mining, abi: miningAbi, functionName: "merge", args: [SQUEEKS, survivor, donor], account: owner,
});
const data = encodeFunctionData({ abi: miningAbi, functionName: "merge", args: [SQUEEKS, survivor, donor] });
console.log(JSON.stringify({
  warning: `Squeek #${donor} will be burned permanently`,
  survivor: survivor.toString(),
  newLevel: (a?.level ?? 0) + 1,
  bundle: callBundle(BigInt(required("EXPECTED_CHAIN_ID")), owner, mining, data),
}, null, 2));

nextMergeBlock, pendingRound and level are uint48 or narrower, so viem returns them as number; convert before comparing with bigint heights.

Run a checkpointLink to this section

Settled work becomes claimable only after a checkpoint processes it. A Keeper normally runs checkpoints, but any account can, for example when an app wants a Miner's newest rewards to be claimable now. The caller pays gas and receives nothing for it.

  1. Read checkpointStatus() at the latest block. Stop if corePaused is true or roundSettled is false.
  2. Simulate checkpoint() against the same latest state and review the fee. A RoundNotSettled revert usually means another account has just run it.
  3. After a confirmed receipt, read checkpointStatus() again; while roundSettled stays true, another call continues.
// simulate-checkpoint.ts
import { getAddress } from "viem";
import { client, required } from "./setup";
import { verifyGraph } from "./verify";
import { miningAbi } from "./mining-abi";

const { mining } = await verifyGraph();
const caller = getAddress(required("CALLER_ADDRESS"));
// Read the latest state, the same state the simulation uses: a Keeper may have checkpointed since ETHEREUM_BLOCK.
const [corePaused, nextRound, roundSettled] = await client.readContract({
  address: mining, abi: miningAbi, functionName: "checkpointStatus",
});
if (corePaused) console.log("SqueekCore is paused; checkpoints wait for unpause.");
else if (!roundSettled) console.log(`Nothing to process: round ${nextRound} is not settled yet.`);
else {
  await client.simulateContract({ address: mining, abi: miningAbi, functionName: "checkpoint", account: caller });
  console.log({ nextRound, simulated: true });
}

Expected resultLink to this section

Transaction Events State change
Claim RewardsClaimed(owner, collection, amount, remainderScaled, tokenIds); an SQK Transfer when amount > 0 SqueekMining state().paid grows by amount; credit drops to the remainder on the lowest ID
Merge SqueekNFT burn Transfer for the donor; PositionsMerged Donor position has level 0; survivor is one level higher with power pending for the next round
Checkpoint WeightActivated and Checkpointed per stretch, then one SQK mint Transfer and CheckpointMinted processedThroughRound advances; minted grows by the funded amount

A zero-amount claim still emits RewardsClaimed and keeps the remainder. Field layouts are on the SqueekMining page.

Common failuresLink to this section

Error Cause Fix
Unauthorized Signer is not the current owner; approval is not enough Sign from the owning address
InvalidClaim Empty, oversized, duplicate or unsorted batch Sort, deduplicate, batch by 32
InvalidPosition ID has no live position Remove burned or unminted IDs
PendingActivation(tokenId, round) Mint or merge power not applied yet Wait for a checkpoint to process round
CooldownActive(tokenId, nextMergeBlock) Completed Squeek block height is below nextMergeBlock Wait for more completed blocks
InvalidMerge Same ID twice, levels differ, or maximum level Choose a valid pair
RoundNotSettled(round) Nothing new to checkpoint Wait for the next settlement
CorePaused SqueekCore is paused Retry checkpoints after unpause; claims still work
Simulation passed, transaction failed State changed between simulation and inclusion, for example a transfer Re-read and prepare again; never reuse the old bundle

Verify the resultLink to this section

  1. Check the receipt status and that its logs come from the verified SqueekMining and SqueekNFT addresses.
  2. Decode the events above and compare them with the simulation.
  3. Re-read ownership, positions and SQK balances at the receipt's block. After a merge, ownerOf(donor) must revert.
  4. Show the result as final only after your confirmation depth.