Features Solutions Technology Tokenomics Docs About Launch App
Docs / Tutorials / Ownership Transfer

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.

Testnet — not production

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()
Consequence for marketplaces

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.

RevertWhen
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…
The transfer is one-sided and final

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
CodeStateMeaning for ownership
0NONEToken does not exist
1MINTEDDigital twin created, no chip bound — not transferable
2BOUNDNFC chip bound — not transferable
3ACTIVATEDAwaiting first claim
4CLAIMEDConsumer-owned — the only state transferAsset accepts
5FLAGGEDReported lost, stolen or recalled — resale blocked until resolved
6RECYCLEDTerminal — 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.

EventEmitted 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.

CapabilityStatus
Two-step transfer with buyer confirmation, signature challenge and 72-hour expiryDoes not exist
Escrow, inspection periods, dispute arbitration or any custody of fundsDoes not exist
Marketplace listings, sale metadata or price recorded on-chainDoes not exist
Webhooks and transfer event callbacksNot planned — index the chain events instead
A REST transfer or transfer-history endpointDoes not exist
Operator / marketplace approval so a contract can transfer on the owner's behalfNot implemented
About the SDK

The TAG IT SDK is not published to npmnpm 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.

The short version

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.

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