Appearance
Withdrawal lifecycle
Burning a token is irreversible. Sending a Steam trade offer is not guaranteed. The withdrawal design exists to make sure the second is possible before the first is allowed.
The states
┌───────────┐ preflight passed; burn is enabled in the UI
│ REQUESTED │
└─────┬─────┘
│ owner calls deTokenize(); the indexer observes Detokenized
▼
┌────────────────┐
│ BURN_CONFIRMED │ the NFT is destroyed; a return offer is now owed
└───────┬────────┘
│ custody sends the return trade offer
▼
┌────────────┐
│ OFFER_SENT │ the offer is in your Steam client
└─────┬──────┘
│ you accept
▼
┌───────────┐
│ COMPLETED │ (terminal)
└───────────┘
Declined / expired / cancelled offers return to BURN_CONFIRMED and a fresh
offer is sent. After repeated failures, or a burn with no matching
reservation:
┌────────────────┐
│ NEEDS_RECOVERY │ a person picks it up; the claim is not lost
└────────────────┘Preflight: everything checked before the burn is offered
The preflight runs before the UI enables the burn button. Returning "ok" is what enables it. Each check below is a refusal, not a warning.
1. The token is minted and not burned
A token already burned, or unknown to the protocol, cannot be withdrawn. Refused with token not withdrawable.
2. A trade URL is on file, and it resolves to a real account
The return offer needs somewhere to go. Without a Steam trade URL there is no route, and burning would produce a claim with no delivery address. Refused with no trade URL on file; a malformed one is refused too, because it names no destination at all.
The URL's partner value is resolved to a SteamID, and preflight returns it so the interface can tell you which Steam account is about to receive the item. It is allowed to be a different account from the one you are signed in as — alts and separate trading accounts are supported. A trade URL is an unauthenticated bearer link naming whoever wrote it and a redemption cannot be undone, so the destination is shown rather than assumed: check it before you burn.
3. ownerOf equals the caller's session-verified wallet
The authoritative owner is read from the contract — not from the indexed database:
ts
// Fails CLOSED: a revert or an RPC error means "cannot confirm", not "probably fine"
async function onChainOwner(tokenId: string) {
try {
return { owner: (await lootSkins.ownerOf(tokenId)).toLowerCase(), confirmed: true };
} catch {
return { owner: null, confirmed: false };
}
}If ownership cannot be confirmed — the node is unreachable, the call reverts — the preflight refuses with could not confirm on-chain ownership right now. It does not fall back to a cached value. An indexed owner is a mirror that may be seconds stale; a withdrawal is irreversible.
The confirmed owner must equal the wallet bound to the caller's session, which the caller proved control of by signing in. Both halves are required: the chain says who owns it, the session says who is asking.
4. No fillable order exists for the token
This is the least obvious check and it protects a third party.
A signed order outlives the token it sells. deTokenize destroys the NFT, but the maker's signature is still valid and its nonce is still unconsumed. Any buyer still holding that order would pay gas for a fulfillOrder that can only revert — the maker no longer owns a token that no longer exists.
The damage is bounded: the indexer marks the order invalid as soon as it sees the burn, so the listing stops being advertised within a poll. But "we clean it up a few seconds later" is not a reason to hand out an irreversible action. The rule is: cancel the listing, then withdraw.
The check mirrors the contract's expiry semantics exactly. Only an order that could still fill blocks a withdrawal:
ts
function orderBlocksWithdrawal(status: string, expiry: bigint, nowSec: bigint): boolean {
return status === "ACTIVE" && expiry >= nowSec;
}>=, not >, because the contract reverts on block.timestamp > order.expiry — an order is still fillable during the second it expires. A filled, cancelled, invalidated, or expired order is already dead and does not block anything.
5. A healthy custody account holds the item
Finally, the underlying item must actually be in custody right now, in an account that is reachable and healthy. If nothing holds it, or the holding account is not currently healthy, the burn is not offered.
This is the check that makes the whole flow honest: LootFi will not let you destroy your claim while it cannot see the thing being claimed.
Reservation
Only when all five pass is a REQUESTED record created, bound to the caller's address. An existing open reservation is reused only if it belongs to the caller — one address can never take over another address's pending withdrawal.
Batches
deTokenize takes an array, so a batch withdrawal is all-or-nothing at preflight: every token must pass independently before any burn is offered. The first failure aborts the batch and names the token that failed.
The burn
solidity
function deTokenize(uint256[] calldata tokenIds) external payable {
if (len == 0) revert EmptyBatch();
if (len > MAX_BATCH) revert BatchTooLarge(len);
uint256 required = withdrawFee * len;
if (msg.value < required) revert InsufficientFee(required, msg.value);
for (uint256 i; i < len; ++i) {
uint256 tokenId = tokenIds[i];
if (_ownerOf(tokenId) != msg.sender) revert NotOwner(tokenId);
_burn(tokenId);
}
emit Detokenized(msg.sender, tokenIds);
}Direct ownership only — approval is deliberately rejected
_ownerOf(tokenId) != msg.sender compares against the direct owner. An approved operator — even one with setApprovalForAll — cannot burn on the owner's behalf. This is not an oversight; ERC-721 would ordinarily permit it.
The reason is routing. Detokenized(owner, tokenIds) is the event the withdrawal is driven from, and owner is msg.sender. If an operator could burn, the event's owner would be the operator, and the return offer would be routed to whoever triggered the burn rather than to whoever actually owned the token.
By requiring direct ownership, Detokenized.owner is always the true owner, so the item always returns to the right person.
The practical consequence: a marketplace, a vault contract, or any other approved operator cannot withdraw your skin. Only you can.
Other properties
MAX_BATCHis 50. Above that,BatchTooLarge. An empty array reverts withEmptyBatch.withdrawFeeis per token, charged in native ETH. ReadwithdrawFee()on chain for the current value; see Fees & royalties.- Excess value is not refunded. Send exactly
withdrawFee × tokenIds.length. This is the one place in the system that does not refund an overpayment — quoted here explicitly rather than left for you to discover. tokenIdsis in the event's DATA field, not indexed. Indexing an array hashes it and makes the ids unrecoverable from logs. See Indexing.
Burn confirmation
The indexer observes Detokenized and confirms the withdrawal — but only for a reservation whose owner matches the burn's owner. A reservation made by a different address is never confirmed by someone else's burn.
If a burn is observed with no matching owner-bound reservation — a direct on-chain burn by someone who never used the LootFi interface, for instance — a recovery record is created instead. The claim is not lost; it just needs a person, because there is no trade URL on file to send anything to.
Return offer
Once burn-confirmed, the item is sent from the account holding it to the trade URL stored on the reservation. The withdrawal moves to OFFER_SENT.
| Outcome | What happens |
|---|---|
| You accept | COMPLETED. The skin is in your inventory. |
| You decline | Back to BURN_CONFIRMED; a fresh offer is sent. |
| The offer expires | Back to BURN_CONFIRMED; a fresh offer is sent. |
| The offer is cancelled | Back to BURN_CONFIRMED; a fresh offer is sent. |
| Sending fails repeatedly | NEEDS_RECOVERY — escalated to a person, with the failure reason recorded. |
A declined or expired offer is not a failure state. It is retried automatically, with backoff. Only repeated send failures escalate.
The returned item has its own Steam hold
The skin arriving in your inventory is a freshly traded item, so Steam applies its own trade protection to it — the same window that applied when you deposited. That is Valve's rule about the item and is entirely outside LootFi's control. See Trade protection.
NEEDS_RECOVERY
A recovery state means a human resolves it. It is not a lost claim and it is not a terminal failure. It is reached in two ways:
- The return offer could not be sent after repeated attempts.
- A burn was observed with no matching owner-bound reservation — typically a direct on-chain burn, where no trade URL was ever recorded.
The burn transaction, the token id, and the burning address are all recorded on chain and are independently verifiable by you. See Troubleshooting for how to raise one.
Why this order of operations
The design constraint is simple: the burn is the irreversible step, and it happens on chain where LootFi cannot intervene. Everything that could make the return impossible is therefore checked before the burn is offered, not after it happens.
The alternative — burn first, discover the problem afterwards — produces a user with no token and no skin and no recourse. Every refusal in the preflight is there to prevent one specific version of that outcome.
Related
- Withdraw your skin — the same flow, from the UI.
- Custody model — how the return is routed.
- LootSkins (ERC-721) —
deTokenizeinterface. - Marketplace — cancelling before you withdraw.