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
- Sort the IDs ascending, remove duplicates and split them into batches of at most 32.
- Read
ownerOffor every ID. If one is not owned by the connected address, stop and say which; do not silently drop it. - Read
claimable(0, ids)for the estimate. It ignores ownership and can change before the claim executes. - Simulate
claim(0, ids)from the owner's address and present the payout. - 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
- Ask the owner which ID survives. The survivor keeps its token ID and image; the donor is burned and cannot be restored.
- At one block, read
ownerOfandposition(0, id)for both IDs,collectionPolicy(0), and SqueekCorestate().blockHeight. - Stop with the reason if the IDs are the same, the levels differ, either is at the maximum level, either has
pendingRoundset, or eithernextMergeBlockis above the height. Time passing does not count toward a cooldown; completed Squeek blocks do. - Simulate
merge(0, survivor, donor)from the owner's address. - 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.
- Read
checkpointStatus()at the latest block. Stop ifcorePausedis true orroundSettledis false. - Simulate
checkpoint()against the same latest state and review the fee. ARoundNotSettledrevert usually means another account has just run it. - After a confirmed receipt, read
checkpointStatus()again; whileroundSettledstays 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
- Check the receipt status and that its logs come from the verified SqueekMining and SqueekNFT addresses.
- Decode the events above and compare them with the simulation.
- Re-read ownership, positions and SQK balances at the receipt's block. After a merge,
ownerOf(donor)must revert. - Show the result as final only after your confirmation depth.