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
2 changes: 1 addition & 1 deletion .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
uses: actions/setup-go@v3
with:
go-version: "1.20.x"
- uses: actions/cache@v1
- uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
coverage.json
coverage.lcov
REPORT.md
.idea
*.pkey
*.private
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
83 changes: 83 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

### Install Dependencies
```bash
flow dependencies install
```
Dependencies are managed via Flow CLI's Dependency Manager, pulling from on-chain sources defined in `flow.json`.

### Run Tests
```bash
sh test.sh
# Expands to:
flow test --cover --covercode="contracts" --coverprofile="coverage.lcov" test/*_tests.cdc
```

### Run a Single Test File
```bash
flow test test/HybridCustody_tests.cdc
flow test test/CapabilityDelegator_tests.cdc
flow test test/CapabilityFactory_tests.cdc
```

### Local Development
```bash
flow emulator start # Start local emulator
flow deploy # Deploy contracts to emulator
```

## Architecture

This project implements **Hybrid Custody** (account linking) on Flow: a model where an app retains control of a managed account while selectively sharing capabilities with a parent (e.g., a user's wallet).

### Core Contracts (`contracts/`)

**`HybridCustody.cdc`** — The primary contract. Defines three resources:
- `OwnedAccount` — Holds an `AuthAccount` capability for the child account. Published on the child account. The app creates and controls this. Can publish a `ChildAccount` capability to a parent.
- `ChildAccount` — Also lives on the child account. Scopes parent access via a `CapabilityFactory` and `CapabilityFilter`. A capability on this resource is shared to the parent via the account inbox.
- `Manager` — Lives on the parent account. Tracks all `ChildAccount` capabilities (shared children) and optionally holds `OwnedAccount` capabilities (owned children it fully controls).

**`CapabilityFilter.cdc`** — Determines which capabilities a parent can retrieve from a child. Three implementations:
- `AllowAllFilter` — Passthrough; all types allowed.
- `AllowlistFilter` — Only explicitly listed types are returned.
- `DenylistFilter` — All types except explicitly denied ones are returned.

**`CapabilityFactory.cdc`** — Abstracts capability retrieval from an account. A `Manager` resource contains `Factory` structs indexed by `Type`. Each `Factory` defines `getCapability()` (private) and `getPublicCapability()` (public). This solves Cadence's static typing constraint for castable capability retrieval.

**`CapabilityDelegator.cdc`** — A supplement to `CapabilityFactory` for sharing capabilities outside the standard NFT/FT interfaces. `Delegator` holds a map of public and private capabilities; public ones are accessible to anyone, private ones only through a `ChildAccount` reference.

### Factory Implementations (`contracts/factories/`)

Pre-built `CapabilityFactory.Factory` structs for common token standards:
- NFT: `NFTProviderFactory`, `NFTCollectionFactory`, `NFTCollectionPublicFactory`, `NFTProviderAndCollectionFactory`
- FT: `FTProviderFactory`, `FTReceiverFactory`, `FTBalanceFactory`, `FTReceiverBalanceFactory`, `FTAllFactory`, `FTVaultFactory`

Hosted instances of these factories are deployed to shared accounts on testnet and mainnet (see README for addresses).

### Test Contracts (`contracts/standard/`)

`ExampleNFT.cdc`, `ExampleNFT2.cdc`, `ExampleToken.cdc` — Test fixtures only; not deployed to production.

### Testing (`test/`)

Tests use Flow's Cadence-native `Test` framework (not Go). Test files end in `_tests.cdc`. The `test_helpers.cdc` file provides `txExecutor`, `scriptExecutor`, `expectScriptFailure`, and `deploy` helpers used by all test files.

In `flow.json`, the `testing` network alias maps all core contracts to address `0x0000000000000007`.

### Account Lifecycle (Publish/Redeem Pattern)

1. Child account creates `OwnedAccount` + configures `CapabilityFactory` and `CapabilityFilter`.
2. Child calls `publishToParent()` — places a `ChildAccount` capability in the parent's account inbox.
3. Parent calls `redeemAccount()` on their `Manager` — claims the inbox capability and registers the child.
4. Parent can now call `getCapabilityFromChild()` on their Manager, which is gated by the child's filter and factory.

### Deployments

| Network | Address |
|---------|---------|
| Testnet | `0x294e44e1ec6993c6` |
| Mainnet | `0xd8a7e05a7ac670c0` |
6 changes: 3 additions & 3 deletions contracts/CapabilityDelegator.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ access(all) contract CapabilityDelegator {
}
}
access(all) view fun findFirstPrivateType(_ type: Type): Type?
access(Get) fun getAllPrivate(): [Capability]
access(Get) view fun getAllPrivate(): [Capability]
}

/// Exposes public Capability retrieval
Expand All @@ -49,7 +49,7 @@ access(all) contract CapabilityDelegator {
}

/// This Delegator is used to store Capabilities, partitioned by public and private access with corresponding
/// GetterPublic and GetterPrivate conformances.AccountCapabilityController
/// GetterPublic and GetterPrivate conformances.
///
access(all) resource Delegator: GetterPublic, GetterPrivate {
access(self) let privateCapabilities: {Type: Capability}
Expand Down Expand Up @@ -85,7 +85,7 @@ access(all) contract CapabilityDelegator {
///
/// @return List of all private Capabilities
///
access(Get) fun getAllPrivate(): [Capability] {
access(Get) view fun getAllPrivate(): [Capability] {
return self.privateCapabilities.values
}

Expand Down
4 changes: 2 additions & 2 deletions contracts/CapabilityFilter.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -115,15 +115,15 @@ access(all) contract CapabilityFilter {

/// Removes a type from the mapping of allowed types
///
/// @param type: The type to remove from the denied types mapping
/// @param type: The type to remove from the allowed types mapping
///
access(Delete) fun removeType(_ type: Type) {
if let removed = self.allowedTypes.remove(key: type) {
emit FilterUpdated(id: self.uuid, filterType: self.getType(), type: type, active: false)
}
}

/// Removes all types from the mapping of denied types
/// Removes all types from the mapping of allowed types
///
access(Delete) fun removeAllTypes() {
for type in self.allowedTypes.keys {
Expand Down
52 changes: 24 additions & 28 deletions contracts/HybridCustody.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ access(all) contract HybridCustody {
/// Account has been sealed - keys revoked, new AuthAccount Capability generated
access(all) event AccountSealed(id: UInt64, address: Address, parents: [Address])

/// An OwnedAccount shares the BorrowableAccount capability to itelf with ChildAccount resources
/// An OwnedAccount shares the BorrowableAccount capability to itself with ChildAccount resources
///
access(all) resource interface BorrowableAccount {
access(contract) view fun _borrowAccount(): auth(Storage, Contracts, Keys, Inbox, Capabilities) &Account
Expand Down Expand Up @@ -247,7 +247,6 @@ access(all) contract HybridCustody {
}
}

/// Functions anyone can call on a manager to get information about an account such as What child accounts it has
/// Functions anyone can call on a manager to get information about an account such as what child accounts it has
access(all) resource interface ManagerPublic {
access(all) view fun borrowAccountPublic(addr: Address): &{AccountPublic, ViewResolver.Resolver}?
Expand Down Expand Up @@ -392,23 +391,21 @@ access(all) contract HybridCustody {
/// Returns a reference to a child account
///
access(Manage) fun borrowAccount(addr: Address): auth(Child) &{AccountPrivate, AccountPublic, ViewResolver.Resolver}? {
let cap = self.childAccounts[addr]
if cap == nil {
return nil
if let cap = self.childAccounts[addr] {
return cap.borrow()
}

return cap!.borrow()
return nil
}

/// Returns a reference to a child account's public AccountPublic interface
///
access(all) view fun borrowAccountPublic(addr: Address): &{AccountPublic, ViewResolver.Resolver}? {
let cap = self.childAccounts[addr]
if cap == nil {
return nil
if let cap = self.childAccounts[addr] {
return cap.borrow()
}

return cap!.borrow()
return nil
}

/// Returns a reference to an owned account
Expand Down Expand Up @@ -612,9 +609,11 @@ access(all) contract HybridCustody {
}

let cap = tmp!
// Check that private capabilities are allowed by either internal or manager filter (if assigned)
// If not allowed, return nil
if self.filter.borrow()!.allowed(cap: cap) == false || (self.getManagerCapabilityFilter()?.allowed(cap: cap) ?? true) == false {
// The child's own filter is always enforced
let childAllows = self.filter.borrow()!.allowed(cap: cap)
// The manager filter is optionally set by the parent account; if absent, default to allow
let managerAllows = self.getManagerCapabilityFilter()?.allowed(cap: cap) ?? true
if !childAllows || !managerAllows {
return nil
}

Expand Down Expand Up @@ -765,12 +764,11 @@ access(all) contract HybridCustody {
}

access(all) view fun getControllerIDForType(type: Type, forPath: StoragePath): UInt64? {
let child = self.childCap.borrow()
if child == nil {
return nil
if let child = self.childCap.borrow() {
return child.getControllerIDForType(type: type, forPath: forPath)
}

return child!.getControllerIDForType(type: type, forPath: forPath)
return nil
}

// When a ChildAccount is destroyed, attempt to remove it from the parent account as well
Expand Down Expand Up @@ -896,13 +894,14 @@ access(all) contract HybridCustody {
acct.inbox.publish(delegatorCap, name: identifier, recipient: parentAddress)
self.parents[parentAddress] = false

let filterRef = filter.borrow()!
emit ChildAccountPublished(
ownedAcctID: self.uuid,
childAcctID: delegatorCap.borrow()!.uuid,
capDelegatorID: delegator.borrow()!.uuid,
factoryID: factory.borrow()!.uuid,
filterID: filter.borrow()!.uuid,
filterType: filter.borrow()!.getType(),
filterID: filterRef.uuid,
filterType: filterRef.getType(),
child: self.getAddress(),
pendingParent: parentAddress
)
Expand Down Expand Up @@ -1093,7 +1092,7 @@ access(all) contract HybridCustody {
///
access(Owner) fun seal() {
self.rotateAuthAccount()
self.revokeAllKeys() // There needs to be a path to giving ownership that doesn't revoke keys
self.revokeAllKeys()
emit AccountSealed(id: self.uuid, address: self.acct.address, parents: self.parents.keys)
self.currentlyOwned = false
}
Expand Down Expand Up @@ -1168,14 +1167,11 @@ access(all) contract HybridCustody {
}

access(all) view fun getControllerIDForType(type: Type, forPath: StoragePath): UInt64? {
let acct = self.acct.borrow()
if acct == nil {
return nil
}

for c in acct!.capabilities.storage.getControllers(forPath: forPath) {
if c.borrowType.isSubtype(of: type) {
return c.capabilityID
if let acct = self.acct.borrow() {
for c in acct.capabilities.storage.getControllers(forPath: forPath) {
if c.borrowType.isSubtype(of: type) {
return c.capabilityID
}
}
}

Expand Down
Loading