Dispute Resolution
What happens when an asset is contested — and exactly how much of that is built today.
Everything described here runs on Base Sepolia (chainId 84532), a public testnet. The contracts have not completed a third-party security audit and are pre-production software. Do not use them to settle real-value claims.
How a dispute works today
A dispute on TAG IT is not a support ticket, and there is no case-filing API. The only dispute mechanism that exists is a lifecycle state on the TAGITCore contract: an asset can be moved into FLAGGED, moved back out by resolvers, or retired permanently. That is the entire surface: flag, batchFlag, approveResolve, resolve and recycle, all on one contract.
| Question | Answer today |
|---|---|
| Where does a dispute live? | On chain, as the asset's lifecycle state in TAGITCore on Base Sepolia |
| Who can open one? | Only an address holding the FLAGGER capability badge. Not the public, and not the owner by virtue of ownership |
| What evidence is stored? | None on chain. flag(uint256 tokenId) takes a token ID and nothing else — no reason code, no document hash |
| Who decides the outcome? | Addresses holding the RESOLVER capability badge. Two of them must approve the same recipient |
| What outcomes exist? | Two: back to the exact pre-flag state (resolve), or terminal RECYCLED (recycle) |
| Refunds, penalties, blacklists? | Not implemented. No contract and no service moves money or bans a party as a result of a flag |
An earlier version of this tutorial documented dispute filing, evidence upload, AI photo analysis, DAO arbitration, staked appeals, automated refunds, fraud-metrics reporting and dispute webhooks through an SDK and a REST API. None of it exists — see What does not exist yet. Every code sample below is either verified running against the live testnet, or sits under a heading that says it is not available.
The FLAGGED state
Assets move through a seven-state lifecycle. FLAGGED (state 5) is the dispute state, and it is the only state that can move backwards.
| ID | State | Relevance to a dispute |
|---|---|---|
0 | NONE | Token does not exist |
1 | MINTED | Digital twin exists but no tag is bound — not flaggable, there is no physical good to contest |
2 | BOUND | Tag bound. Flaggable — manufacturing recall or pre-sale theft |
3 | ACTIVATED | Passed QA, ready for market. Flaggable |
4 | CLAIMED | Held by an end owner. Flaggable — lost, stolen, or authenticity contested |
5 | FLAGGED | The dispute state. Awaiting resolver approvals |
6 | RECYCLED | Terminal. No transition out, ever |
The transitions a dispute can take:
{BOUND | ACTIVATED | CLAIMED} --flag()--> FLAGGED
FLAGGED --resolve()--> the EXACT pre-flag state
{any live state} --recycle()--> RECYCLED (terminal)
resolve() does not blindly return an asset to CLAIMED. The contract records the state held immediately before the flag and restores that exact state, so a flag followed by a resolve is state-neutral and cannot be used to skip a forward transition such as claim().
Step 1: Read an asset's dispute status
This is the part that works right now, for anyone, with no key and no account. Reads are open. The snippet below runs as written against Base Sepolia.
import { createPublicClient, http } from 'viem';
import { baseSepolia } from 'viem/chains';
// TAGITCore proxy on Base Sepolia (chainId 84532)
const TAGIT_CORE = '0x3aDc7EFDb58Ae85483eFf5D4966D916185f31d1D';
const coreAbi = [
{
type: 'function',
name: 'getAsset',
stateMutability: 'view',
inputs: [{ name: 'tokenId', type: 'uint256' }],
outputs: [
{ name: 'assetOwner', type: 'address' },
{ name: 'timestamp', type: 'uint64' },
{ name: 'state', type: 'uint8' },
{ name: 'flags', type: 'uint8' },
{ name: 'reserved', type: 'uint16' }
]
},
{
type: 'function',
name: 'getResolveApprovalStatus',
stateMutability: 'view',
inputs: [{ name: 'tokenId', type: 'uint256' }],
outputs: [
{ name: 'approvalCount', type: 'uint256' },
{ name: 'recipient', type: 'address' },
{ name: 'quorumReached', type: 'bool' }
]
}
];
const STATES = ['NONE', 'MINTED', 'BOUND', 'ACTIVATED', 'CLAIMED', 'FLAGGED', 'RECYCLED'];
const client = createPublicClient({
chain: baseSepolia,
transport: http('https://sepolia.base.org')
});
async function checkDisputeStatus(tokenId) {
const [assetOwner, timestamp, state] = await client.readContract({
address: TAGIT_CORE,
abi: coreAbi,
functionName: 'getAsset',
args: [tokenId]
});
console.log('owner: ', assetOwner);
console.log('state: ', state, `(${STATES[state]})`);
console.log('last change:', new Date(Number(timestamp) * 1000).toISOString());
if (state !== 5) {
console.log('disputed: no — asset is not FLAGGED');
return;
}
const [approvalCount, recipient, quorumReached] = await client.readContract({
address: TAGIT_CORE,
abi: coreAbi,
functionName: 'getResolveApprovalStatus',
args: [tokenId]
});
console.log('disputed: yes — FLAGGED, awaiting resolver approvals');
console.log('approvals: ', approvalCount, 'of 2 required');
console.log('recipient: ', recipient);
console.log('resolvable: ', quorumReached);
}
await checkDisputeStatus(50n);
Actual output for token 50, run on 26 July 2026:
owner: 0x458B4d0c3a55006965Fd13D6af7B8509De51Cb3D
state: 4 (CLAIMED)
last change: 2026-07-16T23:56:24.000Z
disputed: no — asset is not FLAGGED
If you would rather not write code, the same state is rendered by the public verification pages on verify.tagit.network: /asset/<tokenId> for a token (check an asset on-chain), /tag/<uid> for a chip UID, and /01/<gtin>/21/<serial> for a GS1 identifier.
getAsset tells you what the ledger says about a token. It cannot tell you whether the object in someone's hand is the tagged object. That requires a real NFC tap, which produces the one-time picc and cmac values consumed by GET https://verify.tagit.network/api/verify?picc=<32hex>&cmac=<16hex>. Those values cannot be fabricated or replayed from a URL, so a link alone proves nothing.
Step 2: Flagging a contested asset
Flagging is capability-gated. TAGITCore.flag(uint256 tokenId) requires the calling address to hold the FLAGGER capability in TAGITAccess (0xb56A1D91995C212342FaA843468F03521340A1D6); the capability id is uint256(keccak256("FLAGGER")). A call from any other address reverts. There is no public "report this item" transaction, and the current owner of an asset cannot flag it unless they separately hold the badge.
The related functions on TAGITCore:
| Function | Capability required | Notes |
|---|---|---|
flag(uint256 tokenId) | FLAGGER | Moves one asset from BOUND / ACTIVATED / CLAIMED to FLAGGED |
batchFlag(uint256[] tokenIds) | FLAGGER | Recall path. Atomic, capped at 100 token IDs; every item counts against the same rate limit as an individual call |
recycle(uint256 tokenId) | RECYCLER | Retires an asset from any live state. Terminal — use it when a flagged asset is unrecoverable |
You can check whether an address is allowed to flag or resolve before you try. Replace 0xYOUR_WALLET_ADDRESS with the address you want to test — the rest runs as written:
import { createPublicClient, http, keccak256, toBytes } from 'viem';
import { baseSepolia } from 'viem/chains';
const TAGIT_ACCESS = '0xb56A1D91995C212342FaA843468F03521340A1D6';
const accessAbi = [
{
type: 'function',
name: 'hasCapability',
stateMutability: 'view',
inputs: [
{ name: 'account', type: 'address' },
{ name: 'capabilityId', type: 'uint256' }
],
outputs: [{ type: 'bool' }]
}
];
// TAGITCore derives capability ids as uint256(keccak256("<NAME>"))
const FLAGGER = BigInt(keccak256(toBytes('FLAGGER')));
const RESOLVER = BigInt(keccak256(toBytes('RESOLVER')));
const client = createPublicClient({
chain: baseSepolia,
transport: http('https://sepolia.base.org')
});
async function canDispute(address) {
const [canFlag, canResolve] = await Promise.all([
client.readContract({ address: TAGIT_ACCESS, abi: accessAbi, functionName: 'hasCapability', args: [address, FLAGGER] }),
client.readContract({ address: TAGIT_ACCESS, abi: accessAbi, functionName: 'hasCapability', args: [address, RESOLVER] })
]);
console.log(address);
console.log(' can flag(): ', canFlag);
console.log(' can resolve():', canResolve);
}
await canDispute('0xYOUR_WALLET_ADDRESS');
Mass-flagging protection
Flagging is rate-limited on chain (NIST IR-4 circuit breaker). The flag that reaches the threshold within the window trips the breaker, and every flag after that reverts with CircuitBreakerCooldown until the cooldown expires; batchFlag counts per item, so batching cannot be used to slip past it. Two read-only views expose the current position, and both are open to anyone:
# Is the breaker tripped, and how long until it resets?
cast call 0x3aDc7EFDb58Ae85483eFf5D4966D916185f31d1D \
"getFlagCircuitBreakerStatus()(bool,uint256)" \
--rpc-url https://sepolia.base.org
# false
# 0
# How many more flags fit in the current window?
cast call 0x3aDc7EFDb58Ae85483eFf5D4966D916185f31d1D \
"getFlagCircuitBreakerCapacity()(uint256)" \
--rpc-url https://sepolia.base.org
# 50
If you are not a badge holder
There is no self-service route to obtain a FLAGGER badge, and no queue that turns a consumer report into an on-chain flag. If you believe a tagged asset is stolen or counterfeit, raise it with the brand or issuer that tagged it — they hold the capability. For pilot access to write functions, get in touch.
Step 3: Resolving a flag
Coming back out of FLAGGED takes two transactions from two different resolvers, then a third to execute. All three require the RESOLVER capability.
approveResolve(uint256 tokenId, address newOwner)— a resolver approves a recipient. The first approver fixes the recipient for that round; any later approver must pass the identical address or the call reverts withRecipientMismatch. No resolver can approve twice in the same round.- A second resolver repeats the call with the same
newOwner. The quorum constantRESOLVE_QUORUMis2— read it yourself from the contract. resolve(uint256 tokenId, address newOwner)— executes once quorum is met. It restores the exact pre-flag state, updates the owner, clears the approvals and bumps the round nonce so stale approvals cannot be reused.
Two rules on newOwner are worth knowing before you design around this:
- For a consumer asset (pre-flag state
CLAIMED), the asset is reassigned to the recipient the resolvers approved. This is the lost/stolen recovery path. - For a manufacturing-phase asset (pre-flag state
BOUNDorACTIVATED),newOwnermust equal the current owner. Resolvers cannot redirect unsold inventory to a third party.
Progress is public. getResolveApprovalStatus(uint256) returns (approvalCount, recipient, quorumReached) — it is the second read in the Step 1 snippet, and for token 50 it currently returns 0, the zero address, and false, because that asset is not currently flagged.
RECYCLED is terminal. Once a disputed asset is recycled there is no path back to CLAIMED, no appeal, and no administrative override. Resolve first; recycle only when the asset is genuinely gone.
Events to watch
There are no dispute webhooks. If you need to react to a flag, subscribe to contract logs on Base Sepolia. These are the three events the flag and resolve paths emit, exactly as declared in TAGITCore:
event StateChanged(uint256 indexed tokenId, State from, State to, address actor);
event ResolveApproved(uint256 indexed tokenId, address indexed approver, uint256 approvalCount);
event CustodyTransfer(
uint256 indexed assetId,
uint8 fromState,
uint8 toState,
address indexed fromOwner,
address indexed toOwner,
uint256 timestamp,
bytes32 prevStateHash
);
State is a Solidity enum, so it appears as uint8 in the ABI. CustodyTransfer.prevStateHash links each transition to the previous one, which is what makes the flag/resolve history a verifiable chain rather than a list of independent events.
AIRP recovery cases — deployed, not yet connected
The section below documents a contract that is deployed but not wired into custody. Opening a case today locks a token bond and emits events — it does not move, freeze or return an asset. The read-only commands here work; there is deliberately no write example.
TAGITRecovery, the AIRP (AI Recovery Protocol) contract, is live on Base Sepolia at 0x6BC3C69367E586810A3B317fA9F0406504e95866. It implements a staked, badge-weighted voting process over recovery cases. Its on-chain parameters, read from the deployment on 26 July 2026:
| Parameter | Value on chain |
|---|---|
| Stake bond to open a case | 100000000000000000000 (100 TAGIT, token 0x5f98B83cD7Aef769cc51D2FB739BA49D561170DE) |
| Voting duration | 604800 seconds (7 days) |
| Minimum votes for quorum | 3 |
| Approval threshold | 6600 basis points (66%) |
| Slash on a rejected claim | 5000 basis points (50% of the bond to the treasury) |
| Appeal bond | 2× the original bond |
| Vote weight by badge | Governance 4, Manufacturer 3, Certified Verifier 2, Verifier 1, no badge 0 (cannot vote) |
| Cases opened to date | nextCaseId is 1 — no case has ever been created |
# Verify those numbers yourself
cast call 0x6BC3C69367E586810A3B317fA9F0406504e95866 \
"nextCaseId()(uint256)" --rpc-url https://sepolia.base.org
# 1
cast call 0x6BC3C69367E586810A3B317fA9F0406504e95866 \
"minimumStake()(uint256)" --rpc-url https://sepolia.base.org
# 100000000000000000000 [1e20]
The three gaps
These are why the contract is not usable as a dispute system yet. Each is verifiable in the source and on chain:
- Resolution does not move the asset.
executeResolution()settles the stake bond and closes the case. The source carries an explicit note where the transfer would go: "In production, this would call TAGITCore.resolve() to transfer the NFT to the claimant." It does not call it. - The contract holds no lifecycle capability.
TAGITAccess.hasCapability()returnsfalsefor the recovery contract on bothFLAGGERandRESOLVER, so it could not flag or resolve an asset even if the call were wired in. - Quarantine is bookkeeping only.
isQuarantined(tokenId)is internal to the recovery contract.TAGITCorecontains no reference to it and never consults it, so a quarantined asset is not restricted on chain.
Until those are closed, the authoritative dispute path is the one in Step 2 and Step 3: flag, two approveResolve calls, then resolve, all on TAGITCore. When AIRP is connected, this page will document it with a working example — not before.
What does not exist yet
Named plainly, so nobody plans a sprint around it:
| Thing you might expect | Reality |
|---|---|
| A dispute or claims REST API | Does not exist. api.tagit.network serves GET /health and a payment-metered POST /verify, which returns an x402 payment envelope when called without payment. There is no dispute, case, evidence or appeal endpoint, and no /v1 namespace |
| Dispute methods in an SDK | Do not exist. @tagit/sdk is not published to npm, so npm install will not resolve it. It is TypeScript only, it covers agent identity, reputation and validation plus WTag and Voucher helpers, and it still defaults to OP Sepolia (11155420), deprecated on 27 June 2026 in favour of Base Sepolia (84532) |
| Evidence upload — photos, receipts, scan logs | Does not exist. Nothing in the flag path accepts a document, a hash or a note |
| AI adjudication of a claim | Does not exist. No service analyses images, scores confidence or recommends an outcome |
| DAO arbitration and appeals over disputes | Not connected. The staking, voting and appeal logic exists only in the unwired TAGITRecovery contract above, which has never had a case |
| Automated refunds, seller flagging, tag blacklisting | Do not exist in any contract or service |
| Dispute webhooks or notifications | Do not exist. Subscribe to contract events instead |
| Fraud-metrics or dispute-rate reporting | Does not exist |
| Filing deadlines, SLAs, resolution-time guarantees | Do not exist. Nothing enforces a window, because nothing accepts a filing |
| A sandbox or staging host | Does not exist. Base Sepolia is the test environment |
What to do today
- Read the state before you trust a claim.
getAssetis keyless, free and authoritative about what the ledger holds. State5means a badge holder has flagged the asset; state6means it is retired for good. - Insist on a tap, not a link. Only a physical NFC read produces the
piccandcmacpair the verification endpoint accepts. A QR code or URL that merely points at a token ID proves nothing about the object. - Index the events.
StateChangedandCustodyTransferare the audit trail.prevStateHashchains them, so a gap or a fork is detectable. - Keep the capability set small.
FLAGGERandRESOLVERare the whole security model for disputes. The two-approval quorum with a matching recipient is what prevents a single compromised resolver from redirecting an asset — it only helps if the two resolvers are genuinely separate parties. - Resolve before you recycle. One restores the asset to the state it held before the flag; the other ends it permanently.
- Do not settle real value on this. Base Sepolia is a testnet and the contracts are unaudited.