Skip to main content

Tutorial: Adding a New Facet to ATS Contracts

This comprehensive guide walks you through creating and integrating a new facet into the Asset Tokenization Studio (ATS) smart contract system.

Table of Contents​

Overview​

Facets in ATS are modular contract components that implement specific features using the Diamond Pattern (EIP-2535). Each facet:

  • Encapsulates a specific domain feature (e.g., rewards, voting, dividends)
  • Can be independently upgraded via the Business Logic Resolver
  • Shares storage with other facets through inheritance
  • Is registered via a unique resolver key

When to Create a New Facet​

Create a new facet when you need to:

  • Add a new domain feature (e.g., staking, governance, rewards)
  • Extend token capabilities without modifying existing facets
  • Implement jurisdiction-specific rules (Layer 3)
  • Separate read/write operations for gas optimization

Prerequisites​

Required Knowledge​

  • Solidity 0.8.x
  • Diamond Pattern (EIP-2535)
  • Proxy patterns
  • Storage slot management
  • Access control patterns

Development Environment​

# From monorepo root
npm ci
npm run ats:contracts:build

# Navigate to contracts package
cd packages/ats/contracts

Facet Anatomy​

Two-Part Structure​

Every facet consists of two contracts:

  1. Business Logic Contract (Abstract)

    • Contains the actual implementation
    • Inherits from Common (Layer 1)
    • Implements domain-specific interface
    • Can be tested independently
  2. Facet Wrapper (Concrete)

    • Thin wrapper implementing IStaticFunctionSelectors
    • Provides metadata for diamond pattern registration
    • Returns resolver key, function selectors, and interface IDs

File Organization​

contracts/layer_2/myFeature/
├── MyFeature.sol # Business logic (abstract)
├── MyFeatureFacet.sol # Facet wrapper (concrete)
└── interfaces/
└── myFeature/
├── IMyFeature.sol # Public interface
└── IMyFeatureStorageWrapper.sol # Storage events/errors

Step-by-Step Implementation​

Step 1: Define the Interface​

Create the public interface defining your facet's functionality.

File: contracts/layer_2/interfaces/rewards/IRewards.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

/**
* @title IRewards
* @notice Interface for token holder rewards functionality
*/
interface IRewards {
/**
* @notice Emitted when rewards are distributed
*/
event RewardDistributed(address indexed tokenHolder, uint256 amount, uint256 timestamp);

/**
* @notice Distribute rewards to token holder
* @param _tokenHolder Address receiving rewards
* @param _amount Reward amount
*/
function distributeReward(address _tokenHolder, uint256 _amount) external returns (bool success_);

/**
* @notice Get total rewards earned by holder
* @param _tokenHolder Address to query
* @return totalRewards_ Total rewards earned
*/
function getRewards(address _tokenHolder) external view returns (uint256 totalRewards_);
}

Step 2: Create Storage Wrapper (if needed)​

If your facet requires custom storage, create a storage wrapper under contracts/domain/{asset,core}/. The storage struct lives at file scope (not inside any contract or interface), carries an ERC-7201 @custom:storage-location annotation, and follows the 5-region layout: R1 Lifecycle (bool flags) → R2 Packed scalars (uint8, bytes3, address, enum) → R3 Single-slot scalars (uint256, bytes32, string) → R4 Aggregates (mapping, array, EnumerableSet, checkpoint arrays) → APPEND-ONLY ZONE. New fields go below the marker — the boundary is greppable and audit-visible. All four region banners are always present, in canonical order, even when a region has no fields — the empty banners are scaffolding that fixes each field's insertion point and the region numbering. Never renumber a region when its only field is removed; leave the empty banner in place.

File: contracts/domain/asset/rewards/RewardsStorageWrapper.sol

// SPDX-License-Identifier: Apache-2.0
pragma solidity >=0.8.0 <0.9.0;

import { IRewardsStorageWrapper } from "./IRewardsStorageWrapper.sol";

/// @custom:hash storage Rewards
bytes32 constant STORAGE_LOCATION_REWARDS = 0x0000000000000000000000000000000000000000000000000000000000000000;

/// @custom:storage-location erc7201:security.token.standard.storage.Rewards
struct RewardsDataStorage {
// ─── R1 Lifecycle (bool flags) ───────────────────────────
bool initialized;
// ─── R2 Packed scalars (uint8, bytes3, address, enum) ────
// ─── R3 Single-slot scalars (uint256, bytes32, string) ───
uint256 totalDistributed;
// ─── R4 Aggregates (mapping, array, EnumerableSet) ───────
mapping(address => uint256) totalRewards;
mapping(address => uint256) lastDistribution;
// ─── APPEND-ONLY ZONE BELOW ───
}

/**
* @title RewardsStorageWrapper
* @notice Storage management for rewards functionality
*/
abstract contract RewardsStorageWrapper is IRewardsStorageWrapper {
/**
* @notice Access rewards storage at the ERC-7201 namespace slot.
* @return rewardsData_ Storage pointer
*/
function _rewardsStorage() internal pure returns (RewardsDataStorage storage rewardsData_) {
bytes32 position = STORAGE_LOCATION_REWARDS;
assembly {
rewardsData_.slot := position
}
}

/**
* @notice Get total rewards for holder
*/
function _getTotalRewards(address _tokenHolder) internal view returns (uint256) {
return _rewardsStorage().totalRewards[_tokenHolder];
}

/**
* @notice Add rewards to holder's balance
*/
function _addRewards(address _tokenHolder, uint256 _amount) internal {
RewardsDataStorage storage rs = _rewardsStorage();
rs.totalRewards[_tokenHolder] += _amount;
rs.totalDistributed += _amount;
rs.lastDistribution[_tokenHolder] = block.timestamp;
}
}

Step 3: Define Storage Events/Errors Interface​

File: contracts/domain/asset/rewards/IRewardsStorageWrapper.sol

// SPDX-License-Identifier: Apache-2.0
pragma solidity >=0.8.0 <0.9.0;

/**
* @title IRewardsStorageWrapper
* @notice Events and errors for rewards storage
*/
interface IRewardsStorageWrapper {
/**
* @notice Emitted when rewards feature is initialized
*/
event RewardsInitialized(address indexed operator);

/**
* @notice Error when reward amount is zero
*/
error RewardAmountIsZero();

/**
* @notice Error when rewards already initialized
*/
error RewardsAlreadyInitialized();
}

Step 4: Define Resolver Key​

Declare the resolver key as a file-scope constant at the top of the facet's interface file (I<Feature>.sol). The annotation /// @custom:hash resolverKey <PascalName> tells the codegen which formula to apply; the hex literal is a placeholder that npm run generate:hashes (or the post-compile hook in hardhat compile) rewrites to the canonical value.

File: contracts/layer_2/rewards/IRewards.sol

// SPDX-License-Identifier: Apache-2.0
pragma solidity >=0.8.0 <0.9.0;

/// @custom:hash resolverKey Rewards
bytes32 constant RESOLVER_KEY_REWARDS = 0x0000000000000000000000000000000000000000000000000000000000000000;

interface IRewards {
// ... function declarations ...
}

The canonical hex is derived from keccak256("asset.tokenization.standard.resolverKey.Rewards"). Do not hand-edit the hex; the CI gate npm run check:hashes will fail on drift.

Step 5: Define Storage Position​

Already handled in Step 2 — the file-scope STORAGE_LOCATION_REWARDS constant sits above the storage wrapper contract and uses the /// @custom:hash storage Rewards annotation. The codegen applies the ERC-7201 derivation keccak256(abi.encode(uint256(keccak256("asset.tokenization.standard.storage.Rewards")) - 1)) & ~bytes32(uint256(0xff)) to produce the canonical hex.

Step 6: Define Roles (if needed)​

Roles stay in the central contracts/constants/roles.sol (they are cross-cutting, unlike per-facet resolver keys and storage locations). Use the canonical naming and the /// @custom:hash role <PascalName> annotation.

File: contracts/constants/roles.sol

// Append to existing file
/// @custom:hash role RewardsDistributor
bytes32 constant ROLE_REWARDS_DISTRIBUTOR = 0x0000000000000000000000000000000000000000000000000000000000000000;

The canonical hex is keccak256("asset.tokenization.standard.role.RewardsDistributor").

Step 7: Create Business Logic Contract​

Implement the core facet logic.

File: contracts/layer_2/rewards/Rewards.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import { IRewards } from "../interfaces/rewards/IRewards.sol";
import { Common } from "../../layer_1/common/Common.sol";

/**
* @title Rewards
* @notice Business logic for token holder rewards
*/
abstract contract Rewards is IRewards, Common {
/**
* @notice Initialize rewards functionality
* @dev Can only be called once
*/
function initialize_Rewards() external override onlyUninitialized(_rewardsStorage().initialized) {
_rewardsStorage().initialized = true;
emit RewardsInitialized(_msgSender());
}

/**
* @notice Distribute rewards to token holder
* @param _tokenHolder Address receiving rewards
* @param _amount Reward amount
* @return success_ True if distribution succeeded
*/
function distributeReward(
address _tokenHolder,
uint256 _amount
)
external
override
onlyUnpaused
onlyRole(ROLE_REWARDS_DISTRIBUTOR)
validateAddress(_tokenHolder)
returns (bool success_)
{
if (_amount == 0) revert RewardAmountIsZero();

// Verify holder is KYC approved
if (_getKycStatus(_tokenHolder) != IKyc.KycStatus.GRANTED) {
revert InvalidKycStatus();
}

// Add rewards to holder's balance
_addRewards(_tokenHolder, _amount);

emit RewardDistributed(_tokenHolder, _amount, block.timestamp);

success_ = true;
}

/**
* @notice Get total rewards earned by holder
* @param _tokenHolder Address to query
* @return totalRewards_ Total rewards earned
*/
function getRewards(
address _tokenHolder
) external view override validateAddress(_tokenHolder) returns (uint256 totalRewards_) {
totalRewards_ = _getTotalRewards(_tokenHolder);
}
}

Step 8: Create Facet Wrapper​

Implement the concrete facet with metadata.

File: contracts/layer_2/rewards/RewardsFacet.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import { Rewards } from "./Rewards.sol";
import { IStaticFunctionSelectors } from "../../interfaces/resolver/resolverProxy/IStaticFunctionSelectors.sol";
import { IRewards } from "../interfaces/rewards/IRewards.sol";

/**
* @title RewardsFacet
* @notice Facet wrapper for Rewards business logic
*/
contract RewardsFacet is Rewards, IStaticFunctionSelectors {
/**
* @notice Get the resolver key for this facet
* @return Unique bytes32 resolver key
*/
function getStaticResolverKey() external pure override returns (bytes32) {
return RESOLVER_KEY_REWARDS;
}

/**
* @notice Get all function selectors this facet provides
* @return Function selector array
*/
function getStaticFunctionSelectors() external pure override returns (bytes4[] memory) {
bytes4[] memory selectors = new bytes4[](3);
selectors[0] = this.initialize_Rewards.selector;
selectors[1] = this.distributeReward.selector;
selectors[2] = this.getRewards.selector;
return selectors;
}

/**
* @notice Get all interface IDs this facet implements
* @return Interface ID array
*/
function getStaticInterfaceIds() external pure override returns (bytes4[] memory) {
bytes4[] memory ids = new bytes4[](1);
ids[0] = type(IRewards).interfaceId;
return ids;
}
}

Step 9: Update Common Contract (if needed)​

If your facet requires storage access across all facets, update the Common contract inheritance chain.

File: contracts/layer_1/common/Common.sol

// Add RewardsStorageWrapper to inheritance
abstract contract Common is
// ... existing wrappers
RewardsStorageWrapper,
// ... other wrappers
{
// ... existing code
}

Step 10: Update Registry​

Add your facet to the deployment registry.

File: scripts/domain/atsRegistry.ts

import { RewardsFacet__factory } from "../../typechain-types";
import { RESOLVER_KEY_REWARDS } from "./constants";

// Add to FACET_FACTORIES
export const FACET_FACTORIES = {
// ... existing facets
RewardsFacet: RewardsFacet__factory,
// ... more facets
};

// Add to FACET_REGISTRY (auto-generated after compilation)
// Run: npm run generate:registry

Step 11: Add to Configurations​

Include your facet in equity/bond configurations as appropriate.

File: scripts/domain/equity/createConfiguration.ts

export async function createEquityConfiguration(
blr: BusinessLogicResolver,
facetAddresses: Map<string, string>,
options?: CreateConfigurationOptions,
): Promise<CreateConfigurationResult> {
const facetConfigurations: FacetConfiguration[] = [
// ... existing facets
{
facetName: "RewardsFacet",
resolverKey: atsRegistry.getFacetDefinition("RewardsFacet").resolverKey.value,
address: facetAddresses.get("RewardsFacet")!,
},
// ... more facets
];

// ... rest of configuration creation
}

Step 12: Compile and Generate Types​

# Compile contracts
npm run compile

# Generate TypeChain types
npm run typechain

# Update registry
npm run generate:registry

Testing Your Facet​

Step 1: Create Unit Tests​

File: test/layer_2/rewards/Rewards.test.ts

import { expect } from "chai";
import { ethers } from "hardhat";
import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers";
import { RewardsFacet } from "../../../typechain-types";

describe("RewardsFacet", function () {
let rewardsFacet: RewardsFacet;
let owner: SignerWithAddress;
let distributor: SignerWithAddress;
let tokenHolder: SignerWithAddress;

beforeEach(async function () {
[owner, distributor, tokenHolder] = await ethers.getSigners();

// Deploy facet
const RewardsFacetFactory = await ethers.getContractFactory("RewardsFacet");
rewardsFacet = await RewardsFacetFactory.deploy();
await rewardsFacet.waitForDeployment();

// Setup roles (would normally be done via AccessControl facet)
// ... role setup
});

describe("Initialization", function () {
it("should initialize rewards functionality", async function () {
await expect(rewardsFacet.initialize_Rewards())
.to.emit(rewardsFacet, "RewardsInitialized")
.withArgs(owner.address);
});

it("should reject double initialization", async function () {
await rewardsFacet.initialize_Rewards();
await expect(rewardsFacet.initialize_Rewards()).to.be.revertedWithCustomError(rewardsFacet, "AlreadyInitialized");
});
});

describe("Reward Distribution", function () {
beforeEach(async function () {
await rewardsFacet.initialize_Rewards();
});

it("should distribute rewards to token holder", async function () {
const amount = ethers.parseEther("100");

await expect(rewardsFacet.connect(distributor).distributeReward(tokenHolder.address, amount))
.to.emit(rewardsFacet, "RewardDistributed")
.withArgs(tokenHolder.address, amount, await time.latest());

const rewards = await rewardsFacet.getRewards(tokenHolder.address);
expect(rewards).to.equal(amount);
});

it("should reject zero amount", async function () {
await expect(
rewardsFacet.connect(distributor).distributeReward(tokenHolder.address, 0),
).to.be.revertedWithCustomError(rewardsFacet, "RewardAmountIsZero");
});

it("should reject invalid address", async function () {
await expect(
rewardsFacet.connect(distributor).distributeReward(ethers.ZeroAddress, 100),
).to.be.revertedWithCustomError(rewardsFacet, "InvalidAddress");
});
});

describe("Metadata", function () {
it("should return correct resolver key", async function () {
const key = await rewardsFacet.getStaticResolverKey();
expect(key).to.equal(RESOLVER_KEY_REWARDS);
});

it("should return function selectors", async function () {
const selectors = await rewardsFacet.getStaticFunctionSelectors();
expect(selectors).to.have.lengthOf(3);
});

it("should return interface IDs", async function () {
const ids = await rewardsFacet.getStaticInterfaceIds();
expect(ids).to.have.lengthOf(1);
});
});
});

Step 2: Run Tests​

npm run test -- test/layer_2/rewards/Rewards.test.ts

Integration Guide​

Deploy Your Facet​

Add to deployment workflow:

import { deployFacets } from "./infrastructure/operations/facetDeployment";
import { RewardsFacet__factory } from "../../typechain-types";

const facetFactories = {
// ... existing facets
RewardsFacet: RewardsFacet__factory,
};

const result = await deployFacets(facetFactories, {
confirmations: 2,
enableRetry: true,
});

Register in BLR​

import { registerFacets } from "./infrastructure/operations/registerFacets";

const facetsToRegister = [
{
name: "RewardsFacet",
address: deployedAddresses.get("RewardsFacet"),
resolverKey: atsRegistry.getFacetDefinition("RewardsFacet").resolverKey.value,
},
];

await registerFacets(blr, facetsToRegister);

Create Token with New Facet​

Deploy a token using the updated configuration:

// Configuration already includes RewardsFacet
const tx = await factory.createEquityToken(
configId,
version, // Use latest version with new facet
initData,
);

Best Practices​

Naming Conventions​

ElementConventionExample
Business logic contractPascalCaseRewards, Staking
Facet wrapperPascalCase + "Facet"RewardsFacet
InterfaceI + ContractNameIRewards
Storage wrapperContractName + "StorageWrapper"RewardsStorageWrapper
Storage interfaceI + StorageWrapperIRewardsStorageWrapper
Resolver keyRESOLVER_KEY_FEATURERESOLVER_KEY_REWARDS
Storage positionSTORAGE_LOCATION_FEATURESTORAGE_LOCATION_REWARDS
RoleROLE_NAMEROLE_REWARDS_DISTRIBUTOR
InitializationinitializeFeatureNameinitializeRewards

Storage Management​

  1. Always use unique storage positions: Use keccak256 of unique strings
  2. Access storage via assembly: Follow EIP-1967 pattern
  3. Inherit storage wrappers: Add to Common for cross-facet access
  4. Document storage layout: Add comments explaining structure

Access Control​

  1. Use role-based modifiers: onlyRole(ROLE_REWARDS_DISTRIBUTOR)
  2. Add pause support: onlyUnpaused modifier
  3. Validate addresses: validateAddress(_tokenHolder)
  4. Check KYC status: Verify compliance for sensitive operations

Gas Optimization​

  1. Separate read/write operations: Consider split facets (like Bond/BondRead)
  2. Use unchecked blocks: For safe arithmetic
  3. Minimize storage writes: Batch updates when possible
  4. Pack storage: Use smaller data types when appropriate

Error Handling​

  1. Use custom errors: More gas-efficient than require strings
  2. Descriptive error names: RewardAmountIsZero vs InvalidAmount
  3. Document error conditions: Add NatSpec comments

Event Emission​

  1. Emit events for state changes: Required for off-chain tracking
  2. Include indexed parameters: For efficient filtering
  3. Use descriptive event names: RewardDistributed vs Distributed

Examples​

Example 1: Minimal Read-Only Facet​

Simple facet with no state changes:

// TokenMetadataFacet.sol - Read-only token metadata
abstract contract TokenMetadata is ITokenMetadata, Common {
function getTokenMetadata() external view override returns (MetadataData memory) {
return MetadataData({ name: _name(), symbol: _symbol(), decimals: _decimals(), totalSupply: _totalSupply() });
}
}

Example 2: Separate Read/Write Facets​

For complex features with many read operations:

// StakingWrite.sol - Write operations
abstract contract StakingWrite is IStakingWrite, Common {
function stake(uint256 amount) external override {
/* ... */
}
function unstake(uint256 amount) external override {
/* ... */
}
}

// StakingRead.sol - Read operations
abstract contract StakingRead is IStakingRead, Common {
function getStakedBalance(address holder) external view override {
/* ... */
}
function getStakingRewards(address holder) external view override {
/* ... */
}
}

Example 3: Layer 3 Jurisdiction-Specific Facet​

Extending Layer 2 functionality:

// RewardsUSA.sol - USA-specific rewards rules
abstract contract RewardsUSA is IRewardsUSA, Rewards {
function distributeRewardWithTaxWithholding(
address _tokenHolder,
uint256 _grossAmount,
uint256 _taxRate
) external override returns (uint256 netAmount_) {
// Calculate net after tax
netAmount_ = _grossAmount - ((_grossAmount * _taxRate) / 10000);

// Distribute net rewards
_addRewards(_tokenHolder, netAmount_);

emit RewardDistributedWithTax(_tokenHolder, _grossAmount, _taxRate, netAmount_);
}
}

Next Steps​

After implementing your facet:

  1. Write comprehensive tests: Unit tests + integration tests
  2. Update documentation: Add usage examples
  3. Security audit: Review access control and storage safety
  4. Deploy to testnet: Verify functionality
  5. Integrate with SDK: Create SDK handlers for facet operations

Support​

For questions and issues: