Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,3 @@ TDOC_IMPLEMENTATION_AMOY=0x_tdoc_implementation_on_amoy
# Filled in after running gasless scripts with NETWORK=amoy
REGISTRY_ADDRESS_AMOY=0x_filled_after_deployRegistry
TITLE_ESCROW_ADDRESS_AMOY=0x_filled_after_mintDocument
FACTORY_ADDRESS_AMOY=0x_filled_after_deployFactory
50 changes: 44 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,16 +138,54 @@ npx hardhat run scripts/mintDocumentGasless.ts --network sepolia

## Environment variables

Deploy/registry/paymaster addresses are network-scoped — suffix the variable with `_SEPOLIA` or `_AMOY` (e.g. `FACTORY_ADDRESS_SEPOLIA`, `FACTORY_ADDRESS_AMOY`). `scripts/lib/network.ts` resolves the suffix from `--network <name>` (hardhat scripts) or the `NETWORK` env var (viem/permissionless scripts). See `.env.example` for the full annotated list.

### Wallets & RPC

| Variable | Description |
| --- | --- |
| `PRIVATE_KEY` | Deployer wallet private key |
| `PRIVATE_KEY` | Deployer/gas-payer wallet — pays for deployments, delegation txs, staking |
| `PRIVATE_KEY2` | Secondary wallet (optional — testing with a second account) |
| `OWNER_PRIVATE_KEY` | Platform owner / whitelisted user — signs UserOps, needs no ETH for gasless ops |
| `SEPOLIA_RPC_URL` | Sepolia RPC endpoint |
| `AMOY_RPC_URL` | Polygon Amoy RPC endpoint |
| `PIMLICO_API_KEY` | Pimlico bundler API key |
| `TDOC_DEPLOYER_ADDRESS` | Deployed TDocDeployer address |
| `PAYMASTER_IMPLEMENTATION` | PlatformPaymaster implementation address |
| `FACTORY_ADDRESS` | PlatformAccountFactory address |
| `PAYMASTER_ADDRESS` | Deployed paymaster clone address |
| `EIP7702_IMPL_ADDRESS` | EIP7702Implementation address |
| `NETWORK` | `sepolia` \| `amoy` — target network for viem/permissionless scripts (default: `sepolia`) |
| `ENTRY_POINT` | EntryPoint v0.8 address (default: `0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108`, same on all supported chains) |

### Deployed addresses (network-suffixed)

| Variable | Description |
| --- | --- |
| `EIP7702_IMPL_ADDRESS_<NETWORK>` | Deployed `EIP7702Implementation` address |
| `PAYMASTER_IMPLEMENTATION_<NETWORK>` | Deployed `PlatformPaymaster` implementation address |
| `FACTORY_ADDRESS_<NETWORK>` | Deployed `PlatformAccountFactory` address |
| `PAYMASTER_ADDRESS_<NETWORK>` | Deployed paymaster clone address |
| `TDOC_DEPLOYER_ADDRESS_<NETWORK>` | TrustVC `TDocDeployer` address (pre-deployed infra) |
| `TDOC_IMPLEMENTATION_<NETWORK>` | TDoc implementation to clone via `deployRegistry` |
| `REGISTRY_ADDRESS_<NETWORK>` | Registry deployed via `deployRegistryGasless.ts` |
| `TITLE_ESCROW_ADDRESS_<NETWORK>` | Title escrow captured via `mintDocumentGasless.ts` |

### Gasless script inputs

| Variable | Description |
| --- | --- |
| `TOKEN_NAME` / `TOKEN_SYMBOL` | Name/symbol for the TradeTrust token registry (`deployRegistryGasless.ts`) |
| `TOKEN_ID` | Document token ID as `uint256` (`mintDocumentGasless.ts`) |
| `BENEFICIARY_ADDRESS` / `HOLDER_ADDRESS` | Document beneficiary/holder (`mintDocumentGasless.ts`) |
| `REMARK` | Optional remark bytes/text attached to the document |
| `NOMINEE_ADDR` / `NEW_HOLDER_ADDR` | Used by the `scripts/trFunctions/*` title-escrow helpers |

### Optional deploy/stake overrides

| Variable | Description |
| --- | --- |
| `PLATFORM_ADDRESS` | Paymaster owner EOA for `deployPlatformPaymaster.ts` (default: deployer) |
| `DAILY_LIMIT_ETH` | Per-user daily gas limit in ETH (default: `0` = unlimited) |
| `DEPLOY_SALT` | Hex `bytes32` CREATE2 salt (default: random) |
| `STAKE_AMOUNT_ETH` | ETH locked as EntryPoint stake (default: `0.01`) |
| `DEPOSIT_AMOUNT_ETH` | ETH deposited into the gas pool (default: `0.05`) |
| `UNSTAKE_DELAY_SEC` | Stake lock period in seconds (default: `86400` = 1 day) |

## Tech stack

Expand Down
32 changes: 28 additions & 4 deletions contracts/Factory.sol
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,32 @@ import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
contract PlatformAccountFactory is Ownable {
address public tdocDeployer;
address public paymasterImplementation;
mapping(address => address) public attachedPaymaster;

event PlatformOnboarded(address indexed platformAddress, address indexed paymaster);
event PlatformOnboarded(
address indexed platformAddress,
address indexed paymaster
);
event TdocDeployerUpdated(address indexed newDeployer);
event ImplementationUpdated(address indexed newImplementation);

constructor(
address _tdocDeployer,
address _paymasterImplementation
) Ownable(msg.sender) {
require(_tdocDeployer != address(0), "Zero address");
require(_paymasterImplementation != address(0), "Zero address");
tdocDeployer = _tdocDeployer;
paymasterImplementation = _paymasterImplementation;
}

function setAttachedPaymaster(
address platformAddress
) external view returns (address) {
address paymaster = attachedPaymaster[platformAddress];
return paymaster;
}

function updateTdocDeployer(address _tdocDeployer) external onlyOwner {
require(_tdocDeployer != address(0), "Zero address");
tdocDeployer = _tdocDeployer;
Expand All @@ -39,12 +52,23 @@ contract PlatformAccountFactory is Ownable {
bytes32 salt
) external returns (address paymaster) {
paymaster = Clones.cloneDeterministic(paymasterImplementation, salt);
PlatformPaymaster(payable(paymaster)).initialize(platformAddress, dailyLimit, tdocDeployer);
PlatformPaymaster(payable(paymaster)).initialize(
platformAddress,
dailyLimit,
tdocDeployer
);
emit PlatformOnboarded(platformAddress, paymaster);
}

// Address is determined solely by implementation + salt (not by constructor args).
function computePaymasterAddress(bytes32 salt) external view returns (address) {
return Clones.predictDeterministicAddress(paymasterImplementation, salt, address(this));
function computePaymasterAddress(
bytes32 salt
) external view returns (address) {
return
Clones.predictDeterministicAddress(
paymasterImplementation,
salt,
address(this)
);
}
}
11 changes: 6 additions & 5 deletions contracts/PlatformPaymaster.sol
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ contract PlatformPaymaster is BasePaymaster {
);

bytes32 private constant DEFAULT_ADMIN_ROLE = bytes32(0);
bytes32 private constant RESTORER_ROLE = keccak256("RESTORER_ROLE");
bytes32 private constant ACCEPTER_ROLE = keccak256("ACCEPTER_ROLE");
bytes32 private constant MINTER_ROLE = keccak256("MINTER_ROLE");

ITDocDeployer public tdocDeployer;

Expand Down Expand Up @@ -141,13 +144,11 @@ contract PlatformPaymaster is BasePaymaster {
bytes memory params = abi.encode(name, symbol, address(this));
deployed = tdocDeployer.deploy(implementation, params);

// Hand admin to the calling EOA
IAccessControl(deployed).grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
IAccessControl(deployed).grantRole(RESTORER_ROLE, msg.sender);
IAccessControl(deployed).grantRole(ACCEPTER_ROLE, msg.sender);
IAccessControl(deployed).grantRole(MINTER_ROLE, msg.sender);

// Paymaster keeps only the operational roles it needs
// (MINTER_ROLE, RESTORER_ROLE, ACCEPTER_ROLE were granted to address(this) via initialize)

// Relinquish admin — EOA is now sole admin
IAccessControl(deployed).renounceRole(
DEFAULT_ADMIN_ROLE,
address(this)
Expand Down
23 changes: 21 additions & 2 deletions contracts/mocks/MockRegistry.sol
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,38 @@ contract MockTitleEscrow {}

contract MockRegistry {
bytes32 public constant DEFAULT_ADMIN_ROLE = bytes32(0);
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant RESTORER_ROLE = keccak256("RESTORER_ROLE");
bytes32 public constant ACCEPTER_ROLE = keccak256("ACCEPTER_ROLE");

mapping(bytes32 => mapping(address => bool)) private _roles;
address public lastTitleEscrow;

error AccessControlUnauthorizedAccount(address account, bytes32 role);
error AccessControlBadConfirmation();

// Mirrors RegistryAccess.__RegistryAccess_init: the real registry grants
// all four roles to the single admin address passed at deploy time.
constructor(address initialAdmin) {
_roles[DEFAULT_ADMIN_ROLE][initialAdmin] = true;
_roles[MINTER_ROLE][initialAdmin] = true;
_roles[RESTORER_ROLE][initialAdmin] = true;
_roles[ACCEPTER_ROLE][initialAdmin] = true;
}

// IAccessControl
// IAccessControl — mirrors OZ's default: granting any role requires
// DEFAULT_ADMIN_ROLE, since the real registry never overrides role admins.
function grantRole(bytes32 role, address account) external {
if (!_roles[DEFAULT_ADMIN_ROLE][msg.sender]) {
revert AccessControlUnauthorizedAccount(msg.sender, DEFAULT_ADMIN_ROLE);
}
_roles[role][account] = true;
}

function renounceRole(bytes32 role, address) external {
function renounceRole(bytes32 role, address callerConfirmation) external {
if (callerConfirmation != msg.sender) {
revert AccessControlBadConfirmation();
}
_roles[role][msg.sender] = false;
}

Expand Down
30 changes: 30 additions & 0 deletions test/PlatformPaymaster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,36 @@ describe("PlatformPaymaster", function () {
paymasterAsOther.write.deployRegistry([impl.address, "TT", "TT"]),
).to.be.rejectedWith("No deployment credits");
});

it("hands admin + all operational roles to msg.sender; paymaster keeps minter/restorer/accepter but not admin", async function () {
const { paymaster, paymasterAsOther, other, impl, publicClient } =
await loadFixture(deployFixture);
await paymaster.write.setUserWhitelist([other.account.address, 1n]);

const hash = await paymasterAsOther.write.deployRegistry([impl.address, "TT", "TT"]);
await publicClient.waitForTransactionReceipt({ hash });

const events = await paymaster.getEvents.RegistryDeployed();
const deployedAddr = events[0].args.deployed as `0x${string}`;
const registry = await hre.viem.getContractAt("MockRegistry", deployedAddr);

const DEFAULT_ADMIN_ROLE = await registry.read.DEFAULT_ADMIN_ROLE();
const MINTER_ROLE = await registry.read.MINTER_ROLE();
const RESTORER_ROLE = await registry.read.RESTORER_ROLE();
const ACCEPTER_ROLE = await registry.read.ACCEPTER_ROLE();

// msg.sender (the deploying EOA) ends up holding all four roles
expect(await registry.read.hasRole([DEFAULT_ADMIN_ROLE, other.account.address])).to.be.true;
expect(await registry.read.hasRole([RESTORER_ROLE, other.account.address])).to.be.true;
expect(await registry.read.hasRole([ACCEPTER_ROLE, other.account.address])).to.be.true;
expect(await registry.read.hasRole([MINTER_ROLE, other.account.address])).to.be.true;

// paymaster relinquishes admin but keeps the operational roles
expect(await registry.read.hasRole([DEFAULT_ADMIN_ROLE, paymaster.address])).to.be.false;
expect(await registry.read.hasRole([RESTORER_ROLE, paymaster.address])).to.be.true;
expect(await registry.read.hasRole([ACCEPTER_ROLE, paymaster.address])).to.be.true;
expect(await registry.read.hasRole([MINTER_ROLE, paymaster.address])).to.be.true;
});
});

// ─── mintDocument ─────────────────────────────────────────────────────────
Expand Down
Loading
Loading