Skip to content
Closed
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
9 changes: 6 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ test with any reproducer.
## Branching

`develop` is the integration branch — all work and PRs target `develop`.
`main` is the release branch (deploys across the Daski stack key off it), so
`develop` → `main` merges are deliberate, explicitly authorized release steps
only.
`sandbox` is the testnet release branch: only the release coordinator writes
it, through the `develop` → `sandbox` release pull request. `main` marks
production and moves only by fast-forward to a release commit of `sandbox`,
performed by the production coordinator. Nobody merges into `sandbox` or
`main` by hand, and moving a branch never deploys a contract: on-chain actions
stay separate governance steps.

Every push to `develop` must satisfy the [release readiness](docs/release-readiness.md) definition of done, because the release coordinator only checks that CI passed on the exact `develop` commit it ships.
56 changes: 45 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,23 @@ forge coverage --skip script --exclude-tests --no-match-coverage 'script/' --rep

Before pushing to `develop`, satisfy [docs/release-readiness.md](docs/release-readiness.md): `develop` must always be releasable, and the release coordinator only checks that CI passed on the exact commit.

## Testnet deployment inputs
## Deployment inputs

Deploy and finalize the fresh standard-order reputation resolver with
`DeployReputationStorage.s.sol`. Deploy the shared factory with
`DeployOutcomeSplitterFactory.s.sol`, then one splitter per reviewed outcome
with `DeployOutcomeSplitter.s.sol`. Validate and write the public artifact with
`WriteOutcomeSplitterManifest.s.sol`.

The splitter scripts run on Base and Base Sepolia and refuse every other chain.
They bind each splitter to the executing chain and to the reviewed canonical
Circle USDC address for that chain, and the activation gate refuses any other
token. The commands are the same on both chains; supply the RPC endpoints of
the chain being deployed to.

`WriteOutcomeSplitterManifest.s.sol` is the sole activation gate and must run
against a Base Sepolia fork pinned to the claimed activation block:
against a fork of that chain pinned to the claimed activation block (Base
Sepolia shown):

```bash
export STANDARD_RAIL_PRIMARY_RPC_URL="$BASE_SEPOLIA_RPC_URL"
Expand Down Expand Up @@ -110,8 +117,35 @@ and pause the resolver immediately if signer integrity is in doubt.
The deployment scripts default `MARKETPLACE_COMMISSION_BPS` to 500. A later
fee change is represented by a new immutable splitter and listing epoch.

The scripts require the `STANDARD_RAIL_*` values named in their source. They
are deployment tooling only; running tests or pushing this repository does not
Deploy the four marketplace registries with `DeployMarketplaceRegistries.s.sol`,
on Base or Base Sepolia only. It deploys AgentIndex, ValidationRegistry,
ProviderRegistry and ServiceRegistry as ERC-1967 UUPS proxies against
`IDENTITY_REGISTRY_ADDRESS` and `SANCTIONS_ORACLE_ADDRESS`, with the reviewed
canonical Circle USDC of the executing chain as the listing-fee token,
`PROVIDER_REGISTRY_TREASURY` as the fee recipient and
`PROVIDER_REGISTRY_LISTING_FEE` in atomic units. None of the identity registry,
sanctions oracle and token has a setter, so review those addresses
independently before deploying.
`MARKETPLACE_REGISTRIES_FINAL_ADMIN` must satisfy the same Safe rules as the
reputation deployment, and `MARKETPLACE_REGISTRIES_PAUSE_GUARDIAN` must be a
nonzero address distinct from the Safe and the broadcaster. The broadcaster
comes from the standard Foundry wallet options; the script reads no private
key. It is only the bootstrap admin: the script pauses each registry, sets the
guardian and proposes the Safe, so it ends with four paused proxies whose
pending admin is the Safe. The Safe must accept administration before it can
unpause a registry, and after acceptance the broadcaster holds no role.

After the Safe has accepted, `VerifyMarketplaceRegistries.s.sol` checks the
deployment without sending anything. Given the four proxy addresses, the
identity registry, the sanctions oracle and the Safe, it requires code and an
ERC-1967 implementation behind every proxy, the expected registry type at each
address, one shared identity registry and sanctions oracle, the reviewed USDC
as listing-fee token, ServiceRegistry pointing at the given ProviderRegistry,
and the Safe as admin with no pending admin. It returns the implementation
addresses for the release record.

The scripts require the environment values named in their source. They are
deployment tooling only; running tests or pushing this repository does not
deploy contracts.

## Security
Expand All @@ -122,13 +156,13 @@ construction, fee-on-transfer behavior, partial release, and reentrancy.
The factory applies the same deployability checks before returning a predicted
CREATE2 address.

Base Sepolia release tooling requires Circle's canonical USDC address to contain
token code and report six decimals. It refuses to activate a route while USDC is
paused or the splitter or either recipient is blacklisted. Circle's pause and
blacklist controls can still stop an existing immutable route; recipients cannot
be rotated and the splitter has no rescue path. Direct native-currency transfers
revert, while EVM-forced native currency remains outside token accounting and
cannot be withdrawn.
Release tooling requires Circle's canonical USDC address for the executing chain
to contain token code and report six decimals. It refuses to activate a route
while USDC is paused or the splitter or either recipient is blacklisted.
Circle's pause and blacklist controls can still stop an existing immutable
route; recipients cannot be rotated and the splitter has no rescue path. Direct
native-currency transfers revert, while EVM-forced native currency remains
outside token accounting and cannot be withdrawn.

## License

Expand Down
9 changes: 6 additions & 3 deletions docs/release-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@ A change may be pushed to `develop` only when all of the following hold.
their own ABI fragments; they do not import this repository's build
output.
- [ ] Deployment scripts under `script/` remain runnable against a fork
(`forge script ... --fork-url ...` with the `STANDARD_RAIL_*` inputs
named in their source).
- [ ] Nothing is merged to `main` or tagged by hand.
(`forge script ... --fork-url ...` with the environment inputs named in
their source).
- [ ] Nothing is merged to `sandbox` or `main`, or tagged, by hand. The release
coordinator writes `sandbox`, the testnet release branch, through the
`develop` to `sandbox` release pull request; the production coordinator
moves `main` only by fast-forward to a release commit of `sandbox`.
- [ ] On-chain actions (deploy, upgrade, Safe batches) happen only through the
coordinator's epoch or upgrade runbooks with explicit owner
authorization. Running tests or pushing this repository never
Expand Down
207 changes: 207 additions & 0 deletions script/DeployMarketplaceRegistries.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {VmSafe} from "forge-std/Vm.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {AgentIndex} from "../src/AgentIndex.sol";
import {ProviderRegistry} from "../src/ProviderRegistry.sol";
import {ServiceRegistry} from "../src/ServiceRegistry.sol";
import {ValidationRegistry} from "../src/ValidationRegistry.sol";
import {Admin2StepUpgradeable} from "../src/utils/Admin2StepUpgradeable.sol";
import {MarketplaceRegistryValidation} from "./MarketplaceRegistryValidation.sol";
import {ReputationSafeValidation} from "./ReputationSafeValidation.sol";
import {StandardRailCircleUSDC} from "./StandardRailCircleUSDC.sol";

/// @notice Genesis deployment of AgentIndex, ValidationRegistry,
/// ProviderRegistry and ServiceRegistry as ERC-1967 UUPS proxies on
/// Base or Base Sepolia.
///
/// The broadcaster is only the bootstrap admin. The script pauses
/// every registry, sets its pause guardian and proposes the reviewed
/// Safe as admin, so it ends with four paused proxies whose pending
/// admin is the Safe. Administration ends with the Safe: it accepts
/// each proxy and only then can unpause it. The broadcaster stays
/// admin of the paused proxies until that acceptance and holds no
/// role after it. Run VerifyMarketplaceRegistries once the Safe has
/// accepted.
///
/// The broadcaster comes from the standard Foundry wallet options
/// (hardware wallet, keystore or --sender); no key is read from the
/// environment.
///
/// IDENTITY_REGISTRY_ADDRESS canonical ERC-8004 IdentityRegistry
/// SANCTIONS_ORACLE_ADDRESS Chainalysis-compatible sanctions oracle
/// MARKETPLACE_REGISTRIES_FINAL_ADMIN reviewed governance Safe
/// MARKETPLACE_REGISTRIES_PAUSE_GUARDIAN distinct nonzero pause guardian
/// PROVIDER_REGISTRY_TREASURY listing-fee recipient
/// PROVIDER_REGISTRY_LISTING_FEE listing fee in atomic USDC units
///
/// The listing-fee token is the reviewed canonical Circle USDC of the
/// executing chain.
contract DeployMarketplaceRegistries is MarketplaceRegistryValidation, ReputationSafeValidation {
struct Config {
address identityRegistry;
address sanctionsOracle;
address finalAdmin;
address pauseGuardian;
address treasury;
uint256 listingFee;
}

error DependencyHasNoCode(address dependency);
error InvalidPauseGuardian();
error InvalidTreasury();
error GovernanceRoleConflict();
error BroadcasterUnavailable();
error DeploymentNotReady();

function run()
external
returns (address agentIndex, address validationRegistry, address providerRegistry, address serviceRegistry)
{
Registries memory registries = _deploy(_readConfig());
return
(
registries.agentIndex,
registries.validationRegistry,
registries.providerRegistry,
registries.serviceRegistry
);
}

function _readConfig() internal view returns (Config memory config) {
config.identityRegistry = vm.envAddress("IDENTITY_REGISTRY_ADDRESS");
config.sanctionsOracle = vm.envAddress("SANCTIONS_ORACLE_ADDRESS");
config.finalAdmin = vm.envAddress("MARKETPLACE_REGISTRIES_FINAL_ADMIN");
config.pauseGuardian = vm.envAddress("MARKETPLACE_REGISTRIES_PAUSE_GUARDIAN");
config.treasury = vm.envAddress("PROVIDER_REGISTRY_TREASURY");
config.listingFee = vm.envUint("PROVIDER_REGISTRY_LISTING_FEE");
}

function _deploy(Config memory config) internal returns (Registries memory registries) {
Dependencies memory dependencies = _validateConfig(config);
address deployer = _broadcaster();
_requireSeparateDeployer(config, deployer);

vm.startBroadcast();
registries = _deployProxies(config, dependencies, deployer);
vm.stopBroadcast();

_requireHandoffReady(registries, config, dependencies, deployer);
}

function _validateConfig(Config memory config) internal view returns (Dependencies memory dependencies) {
_requireSupportedChain();
dependencies = Dependencies({
identityRegistry: config.identityRegistry,
sanctionsOracle: config.sanctionsOracle,
canonicalToken: StandardRailCircleUSDC.canonicalToken(block.chainid)
});
if (dependencies.identityRegistry.code.length == 0) revert DependencyHasNoCode(dependencies.identityRegistry);
if (dependencies.sanctionsOracle.code.length == 0) revert DependencyHasNoCode(dependencies.sanctionsOracle);
if (dependencies.canonicalToken.code.length == 0) revert DependencyHasNoCode(dependencies.canonicalToken);

_validateSafe(config.finalAdmin);
if (config.pauseGuardian == address(0) || config.pauseGuardian == config.finalAdmin) {
revert InvalidPauseGuardian();
}
if (config.treasury == address(0)) revert InvalidTreasury();
}

/// @dev The account that signs the broadcast, whichever wallet option
/// supplied it. It is read in a broadcast window of its own that sends
/// nothing, so every refusal happens before the first transaction.
function _broadcaster() private returns (address deployer) {
vm.startBroadcast();
VmSafe.CallerMode mode;
(mode, deployer,) = vm.readCallers();
vm.stopBroadcast();
if (mode != VmSafe.CallerMode.RecurrentBroadcast || deployer == address(0)) revert BroadcasterUnavailable();
}

/// @dev The bootstrap admin keeps nothing once the Safe has accepted.
function _requireSeparateDeployer(Config memory config, address deployer) private pure {
if (deployer == config.finalAdmin || deployer == config.treasury) revert GovernanceRoleConflict();
if (deployer == config.pauseGuardian) revert InvalidPauseGuardian();
}

function _deployProxies(Config memory config, Dependencies memory dependencies, address deployer)
private
returns (Registries memory registries)
{
registries.agentIndex = _deployProxy(
address(new AgentIndex()),
abi.encodeCall(
AgentIndex.initialize, (dependencies.identityRegistry, dependencies.sanctionsOracle, deployer)
),
config
);
registries.validationRegistry = _deployProxy(
address(new ValidationRegistry()),
abi.encodeCall(
ValidationRegistry.initialize, (dependencies.identityRegistry, dependencies.sanctionsOracle, deployer)
),
config
);
registries.providerRegistry = _deployProxy(
address(new ProviderRegistry()),
abi.encodeCall(
ProviderRegistry.initialize,
(
dependencies.identityRegistry,
dependencies.canonicalToken,
config.treasury,
config.listingFee,
dependencies.sanctionsOracle,
deployer
)
),
config
);
registries.serviceRegistry = _deployProxy(
address(new ServiceRegistry()),
abi.encodeCall(
ServiceRegistry.initialize,
(dependencies.identityRegistry, registries.providerRegistry, dependencies.sanctionsOracle, deployer)
),
config
);
}

/// @dev Creates and initializes one proxy in a single transaction, then
/// pauses it, sets its guardian and proposes the Safe before the next
/// registry is deployed, so an interrupted run leaves at most one
/// registry unpaused.
function _deployProxy(address implementation, bytes memory initializer, Config memory config)
private
returns (address proxy)
{
proxy = address(new ERC1967Proxy(implementation, initializer));
Admin2StepUpgradeable registry = Admin2StepUpgradeable(proxy);
registry.pauseExternalDependency();
registry.setPauseGuardian(config.pauseGuardian);
registry.transferAdmin(config.finalAdmin);
}

function _requireHandoffReady(
Registries memory registries,
Config memory config,
Dependencies memory dependencies,
address deployer
) internal view {
_validateSafe(config.finalAdmin);
_requireImplementations(registries);
_requireWiring(registries, dependencies);
_requireAdministration(registries, deployer, config.finalAdmin);

ProviderRegistry providerRegistry = ProviderRegistry(registries.providerRegistry);
bool ready =
providerRegistry.treasury() == config.treasury && providerRegistry.listingFee() == config.listingFee;
address[4] memory proxies = _list(registries);
for (uint256 i = 0; i < proxies.length; i++) {
Admin2StepUpgradeable registry = Admin2StepUpgradeable(proxies[i]);
ready = ready && registry.pauseGuardian() == config.pauseGuardian && registry.externalDependencyPaused();
}
if (!ready) revert DeploymentNotReady();
}
}
Loading
Loading