Features Solutions Technology Tokenomics Docs About Launch App
Docs / Tutorials / Dispute Resolution

Dispute Resolution

What happens when an asset is contested — and exactly how much of that is built today.

Testnet, and Unaudited

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.

QuestionAnswer 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
What was removed from this page

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.

IDStateRelevance to a dispute
0NONEToken does not exist
1MINTEDDigital twin exists but no tag is bound — not flaggable, there is no physical good to contest
2BOUNDTag bound. Flaggable — manufacturing recall or pre-sale theft
3ACTIVATEDPassed QA, ready for market. Flaggable
4CLAIMEDHeld by an end owner. Flaggable — lost, stolen, or authenticity contested
5FLAGGEDThe dispute state. Awaiting resolver approvals
6RECYCLEDTerminal. 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.

A state read is not a physical authenticity check

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:

FunctionCapability requiredNotes
flag(uint256 tokenId)FLAGGERMoves one asset from BOUND / ACTIVATED / CLAIMED to FLAGGED
batchFlag(uint256[] tokenIds)FLAGGERRecall path. Atomic, capped at 100 token IDs; every item counts against the same rate limit as an individual call
recycle(uint256 tokenId)RECYCLERRetires 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.

  1. 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 with RecipientMismatch. No resolver can approve twice in the same round.
  2. A second resolver repeats the call with the same newOwner. The quorum constant RESOLVE_QUORUM is 2 — read it yourself from the contract.
  3. 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:

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.

recycle() cannot be undone

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

Do not build on this yet

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:

ParameterValue on chain
Stake bond to open a case100000000000000000000 (100 TAGIT, token 0x5f98B83cD7Aef769cc51D2FB739BA49D561170DE)
Voting duration604800 seconds (7 days)
Minimum votes for quorum3
Approval threshold6600 basis points (66%)
Slash on a rejected claim5000 basis points (50% of the bond to the treasury)
Appeal bond2× the original bond
Vote weight by badgeGovernance 4, Manufacturer 3, Certified Verifier 2, Verifier 1, no badge 0 (cannot vote)
Cases opened to datenextCaseId 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:

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 expectReality
A dispute or claims REST APIDoes 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 SDKDo 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 logsDoes not exist. Nothing in the flag path accepts a document, a hash or a note
AI adjudication of a claimDoes not exist. No service analyses images, scores confidence or recommends an outcome
DAO arbitration and appeals over disputesNot 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 blacklistingDo not exist in any contract or service
Dispute webhooks or notificationsDo not exist. Subscribe to contract events instead
Fraud-metrics or dispute-rate reportingDoes not exist
Filing deadlines, SLAs, resolution-time guaranteesDo not exist. Nothing enforces a window, because nothing accepts a filing
A sandbox or staging hostDoes not exist. Base Sepolia is the test environment

What to do today

Edit this page on GitHub
Type to search documentation...