Ownership Transfer
How ownership actually moves on TAG IT: two contract functions, and nothing else. Every read and every revert shown below was executed against the live deployment on 26 July 2026 and reproduces exactly as printed. The one exception is the signed transferAsset example, which needs the asset owner's private key — it is written against the deployed ABI but is not a transcript of a transaction we broadcast.
TAGITCore runs on Base Sepolia testnet (chain ID 84532) and is unaudited. Treat transfers here as integration and evaluation material, not as settlement of real title.
How ownership moves
Ownership lives in exactly one place: the TAGITCore contract. There is no transfer API, no off-chain pending-transfer object, no expiry timer and no buyer-confirmation step. An asset's owner changes when — and only when — one of two contract functions succeeds.
| Function | Who may call it | Lifecycle effect |
|---|---|---|
claim(uint256 tokenId, address newOwner) |
Holder of the CLAIMER capability badge — the brand or retailer, not the consumer |
ACTIVATED (3) → CLAIMED (4) |
transferAsset(uint256 tokenId, address to) |
The current asset owner, and nobody else | Stays CLAIMED (4); only the owner address changes |
So there are two distinct movements. Claim is the handover out of the supply chain into consumer hands, and it is permissioned — a consumer cannot claim an asset to themselves. transferAsset is the peer-to-peer resale primitive, and it is gated on ownership rather than on any badge: whoever owns the asset may sell it, with no approval from TAG IT.
Standard ERC-721 transfers are disabled
TAGITCore is an ERC-721 contract, but it overrides _update() with a single check — if (auth != address(0)) revert TransferDisabled();. Every externally initiated token movement carries a non-zero auth, so transferFrom and safeTransferFrom always revert with TransferDisabled(). The internal _transfer calls made by claim and transferAsset pass auth == address(0) and are the only paths that move a token. On-chain state and physical custody therefore cannot drift apart. This is verifiable without a wallet:
Approvals are not blocked — they are inert. approve and setApprovalForAll are not overridden, so they succeed and write approval state as usual. What fails is any transfer that tries to use that approval. Do not read a successful setApprovalForAll as evidence that an operator can move your asset; it cannot.
# The real owner of token 50 attempting a plain ERC-721 transfer
cast call 0x3aDc7EFDb58Ae85483eFf5D4966D916185f31d1D \
"transferFrom(address,address,uint256)" \
0x458B4d0c3a55006965Fd13D6af7B8509De51Cb3D 0xYOUR_WALLET_ADDRESS 50 \
--from 0x458B4d0c3a55006965Fd13D6af7B8509De51Cb3D \
--rpc-url https://sepolia.base.org
# execution reverted, data: "0xa24e573d" // TransferDisabled()
A marketplace contract cannot move a TAG IT asset on a seller's behalf. Not because the approval is refused — it will be granted — but because the transferFrom the marketplace would then call reverts with TransferDisabled(). Any listing flow that assumes standard ERC-721 escrow-by-approval will fail at settlement, not at listing. Today the seller must send the transferAsset transaction themselves. Operator and marketplace-approval support is not implemented.
Resale: transferAsset
Every precondition below is enforced on-chain. If any fails the call reverts and nothing changes.
| Revert | When |
|---|---|
TokenNotFound(tokenId) | The token has never been minted |
InvalidState(tokenId, current, required) | The asset is not in CLAIMED state — an unclaimed or flagged asset cannot be resold |
NotAssetOwner(tokenId, caller, owner) | The caller is not the current owner |
ZeroAddress() | The buyer address is 0x0 |
InvalidTransition(...) | The buyer is the current owner — a self-resale is a no-op |
You can watch the ownership check fire against the live contract, from any address, at no cost:
cast call 0x3aDc7EFDb58Ae85483eFf5D4966D916185f31d1D \
"transferAsset(uint256,address)" 50 0xYOUR_WALLET_ADDRESS \
--from 0x0000000000000000000000000000000000001234 \
--rpc-url https://sepolia.base.org
# execution reverted. Raw ABI-encoded revert data, decoded:
# selector 0xc79bcb68 NotAssetOwner(uint256,address,address)
# tokenId 50
# caller 0x0000000000000000000000000000000000001234
# actual owner 0x458B4d0c3a55006965Fd13D6af7B8509De51Cb3D
To perform a real resale, the current owner signs the transaction. Simulate first — the reverts above surface in the simulation, before you spend gas:
// npm install viem
import { createPublicClient, createWalletClient, http } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { baseSepolia } from 'viem/chains';
const TAGIT_CORE = '0x3aDc7EFDb58Ae85483eFf5D4966D916185f31d1D';
const abi = [{
name: 'transferAsset', type: 'function', stateMutability: 'nonpayable',
inputs: [
{ name: 'tokenId', type: 'uint256' },
{ name: 'to', type: 'address' },
],
outputs: [],
}];
// The account signing this MUST be the current owner of the asset.
const account = privateKeyToAccount(process.env.OWNER_PRIVATE_KEY);
const publicClient = createPublicClient({ chain: baseSepolia, transport: http() });
const walletClient = createWalletClient({ account, chain: baseSepolia, transport: http() });
const { request } = await publicClient.simulateContract({
address: TAGIT_CORE,
abi,
functionName: 'transferAsset',
args: [50n, '0xYOUR_WALLET_ADDRESS'], // buyer
account,
});
const hash = await walletClient.writeContract(request);
const receipt = await publicClient.waitForTransactionReceipt({ hash });
console.log(receipt.status, hash); // 'success', 0x…
transferAsset is a single transaction from the seller. The buyer does not confirm, cannot decline, and there is no window in which the transfer can be reversed. Verify the buyer's address out of band before you send it — and note that payment is entirely outside this call. The contract moves title; it does not move money and holds no funds.
First claim
claim(tokenId, newOwner) is the manufacturing-to-consumer handover. It requires the CLAIMER capability badge from the BIDGES access-control contract, so it is called by the brand or retailer — a consumer with a wallet and no badge cannot call it, and neither can your integration unless a badge has been issued to it.
The asset must be in ACTIVATED state (3). The call sets the state to CLAIMED (4) and moves ERC-721 ownership to newOwner in the same transaction. If you are building consumer-side software, this is a step you consume the result of rather than one you invoke.
Confirming the current owner
For most integrations this read is the whole job, and it needs no key, wallet or signup — just a public Base Sepolia RPC. It is also how you confirm a transfer landed.
// npm install viem
import { createPublicClient, http } from 'viem';
import { baseSepolia } from 'viem/chains';
const client = createPublicClient({ chain: baseSepolia, transport: http() });
const abi = [{
name: 'getAsset', type: 'function', 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' },
],
}];
const [owner, timestamp, state] = await client.readContract({
address: '0x3aDc7EFDb58Ae85483eFf5D4966D916185f31d1D',
abi,
functionName: 'getAsset',
args: [50n],
});
console.log({ owner, timestamp, state });
// → { owner: '0x458B4d0c3a55006965Fd13D6af7B8509De51Cb3D',
// timestamp: 1784246184n, state: 4 } // 4 = CLAIMED
The same read from the command line:
cast call 0x3aDc7EFDb58Ae85483eFf5D4966D916185f31d1D \
"getAsset(uint256)(address,uint64,uint8,uint8,uint16)" 50 \
--rpc-url https://sepolia.base.org
# 0x458B4d0c3a55006965Fd13D6af7B8509De51Cb3D
# 1784246184
# 4
# 0
# 0
| Code | State | Meaning for ownership |
|---|---|---|
0 | NONE | Token does not exist |
1 | MINTED | Digital twin created, no chip bound — not transferable |
2 | BOUND | NFC chip bound — not transferable |
3 | ACTIVATED | Awaiting first claim |
4 | CLAIMED | Consumer-owned — the only state transferAsset accepts |
5 | FLAGGED | Reported lost, stolen or recalled — resale blocked until resolved |
6 | RECYCLED | Terminal — ownership can never move again |
Events to index
There is no webhook delivery. To react to transfers, index these TAGITCore events from the chain with viem's watchContractEvent, a subgraph, or any log-polling client you already run.
| Event | Emitted by |
|---|---|
AssetResold(uint256 indexed tokenId, address indexed from, address indexed to) |
transferAsset |
StateChanged(uint256 indexed tokenId, State from, State to, address actor) |
claim and every other lifecycle transition |
CustodyTransfer(uint256 indexed assetId, uint8 fromState, uint8 toState, address indexed fromOwner, address indexed toOwner, uint256 timestamp, bytes32 prevStateHash) |
Both — the hash-linked custody audit trail |
Reconstructing an asset's full ownership history means reading its CustodyTransfer logs. There is no endpoint that returns that history for you.
What does not exist
Earlier versions of this page documented a transfer workflow that was never built. None of the following exists today. They are listed so you do not go looking for them, and they will be documented here with working examples on the day they ship — not before.
| Capability | Status |
|---|---|
| Two-step transfer with buyer confirmation, signature challenge and 72-hour expiry | Does not exist |
| Escrow, inspection periods, dispute arbitration or any custody of funds | Does not exist |
| Marketplace listings, sale metadata or price recorded on-chain | Does not exist |
| Webhooks and transfer event callbacks | Not planned — index the chain events instead |
| A REST transfer or transfer-history endpoint | Does not exist |
| Operator / marketplace approval so a contract can transfer on the owner's behalf | Not implemented |
The TAG IT SDK is not published to npm — npm install @tagit/sdk will fail. It is TypeScript only (there is no Python, Swift or Kotlin package), it covers agent identity, reputation and validation plus WTag and Voucher helpers, and it has no transfer, product or ownership functions at all. Its default chain is still OP Sepolia, which was retired in June 2026 in favour of Base Sepolia. For transfers, call the contract directly as shown above.
Read ownership with getAsset — open to everyone. Move ownership with transferAsset, signed by the current owner. Everything else is either a badge-gated supply-chain function or something we have not built.