A permissioned token needs a testable answer to a simple question: why did this movement of tokens succeed? A successful wallet transfer proves little if an agent operation follows different rules, a registry change removes an eligibility requirement or a frontend mistakes a compliance check for complete transaction validation.
This walkthrough implements a recipient-country rule against the actual ERC-3643 contract suite and exercises it with 19 passing tests. It also records a deliberately broken version of the module, the failing regression and the correction. The test fixture uses real token, registry and compliance contracts.
Identity signatures are replaced with explicit test doubles, so the result establishes transfer-control behavior within that boundary.
The distinction between documentation and executable behavior matters here. The ERC-3643 specification describes minting as bypassing compliance rules. The pinned implementation used below calls canTransfer during minting. A release review must resolve that difference for its selected contracts before anyone signs an issuance transaction.
Start with a policy-to-operation map
Write the intended rule before choosing a module. In this example, an ordinary transfer requires an eligible recipient whose registered country appears in an allowlist. Wallet freezes, available balance and the token's pause state remain separate token-level checks. Country values represent registry records maintained by authorized operators; they do not reveal a wallet's physical location.
The implementation question in blockchain engineering work at Pharos Production is how a requirement becomes an observable contract behavior. For this walkthrough, the useful deliverable is a table connecting each rule to a transaction and an assertion. A statement that the token supports compliance cannot tell an integration engineer which call should revert.
| Requirement | Enforcement point | Observable test |
|---|---|---|
| Recipient has the required trusted claim | Identity registry | Missing claim or removed issuer rejects transfer |
| Recipient country is permitted | Custom compliance module | Registered recipient in a denied country cannot receive an ordinary transfer |
| Token is available for ordinary movement | Token pause modifier | Paused transfer fails even when canTransfer returns true |
| Wallet and balance restrictions apply | Token freeze checks | Frozen wallet fails; partially frozen balance retains its boundary |
| Delegated spending stays bounded | Token allowance accounting | Successful spending reduces allowance; rejected transfer preserves it |
| Privileged movement has explicit authority | Token agent role | Unauthorized forced transfer fails; authorized exceptional path is tested separately |
Decide separately what should happen when an investor's country changes while they already own tokens. This module restricts recipients. It does not automatically confiscate an existing holding, prevent every outbound movement or calculate ownership concentration. Those are different policy decisions and require their own enforcement points and tests.
Pin the contracts before interpreting the standard
The code below targets ERC-3643/ERC-3643 at commit 2f0704dee9658ad9cd5b6ed82e7427da74c28345. Its package identifies version 4.1.3. The compiler is Solidity 0.8.17, with OpenZeppelin contracts and upgradeable contracts pinned to 4.8.3 and the ONCHAINID Solidity package pinned to 2.0.0. This is a reproducible source selection, not a recommendation to deploy an unreviewed repository head.
Read the selected entry points directly. In this version, transfer and transferFrom combine token checks with recipient verification and modular compliance. Do not describe that as universal verification of both participants. If your policy requires renewed sender eligibility on every ordinary movement, add and test that requirement explicitly rather than assuming the recipient check covers it.
The standard's transfer description groups minting with forced transfers as paths that bypass compliance. However, the pinned Token implementation checks canTransfer(address(0), recipient, amount) inside mint. Our mint-denial test captures this behavior. Preserve that test when upgrading the dependency; a changed result needs an explicit issuance-policy decision.
Review inherited behavior and callbacks too. An entry point can omit an ordinary permission check while still reverting in a downstream module action. An interface name alone cannot establish that an operation will succeed.
Make identity requirements explicit
The identity registry documentation separates a wallet's registration from its verification. Registration associates the wallet with an identity and a country. Verification evaluates the required claim topics against the trusted issuer configuration. Keep those responsibilities distinct in onboarding screens and operational runbooks.
A configuration edge case deserves its own regression. In the pinned IdentityRegistry implementation, a registered identity passes verification when the required-topic list is empty. Our fixture first removes a claim and observes rejection. It then removes the sole required topic and observes acceptance. That is configuration behavior, not evidence of a newly discovered vulnerability.
Consequently, checking that every wallet has an identity contract is insufficient for a deployment that requires a particular credential. The release receipt should record the required topics, trusted issuers and each issuer's permitted topics. A reviewer should be able to explain why an empty list is acceptable or show that the deployment rejects that configuration before activation.
Topic 7 is a synthetic identifier in this fixture, not a universal KYC topic. Its issuer stub always accepts the supplied claim, and its identity stub exposes unrestricted setters for test setup. Neither stub belongs in a production deployment. Real signature validation, claim expiry and issuer-specific revocation behavior require a separate integration suite using the chosen identity implementation.
Implement one recipient-country module
Keep this first rule narrow enough to inspect. A mapping answers whether a particular compliance contract permits a particular country. The outer address key matters because a module can serve more than one compliance context. Without that separation, one deployment's policy update could unintentionally alter another deployment's behavior.
The module reads the token bound to the supplied compliance address, then reads the recipient's country through that token's identity registry. The allowlist defaults to rejection. Country zero cannot be enabled through the setter, making the example's requirement for an explicitly configured country visible in code. The fixture uses 250 and 276 as two distinct country values; neither value represents a policy recommendation.
Save the following as src/RecipientCountryModule.sol. It extends the pinned AbstractModule. Configuration requires a call from a bound compliance contract. The check also rejects an unbound compliance context. The transfer, mint and burn action hooks remain access-controlled even though this stateless rule has nothing to update.
// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.17;
import "erc3643/compliance/modular/modules/AbstractModule.sol";
import "erc3643/compliance/modular/IModularCompliance.sol";
import "erc3643/token/IToken.sol";
contract RecipientCountryModule is AbstractModule {
mapping(address => mapping(uint16 => bool)) public allowed;
event CountryPermissionSet(address indexed compliance, uint16 country, bool enabled);
function setCountry(uint16 country, bool enabled) external onlyComplianceCall {
require(country != 0, "country must be explicit");
allowed[msg.sender][country] = enabled;
emit CountryPermissionSet(msg.sender, country, enabled);
}
function moduleCheck(address, address to, uint256, address compliance)
external view override onlyBoundCompliance(compliance) returns (bool)
{
IToken token = IToken(IModularCompliance(compliance).getTokenBound());
uint16 country = token.identityRegistry().investorCountry(to);
return allowed[compliance][country];
}
function moduleTransferAction(address, address, uint256)
external override onlyComplianceCall {}
function moduleMintAction(address, uint256)
external override onlyComplianceCall {}
function moduleBurnAction(address, uint256)
external override onlyComplianceCall {}
function canComplianceBind(address) external pure override returns (bool) { return true; }
function isPlugAndPlay() external pure override returns (bool) { return true; }
function name() external pure override returns (string memory) { return "RecipientCountryModule"; }
}
A rule based only on recipient country does not need a transfer counter. If you extend it to maximum holders or rolling volume limits, the action hooks become part of correctness. Define how minting, burning and exceptional transfers update that state. Test a revert in a later callback and prove that earlier changes roll back with the transaction. This article's empty hooks cannot establish that property for a future stateful module.
Route configuration through the compliance owner
An operator should not call setCountry directly on the module. The configured compliance owner calls callModuleFunction, which invokes the module so that its msg.sender is the bound compliance contract. This arrangement gives the module the correct policy namespace while preserving the compliance contract's ownership check.
The ModularCompliance implementation evaluates its bound modules in canTransfer. With no modules attached, there is no module-level restriction to reject the transfer. Therefore, a deployment check should assert the intended module set and configured values. A green transaction against an accidentally empty set proves the wrong policy.
Permissioned issuance requires agreement between investor restrictions and executable rules. Pharos Production describes that mapping, including work with the client's legal team, in its tokenization and RWA development process. The engineering handoff should carry the approved policy into explicit module settings and transaction tests. A country allowlist by itself cannot establish that an instrument or offering meets applicable legal requirements.
Prepare the configuration receipt before unpausing. Record the token address, registry address, compliance address and module address alongside the owner and agent assignments. Include the transaction that bound the module and the transactions that set permitted countries. Compare the resulting onchain values with the intended configuration, rather than accepting a deployment script's successful exit status as sufficient evidence.
Build a reproducible local fixture
Use a fresh directory with Git, Node.js, npm and Foundry already installed. The following commands fetch the selected source revision and the pinned dependency versions. Preserve the resulting package lockfile with your test evidence. Review the repository's GPL-3.0 licensing and the licenses of imported dependencies before redistributing or combining the code.
mkdir -p erc3643-controls/src erc3643-controls/test erc3643-controls/vendor
cd erc3643-controls
git clone https://github.com/ERC-3643/ERC-3643.git vendor/erc3643
git -C vendor/erc3643 checkout 2f0704dee9658ad9cd5b6ed82e7427da74c28345
npm init -y
npm install --save-exact --ignore-scripts --no-audit --no-fund \
@openzeppelin/contracts@4.8.3 \
@openzeppelin/contracts-upgradeable@4.8.3 \
@onchain-id/solidity@2.0.0
Save this configuration as foundry.toml. The remappings are necessary because the tutorial imports the actual suite rather than recreating token behavior in a simplified mock. The suite's source files remain under vendor/erc3643; the tutorial's module and tests stay in separate directories.
[profile.default]
src = "src"
test = "test"
libs = ["vendor", "node_modules"]
solc_version = "0.8.17"
optimizer = true
optimizer_runs = 200
remappings = ["erc3643/=vendor/erc3643/contracts/", "@openzeppelin/=node_modules/@openzeppelin/", "@onchain-id/=node_modules/@onchain-id/"]
Below, the complete fixture deploys the actual registry storage, claim-topic registry, trusted-issuer registry, identity registry, modular compliance and token. It initializes each instance, binds registry storage and assigns the agent permissions required by the test. The token receives registry-agent authority for the recovery scenario. Alice and Bob start with registered identities in the permitted country.
Initialization mints 100 indivisible fixture units to Alice and then unpauses ordinary transfers. Using zero decimals makes the balance assertions easy to inspect; production assets need their own denomination and rounding tests. These are directly initialized local contract instances. The fixture does not deploy the suite's proxy factory or validate an implementation-authority upgrade.
Save the complete file as test/TransferControls.t.sol. Every test starts from the same fresh setup, so a policy change in one test cannot make another test pass accidentally.
// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.17;
import "erc3643/token/Token.sol";
import "erc3643/compliance/modular/ModularCompliance.sol";
import "erc3643/registry/implementation/IdentityRegistry.sol";
import "erc3643/registry/implementation/IdentityRegistryStorage.sol";
import "erc3643/registry/implementation/TrustedIssuersRegistry.sol";
import "erc3643/registry/implementation/ClaimTopicsRegistry.sol";
import "../src/RecipientCountryModule.sol";
interface Vm {
function prank(address) external;
function expectRevert() external;
function expectRevert(bytes calldata) external;
}
// Test doubles: no signature verification, expiry, revocation list or real KYC.
contract IssuerStub {
function isClaimValid(IIdentity, uint256, bytes calldata, bytes calldata)
external pure returns (bool) { return true; }
}
contract IdentityStub {
address public issuer;
bool public present = true;
mapping(bytes32 => bool) public management;
constructor(address source, address wallet) {
issuer = source;
management[keccak256(abi.encode(wallet))] = true;
}
function setPresent(bool value) external { present = value; }
function allowRecovery(address wallet) external {
management[keccak256(abi.encode(wallet))] = true;
}
function keyHasPurpose(bytes32 key, uint256 purpose) external view returns (bool) {
return purpose == 1 && management[key];
}
function getClaim(bytes32 id) external view
returns (uint256, uint256, address, bytes memory, bytes memory, string memory)
{
if (present && id == keccak256(abi.encode(issuer, uint256(7))))
return (7, 1, issuer, hex"01", hex"02", "");
return (0, 0, address(0), "", "", "");
}
}
contract TransferControlsTest {
Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code")))));
address constant ALICE = address(0xA11CE);
address constant BOB = address(0xB0B);
address constant SPENDER = address(0xCA11);
address constant NEW_WALLET = address(0xBEEF);
Token token;
ModularCompliance compliance;
RecipientCountryModule policy;
IdentityRegistry registry;
ClaimTopicsRegistry topics;
TrustedIssuersRegistry issuers;
IssuerStub issuer;
IdentityStub aliceIdentity;
IdentityStub bobIdentity;
function setUp() public {
topics = new ClaimTopicsRegistry(); topics.init(); topics.addClaimTopic(7);
issuers = new TrustedIssuersRegistry(); issuers.init();
issuer = new IssuerStub();
uint256[] memory required = new uint256[](1); required[0] = 7;
issuers.addTrustedIssuer(IClaimIssuer(address(issuer)), required);
IdentityRegistryStorage store = new IdentityRegistryStorage(); store.init();
registry = new IdentityRegistry();
registry.init(address(issuers), address(topics), address(store));
store.bindIdentityRegistry(address(registry)); registry.addAgent(address(this));
aliceIdentity = new IdentityStub(address(issuer), ALICE);
bobIdentity = new IdentityStub(address(issuer), BOB);
registry.registerIdentity(ALICE, IIdentity(address(aliceIdentity)), 250);
registry.registerIdentity(BOB, IIdentity(address(bobIdentity)), 250);
compliance = new ModularCompliance(); compliance.init();
token = new Token();
token.init(address(registry), address(compliance), "ControlFixture", "CFX", 0, address(0));
token.addAgent(address(this)); registry.addAgent(address(token));
policy = new RecipientCountryModule(); compliance.addModule(address(policy));
setCountry(250, true);
token.mint(ALICE, 100); token.unpause();
}
function setCountry(uint16 country, bool enabled) internal {
compliance.callModuleFunction(
abi.encodeCall(RecipientCountryModule.setCountry, (country, enabled)), address(policy));
}
function send(uint256 amount) internal {
vm.prank(ALICE); token.transfer(BOB, amount);
}
function deny() internal {
vm.expectRevert(bytes("Transfer not possible")); send(10);
require(token.balanceOf(ALICE) == 100 && token.balanceOf(BOB) == 0, "denial mutated balances");
}
function testAllowedTransfer() public {
send(10); require(token.balanceOf(BOB) == 10 && token.balanceOf(ALICE) == 90);
}
function testDeniedCountry() public { registry.updateCountry(BOB, 276); deny(); }
function testMissingClaim() public { bobIdentity.setPresent(false); deny(); }
function testIssuerRemoved() public { issuers.removeTrustedIssuer(IClaimIssuer(address(issuer))); deny(); }
function testPauseIsOutsideCanTransfer() public {
token.pause(); require(compliance.canTransfer(ALICE, BOB, 10));
vm.expectRevert(bytes("Pausable: paused")); send(10);
}
function testFrozenSender() public {
token.setAddressFrozen(ALICE, true); vm.expectRevert(bytes("wallet is frozen")); send(10);
}
function testFrozenRecipient() public {
token.setAddressFrozen(BOB, true); vm.expectRevert(bytes("wallet is frozen")); send(10);
}
function testPartialFreezeBoundary() public {
token.freezePartialTokens(ALICE, 85);
vm.expectRevert(bytes("Insufficient Balance")); send(16);
send(15); require(token.balanceOf(ALICE) == 85);
}
function testTransferFromAllowance() public {
vm.prank(ALICE); token.approve(SPENDER, 20);
vm.prank(SPENDER); token.transferFrom(ALICE, BOB, 10);
require(token.allowance(ALICE, SPENDER) == 10 && token.balanceOf(BOB) == 10);
}
function testTransferFromDeniedCountry() public {
registry.updateCountry(BOB, 276);
vm.prank(ALICE); token.approve(SPENDER, 20);
vm.expectRevert(bytes("Transfer not possible")); vm.prank(SPENDER);
token.transferFrom(ALICE, BOB, 10);
require(token.allowance(ALICE, SPENDER) == 20 && token.balanceOf(BOB) == 0);
}
function testDirectModuleConfigurationDenied() public {
vm.expectRevert(bytes("only bound compliance can call")); policy.setCountry(276, true);
}
function testNonOwnerConfigurationDenied() public {
vm.expectRevert(bytes("Ownable: caller is not the owner")); vm.prank(BOB);
compliance.callModuleFunction(abi.encodeCall(RecipientCountryModule.setCountry, (276, true)), address(policy));
}
function testForcedTransferBypassesOrdinaryGates() public {
registry.updateCountry(BOB, 276); token.pause(); token.setAddressFrozen(ALICE, true);
token.freezePartialTokens(ALICE, 70); token.forcedTransfer(ALICE, BOB, 80);
require(token.balanceOf(BOB) == 80 && token.getFrozenTokens(ALICE) == 20);
}
function testMintChecksCountry() public {
registry.updateCountry(BOB, 276);
vm.expectRevert(bytes("Compliance not followed")); token.mint(BOB, 1);
}
function testRecoveryPreservesRestrictions() public {
aliceIdentity.allowRecovery(NEW_WALLET);
token.freezePartialTokens(ALICE, 20); token.setAddressFrozen(ALICE, true); token.pause();
token.recoveryAddress(ALICE, NEW_WALLET, address(aliceIdentity));
require(token.balanceOf(ALICE) == 0 && token.balanceOf(NEW_WALLET) == 100);
require(token.getFrozenTokens(NEW_WALLET) == 20 && token.isFrozen(NEW_WALLET));
require(!registry.contains(ALICE) && registry.investorCountry(NEW_WALLET) == 250);
}
function testNonAgentForcedTransferDenied() public {
vm.expectRevert(); vm.prank(BOB); token.forcedTransfer(ALICE, BOB, 1);
}
function testEmptyTopicsAdmitRegisteredIdentity() public {
bobIdentity.setPresent(false); require(!registry.isVerified(BOB));
topics.removeClaimTopic(7); require(registry.isVerified(BOB)); send(10);
}
function testPolicyChangeInvalidatesPreflight() public {
require(compliance.canTransfer(ALICE, BOB, 10)); setCountry(250, false); deny();
}
function testComplianceContextsAreIsolated() public {
ModularCompliance second = new ModularCompliance(); second.init();
second.bindToken(address(token)); second.addModule(address(policy));
require(compliance.canTransfer(ALICE, BOB, 10));
require(!second.canTransfer(ALICE, BOB, 10));
}
}
Run forge test -vv from the project directory. The recorded local run on September 30, 2026 compiled the fixture with Solidity 0.8.17 and passed all 19 tests, with no skipped tests. That count describes these named examples. It does not measure exhaustive path coverage, successful mainnet integration or resistance to every adversarial input.
Prove that a denial test can fail
A passing rejection test is more persuasive when it detects a known defect. For the recorded negative run, the module's final check was deliberately replaced with return true. The country-change regression then failed because the expected revert did not occur. Restoring the mapping lookup made that same test pass, after which the complete suite passed again.
--- deliberately-permissive/RecipientCountryModule.sol
+++ corrected/RecipientCountryModule.sol
@@ -20,7 +20,7 @@
{
IToken token = IToken(IModularCompliance(compliance).getTokenBound());
uint16 country = token.identityRegistry().investorCountry(to);
- return true; // DELIBERATE TEST MUTATION: ignore the recipient country.
+ return allowed[compliance][country];
}
function moduleTransferAction(address, address, uint256)
This is a mutation of our tutorial module, not an upstream security finding. Keep the failing output, the correction diff and the passing output together. Otherwise a demonstration can accidentally compare different test selections or lose the source change that explains the result. Both runs used forge test --match-test testDeniedCountry -vv.
Replay of recorded local test output: deliberately permissive tutorial variant, correction and passing checks. The animation summarizes the retained outputs; it is not a terminal screen recording.
The denial helper checks unchanged balances after rejected ordinary transfers. The delegated-spending test also checks that a failed transferFrom leaves its allowance intact. Those assertions connect the error to state, which is more useful than merely observing that some call reverted. Extend this approach to event expectations and downstream accounting when adding stateful modules.
The context-isolation case deserves a precise reading. It binds the same module to a second compliance instance and confirms that the second instance does not inherit the first instance's country permission. Both compliance instances point to the same token solely to keep this probe small. This is a namespace test, not a rehearsal of two separately deployed instruments with different registries and governance.
For a production shared-module deployment, create that second instrument explicitly. Change the first instrument's country rule and verify the second instrument's full transfer result remains unchanged. Then reverse the direction of the change. Include distinct owners and registry data so that an accidental cross-reference cannot hide behind identical fixture values. Those additional cases are proposed extensions, not part of the reported 19-test result.
Review minting and exceptional movement separately
A single assertion that restrictions work hides differences between entry points. The country rule blocks ordinary receipt in the denied country and also blocks minting there in this selected implementation. Our tests exercise both. Forced transfer follows another path: an authorized agent can move tokens while the token is paused and the sender is frozen, without applying this module's ordinary country check.
The fixture freezes 70 of Alice's 100 units, then forces a transfer of 80. The asserted result is Bob holding 80 and Alice retaining 20 frozen units. This concrete balance transition is the reviewed behavior, not a generic statement that an agent can ignore everything. Recipient identity verification still matters, and the token invokes the transfer callback after moving the balance.
Our module's callback is empty. A different module may enforce additional behavior there or revert. Test the complete installed module set before describing forced transfer as guaranteed emergency liquidity. Add explicit negative cases for unauthorized agents and invalid recipients, and test your intended handling of an already frozen recipient. The provided suite covers the unauthorized-agent case; it does not claim those additional recipient cases were executed.
Burning needs its own supply and accounting tests in a production suite. This example implements the required burn hook but does not include a burn scenario. Similarly, the existence of batch entry points does not establish the desired partial-failure semantics for a custody integration. Enumerate the actual entry points the integration exposes and attach an acceptance case to each.
Treat pause and recovery as separate controls
The ERC-3643 authors, including Joachim Lebrun, make pausing an explicit requirement in the specification:
MUST have the possibility to pause the token
For an operator, the useful follow-up is the scope of that pause. Our test proves that compliance.canTransfer can return true while an ordinary transfer reverts because the token is paused. The forced-transfer test proves that an agent operation can still move a balance in that state. An incident runbook must identify which operations remain available after the pause transaction confirms.
Recovery is another privileged workflow. The fixture authorizes the replacement wallet through the identity stub's management-key check, then exercises the actual token recovery function. It asserts movement of the full balance, preservation of a 20-unit partial freeze and the wallet's frozen flag, removal of the old registry association and retention of country 250.
That result does not rotate credentials across every connected service or remove every old key from the identity contract. A real recovery procedure needs separate authorization evidence and updates to offchain custody records. It also needs a negative suite for an unauthorized replacement wallet. The identity stub deliberately makes the positive management-key condition easy to arrange; it cannot establish the security of a real key-management implementation.
A recovery review should also trace permissions across contract boundaries. The local setup grants the token permission to change the identity registry because the recovery operation needs that interaction. Checking only the caller's token-agent role would miss this dependency. Remove the supporting registry authority in a separate deployment rehearsal and confirm that recovery fails without leaving a partially moved balance or a stranded registry entry.
Before restoring service after an incident, compare both balance state and identity state with the approved recovery request. Confirm the replacement address through the organization's established process, then reconcile downstream systems against the final transaction receipt. Reusing a previously approved wallet address from an old support ticket is not sufficient evidence that the current request is authorized.
Give preflight results an honest lifetime
A frontend can ask the compliance contract whether its modules would permit a movement. It should label the result accordingly. The check does not establish adequate allowance, an unfrozen wallet or an unpaused token. Our paused-token test makes that limitation visible without depending on an RPC provider or a race between transactions.
The policy-change test establishes another boundary. It observes a positive compliance check, disables the previously permitted country and then confirms that the ordinary transfer fails. In a deployed system, the state used for a preflight call can change before transaction execution. Avoid turning a simulated result into a promise that the eventual transaction will succeed.
For a transaction preview, simulate the exact entry point with the intended sender, calldata and value against a documented block context. Show the time or block associated with the result, then handle an execution failure as an expected possibility. Preserve the transaction hash and inspect the mined receipt before updating balances as settled. These are integration recommendations; the local fixture does not test a remote provider or mempool ordering.
Failure messages should also preserve the distinction between identity, policy and token state. The pinned token combines some rejection conditions under one revert string. An application may need separate read-only diagnostics to explain a failed simulation, but it should not claim a uniquely established cause when several conditions are false. Keep sensitive identity material out of public error messages.
Make policy changes visible to reviewers
Changing a trusted issuer, removing a required topic or replacing the compliance contract can alter admissible transfers without changing the token's visible name. Treat those operations as changes to the running system's policy. Maintain an inventory of who can perform each one, including any authority that can upgrade the code implementing those permissions.
The custom module emits CountryPermissionSet with the compliance address, country and new value. Retain that context in operational records so that a shared module's event is associated with the right token deployment. Compare observed configuration with an approved baseline after changes. An event stream is useful evidence, but a missed subscription message should not prevent reconciliation from current state and historical logs.
Choose an approval mechanism appropriate to each authority. A multisignature owner may reduce dependence on one key; a timelock may create review time for a configuration change. Neither control automatically governs an agent that retains a separate immediate operation. Draw the complete authority map and test the exceptional route, including who can remove or replace its operator.
Treat changing the allowlist as a release even when no Solidity file changes. Save a before-and-after view, simulate a permitted and a denied movement under the new configuration, and state whether existing holders are affected. Assign responsibility for reconciliation if an operator discovers that a registry country value was wrong. This preserves the connection between the administrative decision and the transfers it actually governs.
The final release receipt should identify the exact source commit, compiler settings, dependency lockfile and deployed bytecode. Attach the configured identities and policy addresses, required topics, trusted issuers, permitted countries and role assignments. Then attach the executed test selection and its limitations. Reviewers need enough information to reproduce the result and enough precision to see what remains untested.
An ERC-3643 implementation is ready for the next engineering review when each permitted movement and each expected rejection has an explicit explanation tied to selected code and configuration. This fixture supplies that explanation for one recipient-country rule and its tested interactions. Expand it around the instrument's real identity system, deployment architecture and authorized operations before treating it as production acceptance evidence.
More insights to read
- Five Smart Contract Upgrade Patterns Compared
- Smart Contracts. Their Potential and Real Limitations. Part 2
- Upgradeable Solidity Smart Contracts. Part 1 — Versioning
- Smart Contracts. Their Potential and Real Limitations. Part 1
- Web3. Smart Contracts. Oracles. Part 1
About the author
Dmytro Nasyrov. Photo supplied by the author.
Written by Dmytro Nasyrov PhD, software architect with 24 years of production experience. Dmytro is the founder and CTO of Pharos Production. He works on production software architecture for FinTech, AI, Web3 and blockchain systems.


Top comments (0)