Deed track contracts
Each Deed track contract: what it is for, what it stores, its public functions and who may call
them, its events, and how it upgrades. The lifecycle that ties them together is in
protocol-overview.md. Deployed addresses are in the root
README.md (Deployed addresses); who holds each role is
in Governance.
On this page
DeedNFTcontracts/core/DeedNFT.solDEFAULT_ADMIN_ROLEValidatorcontracts/core/Validator.solIValidator)ValidatorRegistrycontracts/core/ValidatorRegistry.solFundManagercontracts/core/FundManager.solDEFAULT_ADMIN_ROLEMetadataRenderercontracts/core/MetadataRenderer.solSubdividecontracts/extensions/Subdivide.solDEFAULT_ADMIN_ROLEFractionalizecontracts/extensions/Fractionalize.solDEFAULT_ADMIN_ROLEFractionTokencontracts/core/FractionToken.solFractionTokenFactorycontracts/core/factories/FractionTokenFactory.solValidatorFactorycontracts/core/factories/ValidatorFactory.solTheir interfaces are in contracts/core/interfaces/: IDeedNFT, IValidator, IValidatorRegistry,
IFundManager, IMetadataRenderer, ISubdivide, IFractionalize, IFractionTokenFactory.
DeedNFT does not declare that it implements IDeedNFT, and the two differ — see
Subdivide.
Every contract's initialize grants all of its roles, or its ownership, to the deployer.
DeedNFT
The T-Deed: one ERC-721 token per real-world asset, described by traits. Imports Limit Break's
ICreatorToken for the royalty-enforcement interface, and reports ERC-7496 (0xaf332f3e) in
supportsInterface.
State: nexttokenId (from 1), the default validator, the validator registry, metadataRenderer,
fundManager, the trait store (tokenId → key → bytes) and trait names, the approved-marketplace
list and the royalty-enforcement flag. totalSupply is a running count of live tokens.
mintAsset(owner, assetType, uri, definition, configuration, validatorAddress, token, salt)MINTER_ROLE, not pausedgenerateUniqueTokenId(owner, assetType, definition, salt)mintAsset uses when salt is non-zero: a hash mod 10⁹burnAsset(tokenId), burnBatchAssets(tokenIds)updateValidationStatus(tokenId, isValid, validatorAddress)VALIDATOR_ROLE, active in the registry, validatorAddress == msg.senderisValidated and validatorupdateMetadata(tokenId, uri, operatingAgreement, definition, configuration)VALIDATOR_ROLE, not pausedoperatingAgreementName). From a non-validator, also resets validationsetTrait(tokenId, traitKey, traitValue, valueType)VALIDATOR_ROLEisValidated, validator and beneficiary; assetType only from VALIDATOR_ROLE. A 32-byte key is used as is, anything else is hashed and its name recorded. valueType 0 raw bytes, 1 string, 2 number, 3 booleanremoveTrait(tokenId, traitName)VALIDATOR_ROLEisValidated, validator, beneficiary and assetTypegetTraitValue, getTraitValues, getTraitKeys, getTraitName, getTraitMetadataURIgetValidationStatus(tokenId)(isValidated, validator)tokenURI(tokenId)royaltyInfo(tokenId, salePrice)getRoyaltyFeePercentage in basis points. Reverts while the deed names no validator, as after an owner's updateMetadataaddMinter, removeMinterDEFAULT_ADMIN_ROLEMINTER_ROLEaddValidator, removeValidatorDEFAULT_ADMIN_ROLE or the validator registryVALIDATOR_ROLEsetDefaultValidatorDEFAULT_ADMIN_ROLEsetValidatorRegistry, setFundManager, setContractURI, setTraitNameDEFAULT_ADMIN_ROLEsetMetadataRenderer(renderer)DEFAULT_ADMIN_ROLEVALIDATOR_ROLE from the old renderer to the new onesetApprovedMarketplace, setRoyaltyEnforcement, setToDefaultSecurityPolicyDEFAULT_ADMIN_ROLEsetTransferValidator, getTransferValidator, getTransferValidationFunctionICreatorToken plumbing. The stored address is not consulted by any transferpause, unpauseDEFAULT_ADMIN_ROLEupdateMetadata; not transfers or setTraitRoyalty enforcement. While on, approve and setApprovalForAll revert with "!mkt" for an
operator not on the marketplace list, and _beforeTokenTransfer reverts a transfer whose sender is
neither the owner nor an approved marketplace. Every transfer also rewrites beneficiary.
Events: DeedNFTMinted, DeedNFTBurned, DeedValidated, TraitUpdated, MetadataRendererUpdated,
ContractURIUpdated, MarketplaceApproved, RoyaltyEnforcementChanged, TransferValidatorUpdated.
Validator
Checks deeds and units against per-asset-type criteria, and sets what a deed costs to mint and
pays in royalties. One Validator serves one primary DeedNFT plus a list of compatible contracts
(which must include Subdivide for unit validation). It is both Ownable and AccessControl.
State: baseUri, defaultOperatingAgreementUri, primaryDeedNFT and compatibleDeedNFTs,
supportedAssetTypes, validationCriteria per asset type (requiredTraits, additionalCriteria,
requireOperatingAgreement, requireDefinition), named operating agreements, the payment-token
whitelist and serviceFee per token, royaltyFeePercentage (default 500 bps) and royaltyReceiver,
fundManager, tokenRegistry.
validateDeed(tokenId)VALIDATOR_ROLEvalidateSubdivisionUnit(subdivideContract, deedId, unitId)VALIDATOR_ROLESubdivide.updateUnitValidationStatus. subdivideContract must be compatiblesetAssetTypeSupport(assetTypeId, supported)CRITERIA_MANAGER_ROLEsetValidationCriteria(assetTypeId, requiredTraits, additionalCriteria, requireOperatingAgreement, requireDefinition)CRITERIA_MANAGER_ROLEadditionalCriteria is stored but nothing checks itsetupValidationCriteria(assetTypeId)CRITERIA_MANAGER_ROLEcountry, state, county, parcelNumber, legalDescription; Vehicle make, model, year, vin; Commercial Equipment manufacturer, model, serialNumber, year. All four require an agreement and a definitionregisterOperatingAgreement(uri, name)METADATA_ROLEvalidateDeed matches againstsetOperatingAgreementName, removeOperatingAgreementNameMETADATA_ROLEaddWhitelistedToken, removeWhitelistedToken, setServiceFee(token, fee)FEE_MANAGER_ROLEsetRoyaltyFeePercentage(bps)FEE_MANAGER_ROLEsetRoyaltyReceiver(receiver)FEE_MANAGER_ROLEwithdrawServiceFees(token)FEE_MANAGER_ROLEFundManager.withdrawValidatorFees, which pays the royalty receiverwithdrawRoyalties(token)FundManagertoken: the FundManager commission to its fee receiver, the rest to the royalty receiversetBaseUri, setDefaultOperatingAgreement, setFundManager, setTokenRegistrysetDeedNFT, setPrimaryDeedNFT, addCompatibleDeedNFT, removeCompatibleDeedNFTgrantRole(role, account)DEFAULT_ADMIN_ROLEsupportsAssetType, defaultOperatingAgreement, operatingAgreementName, validateOperatingAgreement, getValidationCriteria, getServiceFee, isTokenWhitelisted, getRoyaltyFeePercentage, getRoyaltyReceiver, getRoyaltyBalance, isCompatibleDeedNFT, getBaseUriisTokenWhitelisted is true for the local list or the TokenRegistryRecognising an operating agreement. validateDeed accepts an agreement that is registered
exactly, contains baseUri, or contains any URI passed to registerOperatingAgreement. A new
deed's agreement is the default URI with its token id appended, so it passes only if the default URI
contains baseUri or has been registered. The public validateOperatingAgreement uses a different
rule — registered exactly, or containing the default URI.
ADMIN_ROLE is declared and checked by FundManager.withdrawValidatorFees, but initialize grants
it to no one.
Events: DeedValidated, ValidationError, ValidationCriteriaUpdated, OperatingAgreementRegistered,
DeedNFTUpdated, PrimaryDeedNFTUpdated, CompatibleDeedNFTUpdated, RoyaltyWithdrawn.
ValidatorRegistry
The list of validators, and the switch that turns each one on. A validator is registered
inactive; activating it is what lets DeedNFT mint against it and lets it write validation.
State: validators (isActive, supportedAssetTypes, name, description), assetTypeValidators
(asset type → validator addresses), the address list, fundManager, deedNFT.
registerValidator(validator, name, description, supportedAssetTypes)FundManager rolesupdateValidatorStatus(validator, isActive)VALIDATOR_ROLE on DeedNFT for every registered validator, and FEE_MANAGER_ROLE on the FundManagerremoveValidator(validator)DeedNFT role. Its entries in assetTypeValidators stayupdateValidatorName(validator, newName)getValidatorAssetTypes(validator)supportsAssetType(0…3) from the validator and stores the resultsetDeedNFT, setFundManagerisValidatorRegistered, isValidatorActive, getValidatorName, getSupportedAssetTypes, getActiveValidators, validators, assetTypeValidatorsEvery function that changes state is onlyOwner. REGISTRY_ADMIN_ROLE is granted to the deployer
and checked by none of them.
Events: ValidatorRegistered (also emitted on rename, and on removal with an empty name),
ValidatorStatusUpdated, ValidatorAssetTypesUpdated, DeedNFTUpdated.
FundManager
Takes the service fee at mint and keeps each validator's share until it is withdrawn.
State: compatibleDeedNFTs, validatorRegistry, the commission in basis points, feeReceiver,
per-validator per-token balances, the list of validators it has given FEE_MANAGER_ROLE, a token
list, tokenRegistry.
processPayment(payer, validatorAddress, token, serviceFee)DeedNFTTokenRegistry, and the fee must equal the validator's getServiceFee. Pulls the fee from payer, sends the commission to feeReceiver, credits the rest to the validatorwithdrawValidatorFees(validatorContract, token)ADMIN_ROLE or FEE_MANAGER_ROLE on that validator, or FEE_MANAGER_ROLE herewithdrawRoyaltyCommission(validatorContract, token)ADMIN_ROLE or FEE_MANAGER_ROLEValidator.withdrawRoyaltiesupdateValidatorRoles()ADMIN_ROLE or the registryFEE_MANAGER_ROLE from validators it granted it to, then grants it to every active onesetCommissionPercentage(bps)ADMIN_ROLEinitialize accepts up to 10,000setFeeReceiver, setValidatorRegistry, setTokenRegistryADMIN_ROLEaddCompatibleDeedNFT, removeCompatibleDeedNFTADMIN_ROLEDeedNFTs may call processPaymentaddWhitelistedToken, removeWhitelistedTokenADMIN_ROLEprocessPayment does not readgetValidatorFeeBalance, getCommissionPercentage, commissionPercentage, feeReceiver, isCompatibleDeedNFT, getCompatibleDeedNFTs, getWhitelistedTokens, formatFeeMINTER_ROLE is granted to the deployer and checked by nothing.
Events: ServiceFeeCollected, ValidatorFeesWithdrawn, CommissionPercentageUpdated,
FeeReceiverUpdated, ValidatorRegistryUpdated, CompatibleDeedNFTUpdated.
MetadataRenderer
Builds the JSON DeedNFT.tokenURI returns. It combines the deed's traits with what it stores
itself, keyed by token id for its one deedNFT. An unvalidated deed shows the invalidated image
(defaultImageURIs[255]). The output format is in metadata-renderer.md.
State: default image and background colour per asset type; per token, the stored metadata (name, description, image, colours, animation URL, external link, gallery, documents, custom metadata), features, condition and legal information.
tokenURI(tokenId), contractURI()syncTraitUpdate(tokenId, traitKey, traitValue)deedNFT onlyname, description, image, background_color, animation_url, gallery and customMetadata traits into storagesetTokenFeatures, setAssetCondition, setTokenLegalInfo, manageTokenDocument, setTokenAnimationURL, setTokenExternalLinkDeedNFT VALIDATOR_ROLE holder, or the deed's ownersetTokenCustomMetadata(tokenId, metadata)DeedNFT as the customMetadata traitsetTokenGallery, setDeedNFTDEFAULT_ADMIN_ROLEDeedNFT it renderssetAssetTypeImageURI, setAssetTypeBackgroundColor, setInvalidatedImageURIgetTokenFeatures, getAssetCondition, getTokenLegalInfo, getTokenDocument, getTokenDocuments, getTokenGallery, getTokenAnimationURL, getTokenExternalLinkEvents: MetadataUpdate, MetadataSynced.
Subdivide
Splits a validated Land or Estate deed into ERC-1155 units, each its own token with a supply of
one. The unit's token id is (deedId << 128) | unitId. The deed stays with its owner.
State: subdivisions per deed (name, description, symbol, collectionUri, totalUnits,
activeUnits, isActive, burnable, collectionAdmin, subdivisionValidator, per-unit metadata),
unit traits, unit owners, deedNFT, validatorRegistry. defaultValidator is set to zero at
initialize and has no setter, so a unit's validator is the subdivision's, or else the deed's.
createSubdivision(deedId, name, description, symbol, collectionUri, totalUnits, burnable)mintUnit(deedId, unitId, to), batchMintUnits(deedId, unitIds, recipients)totalUnits; a zero recipient means the deed's owner. Copies assetType, operatingAgreement, definition, configuration and the validator from the deedburnUnit(deedId, unitId)burnabledeactivateSubdivision(deedId)updateUnitValidationStatus(deedId, unitId, isValid, validatorAddress)VALIDATOR_ROLE, active in the registryisValidated and validator on the unitsetUnitMetadata, setBurnable, setSubdivisionValidator, transferCollectionAdminsetUnitTrait, setUnitTraitFlexible, removeUnitTraitassetType reverts — see belowaddValidator, removeValidator, setUnitTraitNameADMIN_ROLEVALIDATOR_ROLE; trait namessetApprovedMarketplace, setRoyaltyEnforcement, setToDefaultSecurityPolicy, setTransferValidatorADMIN_ROLEpause, unpauseADMIN_ROLEuri(tokenId)collectionUri/unitIdroyaltyInfo(tokenId, salePrice)royaltyInfosubdivisions, subdivisionExists, unitExists, ownerOf, getUnitAssetType, isUnitValidated, getUnitValidationStatus, getSubdivisionValidator, supportsSubdivision, getUnitTraitValue, getUnitTraitValues, getUnitTraitKeys, getUnitTraitName, getUnitMetadata, getUnitTraitMetadataURIThe deed is not locked. Its owner can still transfer or burn it. Minting units follows the deed's current owner; the collection admin stays whoever created the subdivision until it is handed on.
DeedNFT has no getAssetType. IDeedNFT declares it and Subdivide calls it, so
setUnitTrait or setUnitTraitFlexible on the assetType key reverts, as does getUnitAssetType
for a unit with no assetType trait of its own.
No tests cover Subdivide.
Events: SubdivisionCreated, UnitMinted, UnitBurned, UnitValidated, UnitTraitUpdated,
UnitMetadataUpdated, BurnableStatusChanged, SubdivisionValidatorUpdated,
SubdivisionDeactivated, CollectionAdminTransferred, MarketplaceApproved,
RoyaltyEnforcementChanged.
Fractionalize
Holds a deed and issues ERC-20 shares against it. Each fraction has an id from nextFractionId
(from 1) and its own FractionToken.
State per fraction: name, description, symbol, collectionUri, totalShares, activeShares,
maxSharesPerWallet (set to totalShares), requiredApprovalPercentage, isActive, burnable,
the asset type and original token id, collectionAdmin, tokenAddress.
createFraction(params)params is FractionCreationParams: assetType (DeedNFT or SubdivisionNFT), originalTokenId, name, description, symbol, collectionUri, totalShares, burnable, approvalPercentage (51–100). Takes the asset and has the factory create the token. Does not check validationmintShares(fractionId, amount, to), batchMintShares(params)totalShares; a zero recipient means the callerburnShares(fractionId, amount)burnableunlockAsset(params)params.topause, unpauseADMIN_ROLEgetFractionBasicInfo, getFractionExtendedInfo, getFractionOwnershipInfo, getFractionToken, getApprovals, canReceiveShares, getVotingPowervalidateAsset, validateDeedNFTAsset, validateSubdivisionAsset, getAssetInformation, getDeedNFTAssetDetails, getDeedNFTAssetTrait, getDeedNFTAssetTraitKeys, getSubdivisionAssetDetails, getSubdivisionAssetTrait, getSubdivisionAssetTraitKeysWho can unlock. With no shares minted, only the collection admin. With shares minted, the caller
must hold every minted share (getVotingPower == activeShares); those shares are burned first. The
checkApprovals route compares the caller's adminApprovals against requiredApprovalPercentage,
but no function sets approvals, so it never passes.
What blocks an unlock or a lock:
- Burning shares needs
burnable. A non-burnable fraction cannot be unlocked once any share is minted. - Shares burned with
FractionToken.burndirectly do not reduceactiveShares, after which no one can hold "every minted share". Burn throughburnShares. createFractionmoves a deed withtransferFrom, sent byFractionalize. WhileDeedNFTenforces royalties,Fractionalizemust be on itssetApprovedMarketplacelist for both the owner'sapproveand the transfer to succeed.- A subdivision unit is moved with
safeTransferFrom, which requires the receiver to implementonERC1155Received.Fractionalizedoes not, so fractionalizing a unit reverts.
No tests cover Fractionalize, FractionToken or FractionTokenFactory.
Events: FractionCreated, AssetLocked, SharesMinted, SharesBurned, AssetUnlocked.
FractionToken
The ERC-20 share token for one fraction. Records fractionalizeContract and fractionId.
mint(to, amount)MINTER_ROLE (Fractionalize), not pausedmaxSharesPerWalletburnFrom(from, amount)MINTER_ROLE, not pausedburnableburn(amount)burnabletransfer, transferFrommaxSharesPerWallet (0 means no cap)setMaxSharesPerWallet, setBurnable, pause, unpauseADMIN_ROLEgetVotingPower(account), canReceive(account, amount)Each token is an EIP-1167 clone initialized by the factory, so the factory holds its admin roles —
and the factory has no function that uses them. The admin setters, pause and upgrade cannot be
reached. A clone has no ERC-1967 implementation slot, so UUPS's onlyProxy check would refuse an
upgrade anyway.
Events: MaxSharesPerWalletUpdated, BurnableStatusUpdated.
FractionTokenFactory
Clones the FractionToken implementation for each fraction. initialize deploys the
implementation with new FractionToken().
createFractionToken(fractionId, name, symbol, maxSharesPerWallet, burnable)fractionalizeContract onlyFractionalize as minter, records it. One token per fraction idupdateImplementation(newImplementation)updateFractionalizeContract(newContract)getTokenByFractionId, isToken, getAllCreatedTokens, getCreatedTokenCount, fractionTokenImplementationEvents: FractionTokenCreated, ImplementationUpdated.
ValidatorFactory
Meant to deploy a configured Validator and register it in one call. It has no deploy script, is
not in deployments/, and has no tests.
createValidator(name, description, supportedAssetTypes, royaltyFeePercentage, serviceFee, baseUri, operatingAgreementUri) is open to anyone. It deploys new Validator() and calls initialize on it
directly, but Validator's constructor calls _disableInitializers(), so that call reverts. Even
past that, ValidatorRegistry.registerValidator is onlyOwner, so the factory would have to own the
registry. Its owner-only setters are addDefaultWhitelistedToken, removeDefaultWhitelistedToken,
updateValidatorRegistry and updateFundManager. Events: ValidatorCreated,
TokenWhitelisted, TokenRemovedFromWhitelist.
Deployment and wiring
deploy/deploy_all.ts deploys and wires ValidatorRegistry, MetadataRenderer, Validator,
DeedNFT, FundManager and Subdivide; deploy/06_deploy_Fractionalize.ts adds
FractionTokenFactory and Fractionalize. All are UUPS proxies. The numbered scripts 00–04
call roles and functions the current contracts do not have — OPERATOR_ROLE, ADMIN_ROLE on
DeedNFT and MetadataRenderer, setAssetTypeSupported, FundManager.setDeedNFT, and
initializer arguments that no longer match — so deploy_all.ts is the one that runs against them.
05 matches the current Subdivide.
What deploy_all.ts wires:
- 1.
ValidatorRegistry.setDeedNFT,Validator.setDeedNFT,MetadataRenderer.setDeedNFTandDeedNFT.setMetadataRenderer, plus the renderer's default images. - 2.
ValidatorRegistry.setFundManager, thenregisterValidatorandupdateValidatorStatus(true)for the default validator — which grants itVALIDATOR_ROLEonDeedNFTandFEE_MANAGER_ROLEon theFundManager. - 3.
Validator.setAssetTypeSupportfor all four types (no criteria) andValidator.setFundManager. - 4.
SubdivideADMIN_ROLEto the deployer andVALIDATOR_ROLEto the validator.
What it leaves for an admin:
DeedNFT.setFundManager— until then minting charges no fee. Once it is set,FundManager.addCompatibleDeedNFT(DeedNFT)must be too, or every mint reverts inprocessPayment.Validator.setupValidationCriteriaorsetValidationCriteria— until then every supported deed validates.Validator.registerOperatingAgreementfor the default agreement.Validator.addCompatibleDeedNFT(Subdivide)— until thenvalidateSubdivisionUnitreverts.DeedNFT.setApprovedMarketplace(Fractionalize, true)— until thencreateFractionreverts while royalties are enforced.
deployments/ does not record whether these were done on Base Sepolia. The Validator and
FundManager ABIs saved in deployments/base-sepolia/ predate setTokenRegistry. Upgrades use
deploy/upgrade/; the steps are in Operators.