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
48 changes: 48 additions & 0 deletions .changeset/stellar-admin-two-step-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
'@openzeppelin/ui-builder-adapter-stellar': minor
'@openzeppelin/ui-builder-types': minor
---

feat(adapter-stellar): add two-step admin transfer support with ledger-based expiration

Implements OpenZeppelin Stellar AccessControl two-step admin transfer pattern:

**New Features:**

- `getAdminInfo()` returns admin state (active/pending/expired/renounced) with pending transfer details
- `transferAdminRole()` initiates two-step admin transfer with expiration ledger parameter
- `acceptAdminTransfer()` allows pending admins to complete admin transfer
- `hasTwoStepAdmin` capability flag in feature detection

**Type Extensions:**

- Added `AdminState` type for admin states ('active' | 'pending' | 'expired' | 'renounced')
- Added `PendingAdminTransfer` interface for pending admin transfer details
- Added `AdminInfo` interface for admin information with state and pending transfer
- Extended `AccessControlCapabilities` with `hasTwoStepAdmin` flag
- Added optional `getAdminInfo`, `transferAdminRole`, `acceptAdminTransfer` methods to `AccessControlService` interface

**Indexer Integration:**

- Added `ADMIN_TRANSFER_INITIATED` event type support
- Added `ADMIN_TRANSFER_COMPLETED` event type support
- Added `AdminTransferInitiatedEvent` interface for pending admin transfers
- Added `queryPendingAdminTransfer()` method to indexer client
- Graceful degradation when indexer is unavailable

**Action Assembly:**

- Added `assembleTransferAdminRoleAction()` for transfer_admin_role transactions
- Added `assembleAcceptAdminTransferAction()` for accept_admin_transfer transactions

**Breaking Changes:**

- Removed `GetOwnershipOptions` interface and `verifyOnChain` option from `getOwnership()` and `getAdminInfo()`
- Removed `readPendingOwner()` function from onchain-reader (it called non-existent `get_pending_owner()` function)
- Signature change: `getOwnership(contractAddress, options?)` -> `getOwnership(contractAddress)`
- Signature change: `getAdminInfo(contractAddress, options?)` -> `getAdminInfo(contractAddress)`
- Removed `TRANSFERRED` from `HistoryChangeType` - use `OWNERSHIP_TRANSFER_COMPLETED` instead

The `verifyOnChain` option was removed because standard OpenZeppelin Stellar contracts do not expose `get_pending_owner()` or `get_pending_admin()` methods. Pending transfer state is only accessible via the indexer, not on-chain.

The `TRANSFERRED` event type was removed to simplify the API. Use the more specific `OWNERSHIP_TRANSFER_STARTED` and `OWNERSHIP_TRANSFER_COMPLETED` types instead.
60 changes: 60 additions & 0 deletions packages/adapter-stellar/src/access-control/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,63 @@ export function assembleAcceptOwnershipAction(contractAddress: string): StellarT
transactionOptions: {},
};
}

/**
* Assembles transaction data for initiating an admin role transfer
*
* For two-step AccessControl contracts, this initiates an admin transfer that must
* be accepted by the pending admin before the expiration ledger.
*
* @param contractAddress The contract address
* @param newAdmin The new admin address
* @param liveUntilLedger The ledger sequence by which the transfer must be accepted
* @returns Transaction data ready for execution
*/
export function assembleTransferAdminRoleAction(
contractAddress: string,
newAdmin: string,
liveUntilLedger: number
): StellarTransactionData {
logger.info(
'assembleTransferAdminRoleAction',
`Assembling transfer_admin_role action to ${newAdmin} with expiration at ledger ${liveUntilLedger}`
);

// Arguments for transfer_admin_role(new_admin: Address, live_until_ledger: u32)
// Note: args are raw values that will be converted to ScVal by the transaction execution flow
return {
contractAddress,
functionName: 'transfer_admin_role',
args: [newAdmin, liveUntilLedger],
argTypes: ['Address', 'u32'],
argSchema: undefined,
transactionOptions: {},
};
}

/**
* Assembles transaction data for accepting a pending admin transfer
*
* For two-step AccessControl contracts, this completes a pending admin transfer
* initiated by the current admin. Must be called by the pending admin before the
* expiration ledger.
*
* @param contractAddress The contract address
* @returns Transaction data ready for execution
*/
export function assembleAcceptAdminTransferAction(contractAddress: string): StellarTransactionData {
logger.info(
'assembleAcceptAdminTransferAction',
`Assembling accept_admin_transfer action for ${contractAddress}`
);

// accept_admin_transfer() has no arguments - caller must be the pending admin
return {
contractAddress,
functionName: 'accept_admin_transfer',
args: [],
argTypes: [],
argSchema: undefined,
transactionOptions: {},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export function detectAccessControlCapabilities(
functionNames.has(fnName)
);

// Detect two-step admin transfer (has accept_admin_transfer function)
const hasTwoStepAdmin = hasAccessControl && functionNames.has('accept_admin_transfer');

// Detect enumerable roles
const hasEnumerableRoles = ENUMERATION_FUNCTIONS.every((fnName) => functionNames.has(fnName));

Expand All @@ -73,7 +76,8 @@ export function detectAccessControlCapabilities(
functionNames,
hasOwnable,
hasAccessControl,
hasTwoStepOwnable
hasTwoStepOwnable,
hasTwoStepAdmin
);

// Collect notes about detected capabilities
Expand All @@ -88,7 +92,13 @@ export function detectAccessControlCapabilities(
}

if (hasAccessControl) {
notes.push('OpenZeppelin AccessControl interface detected');
if (hasTwoStepAdmin) {
notes.push(
'OpenZeppelin two-step AccessControl interface detected (with accept_admin_transfer)'
);
} else {
notes.push('OpenZeppelin AccessControl interface detected');
}
}

if (hasEnumerableRoles) {
Expand All @@ -109,6 +119,7 @@ export function detectAccessControlCapabilities(
hasOwnable,
hasTwoStepOwnable,
hasAccessControl,
hasTwoStepAdmin,
hasEnumerableRoles,
supportsHistory,
verifiedAgainstOZInterfaces,
Expand All @@ -123,13 +134,15 @@ export function detectAccessControlCapabilities(
* @param hasOwnable Whether Ownable was detected
* @param hasAccessControl Whether AccessControl was detected
* @param hasTwoStepOwnable Whether two-step Ownable was detected
* @param hasTwoStepAdmin Whether two-step admin was detected
* @returns True if verified against OZ interfaces
*/
function verifyOZInterface(
functionNames: Set<string>,
hasOwnable: boolean,
hasAccessControl: boolean,
hasTwoStepOwnable = false
hasTwoStepOwnable = false,
hasTwoStepAdmin = false
): boolean {
// If no OZ patterns detected, not applicable
if (!hasOwnable && !hasAccessControl) {
Expand Down Expand Up @@ -158,14 +171,25 @@ function verifyOZInterface(
}
}

// Verify AccessControl optional functions (at least 4 of 7 should be present)
// Verify AccessControl optional functions
// For two-step admin, require at least 5 of 7 optional functions
// For basic AccessControl, at least 4 of 7 should be present
if (hasAccessControl) {
const accessControlOptionalCount = ACCESS_CONTROL_FUNCTIONS.optional.filter((fnName) =>
functionNames.has(fnName)
).length;

if (accessControlOptionalCount < 4) {
return false;
if (hasTwoStepAdmin) {
// Two-step admin should have at least 5 of 7 optional functions
// (transfer_admin_role, accept_admin_transfer guaranteed, plus others)
if (accessControlOptionalCount < 5) {
return false;
}
} else {
// Basic AccessControl should have at least 4 of 7 optional functions
if (accessControlOptionalCount < 4) {
return false;
}
}
}

Expand Down
17 changes: 14 additions & 3 deletions packages/adapter-stellar/src/access-control/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,35 @@
* - {@link StellarAccessControlService.acceptOwnership} - Accepts pending ownership transfer
* - {@link getCurrentLedger} - Gets current ledger sequence for expiration calculation
* - {@link validateExpirationLedger} - Validates expiration ledger before submission
* - {@link readPendingOwner} - Reads pending owner info from on-chain state
*
* ## Two-Step Admin Transfer Support
*
* This module provides full support for OpenZeppelin's two-step admin transfer pattern:
* - {@link StellarAccessControlService.getAdminInfo} - Returns admin state (active/pending/expired/renounced)
* - {@link StellarAccessControlService.transferAdminRole} - Initiates two-step admin transfer with expiration
* - {@link StellarAccessControlService.acceptAdminTransfer} - Accepts pending admin transfer
* - {@link AdminTransferInitiatedEvent} - Pending admin transfer event from indexer
*
* ## Action Assembly
*
* - {@link assembleGrantRoleAction} - Prepares grant_role transaction
* - {@link assembleRevokeRoleAction} - Prepares revoke_role transaction
* - {@link assembleTransferOwnershipAction} - Prepares transfer_ownership transaction with expiration
* - {@link assembleAcceptOwnershipAction} - Prepares accept_ownership transaction
* - {@link assembleTransferAdminRoleAction} - Prepares transfer_admin_role transaction with expiration
* - {@link assembleAcceptAdminTransferAction} - Prepares accept_admin_transfer transaction
*
* ## Feature Detection
*
* - {@link detectAccessControlCapabilities} - Detects Ownable/AccessControl support
* - `hasTwoStepOwnable` capability flag indicates two-step transfer support
* - `hasTwoStepOwnable` capability flag indicates two-step ownership transfer support
* - `hasTwoStepAdmin` capability flag indicates two-step admin transfer support
*
* ## Indexer Client
*
* - {@link StellarIndexerClient} - Queries historical events and pending transfers
* - {@link OwnershipTransferStartedEvent} - Pending transfer event from indexer
* - {@link OwnershipTransferStartedEvent} - Pending ownership transfer event from indexer
* - {@link AdminTransferInitiatedEvent} - Pending admin transfer event from indexer
*
* @module access-control
*/
Expand Down
Loading
Loading