Skip to content

Commit 94bc4b4

Browse files
authored
feat(adapter-stellar): add two-step Ownable support with ledger-based expiration (#271)
* docs(spec): add Stellar Ownable two-step transfer specification Complete feature specification for extending the Stellar Access Control module to support OpenZeppelin Ownable two-step ownership transfer with ledger-based expiration. Includes: - spec.md: 23 functional requirements, 6 NFRs, 4 user stories - plan.md: implementation approach and architecture decisions - tasks.md: 64 implementation tasks organized by user story - research.md: Phase 0 research findings - data-model.md: entity definitions and relationships - contracts/: TypeScript interface definitions - quickstart.md: integration guide - checklists/: requirements quality validation * feat(adapter-stellar): add two-step Ownable foundation (Phase 1 & 2) Add foundational infrastructure for Stellar Ownable two-step ownership transfer with ledger-based expiration support. Types (chain-agnostic): - Add OwnershipState type ('owned' | 'pending' | 'expired' | 'renounced') - Add PendingOwnershipTransfer interface with expirationBlock - Extend OwnershipInfo with optional state and pendingTransfer - Add hasTwoStepOwnable flag to AccessControlCapabilities Adapter-stellar: - Add getCurrentLedger() to query current ledger via Soroban RPC - Add OWNERSHIP_TRANSFER_INITIATED event type to indexer client - Add queryPendingOwnershipTransfer() method to detect pending transfers - Add validateExpirationLedger() helper for client-side validation - Update feature detection to identify two-step Ownable contracts * feat(adapter-stellar): implement ownership status viewing (Phase 3) Implement User Story 1 - View Contract Ownership Status with: - Add getOwnership() method with full state determination (owned/pending/expired/renounced) - Add readPendingOwner() on-chain query for expiration data (liveUntilLedger) - Add getCurrentLedger() for expiration calculations - Update indexer client to use OWNERSHIP_TRANSFER_STARTED event type - Hybrid data sourcing: indexer for event detection, on-chain for expiration - Add comprehensive unit tests (T015-T027) and integration tests - Update spec files with indexer schema discovery notes Note: Indexer does not store live_until_ledger, so expiration is queried on-chain via get_pending_owner() contract method. * feat(adapter-stellar): implement two-step ownership transfer initiation (Phase 4) Add support for initiating two-step ownership transfers with expiration: - Update assembleTransferOwnershipAction() to include live_until_ledger parameter - Update transferOwnership() to require expirationLedger with client-side validation - Add boundary condition check (expirationLedger == currentLedger is invalid per FR-020) - Add specific error messages per FR-018 - Add INFO logging for transfer initiation per NFR-004 - Update AccessControlService interface for two-step transfer support * feat(adapter-stellar): implement ownership acceptance (Phase 5) Add acceptOwnership() method to complete the two-step ownership transfer flow. Pending owners can now accept transfers before expiration. - Add assembleAcceptOwnershipAction() in actions.ts - Add acceptOwnership() method in service.ts with INFO logging - Add comprehensive tests for acceptance flow (T039-T048) - Mark Phase 5 tasks complete in tasks.md * chore: rename spec from 001 to 006-stellar-ownable-support Fix spec numbering to follow existing sequence (001-005 already used). Rename directory and update all internal references. * docs(spec): mark Phase 6 feature detection tasks complete Phase 6 (User Story 4: Detect Ownable Contract Features) was already implemented. Verified all tests pass and marked tasks T049-T056 complete. * fix(adapter-stellar): add blockHeight to ownership transfer query The GraphQL query for OWNERSHIP_TRANSFER_STARTED was missing the blockHeight field, causing the fallback logic at line 609 to fail. When ledger was falsy, parseInt(undefined, 10) returned NaN. * feat(adapter-stellar): complete Phase 7 polish for two-step Ownable support Phase 7 Polish & Cross-Cutting Concerns: - Enhanced module-level JSDoc in index.ts with two-step Ownable documentation - Added @example sections to service methods (getOwnership, transferOwnership, acceptOwnership) - Added integration tests for full two-step transfer workflow - Added edge case tests (boundary conditions, network errors) - Added performance benchmark tests (NFR-001/NFR-002/NFR-003) - Created changeset for minor version bump Bug Fixes: - Fixed missing admin field validation in queryPendingOwnershipTransfer (returns null instead of invalid OwnershipTransferStartedEvent with empty previousOwner) - Fixed missing GraphQL error check for completion query response (now throws OperationFailed instead of silently treating errors as no completion) Documentation: - Updated quickstart.md with correct field names and imports - Marked all Phase 7 tasks complete in tasks.md * fix(adapter-stellar): remove redundant null check in readPendingOwner
1 parent 56eb3fc commit 94bc4b4

File tree

22 files changed

+5079
-54
lines changed

22 files changed

+5079
-54
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@openzeppelin/ui-builder-adapter-stellar': minor
3+
'@openzeppelin/ui-builder-types': minor
4+
---
5+
6+
feat(adapter-stellar): add two-step Ownable support with ledger-based expiration
7+
8+
Implements OpenZeppelin Stellar Ownable two-step ownership transfer pattern:
9+
10+
**New Features:**
11+
12+
- `getOwnership()` now returns ownership state (owned/pending/expired/renounced) with pending transfer details
13+
- `transferOwnership()` supports expiration ledger parameter for two-step transfers
14+
- `acceptOwnership()` allows pending owners to complete ownership transfer
15+
- `getCurrentLedger()` helper to get current ledger sequence for expiration calculation
16+
- `validateExpirationLedger()` validation helper for client-side expiration checks
17+
- `hasTwoStepOwnable` capability flag in feature detection
18+
19+
**Type Extensions:**
20+
21+
- Added `OwnershipState` type for ownership states
22+
- Added `PendingOwnershipTransfer` interface for pending transfer details
23+
- Extended `OwnershipInfo` with `state` and `pendingTransfer` fields
24+
- Extended `AccessControlCapabilities` with `hasTwoStepOwnable` flag
25+
26+
**Indexer Integration:**
27+
28+
- Added `OWNERSHIP_TRANSFER_STARTED` event type support
29+
- Added `queryPendingOwnershipTransfer()` method to indexer client
30+
- Graceful degradation when indexer is unavailable
31+
32+
**Non-Functional:**
33+
34+
- Performance: Ownership queries < 3s, indexer queries < 1s, ledger queries < 500ms
35+
- Logging: INFO for ownership operations, WARN for indexer unavailability

packages/adapter-stellar/src/access-control/actions.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,26 +73,57 @@ export function assembleRevokeRoleAction(
7373
/**
7474
* Assembles transaction data for transferring ownership of a contract
7575
*
76+
* For two-step Ownable contracts, this initiates a transfer that must be accepted
77+
* by the pending owner before the expiration ledger.
78+
*
7679
* @param contractAddress The contract address
7780
* @param newOwner The new owner address
81+
* @param liveUntilLedger The ledger sequence by which the transfer must be accepted
7882
* @returns Transaction data ready for execution
7983
*/
8084
export function assembleTransferOwnershipAction(
8185
contractAddress: string,
82-
newOwner: string
86+
newOwner: string,
87+
liveUntilLedger: number
8388
): StellarTransactionData {
8489
logger.info(
8590
'assembleTransferOwnershipAction',
86-
`Assembling transfer_ownership action to ${newOwner}`
91+
`Assembling transfer_ownership action to ${newOwner} with expiration at ledger ${liveUntilLedger}`
8792
);
8893

89-
// Arguments for transfer_ownership(new_owner: Address)
94+
// Arguments for transfer_ownership(new_owner: Address, live_until_ledger: u32)
9095
// Note: args are raw values that will be converted to ScVal by the transaction execution flow
9196
return {
9297
contractAddress,
9398
functionName: 'transfer_ownership',
94-
args: [newOwner],
95-
argTypes: ['Address'],
99+
args: [newOwner, liveUntilLedger],
100+
argTypes: ['Address', 'u32'],
101+
argSchema: undefined,
102+
transactionOptions: {},
103+
};
104+
}
105+
106+
/**
107+
* Assembles transaction data for accepting a pending ownership transfer
108+
*
109+
* For two-step Ownable contracts, this completes a pending transfer initiated by
110+
* the current owner. Must be called by the pending owner before the expiration ledger.
111+
*
112+
* @param contractAddress The contract address
113+
* @returns Transaction data ready for execution
114+
*/
115+
export function assembleAcceptOwnershipAction(contractAddress: string): StellarTransactionData {
116+
logger.info(
117+
'assembleAcceptOwnershipAction',
118+
`Assembling accept_ownership action for ${contractAddress}`
119+
);
120+
121+
// accept_ownership() has no arguments - caller must be the pending owner
122+
return {
123+
contractAddress,
124+
functionName: 'accept_ownership',
125+
args: [],
126+
argTypes: [],
96127
argSchema: undefined,
97128
transactionOptions: {},
98129
};

packages/adapter-stellar/src/access-control/feature-detection.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ export function detectAccessControlCapabilities(
5454
// Detect Ownable
5555
const hasOwnable = OWNABLE_FUNCTIONS.required.every((fnName) => functionNames.has(fnName));
5656

57+
// Detect two-step Ownable (has accept_ownership function)
58+
const hasTwoStepOwnable = hasOwnable && functionNames.has('accept_ownership');
59+
5760
// Detect AccessControl
5861
const hasAccessControl = ACCESS_CONTROL_FUNCTIONS.required.every((fnName) =>
5962
functionNames.has(fnName)
@@ -69,14 +72,19 @@ export function detectAccessControlCapabilities(
6972
const verifiedAgainstOZInterfaces = verifyOZInterface(
7073
functionNames,
7174
hasOwnable,
72-
hasAccessControl
75+
hasAccessControl,
76+
hasTwoStepOwnable
7377
);
7478

7579
// Collect notes about detected capabilities
7680
const notes: string[] = [];
7781

7882
if (hasOwnable) {
79-
notes.push('OpenZeppelin Ownable interface detected');
83+
if (hasTwoStepOwnable) {
84+
notes.push('OpenZeppelin two-step Ownable interface detected (with accept_ownership)');
85+
} else {
86+
notes.push('OpenZeppelin Ownable interface detected');
87+
}
8088
}
8189

8290
if (hasAccessControl) {
@@ -99,6 +107,7 @@ export function detectAccessControlCapabilities(
99107

100108
return {
101109
hasOwnable,
110+
hasTwoStepOwnable,
102111
hasAccessControl,
103112
hasEnumerableRoles,
104113
supportsHistory,
@@ -113,26 +122,39 @@ export function detectAccessControlCapabilities(
113122
* @param functionNames Set of function names in the contract
114123
* @param hasOwnable Whether Ownable was detected
115124
* @param hasAccessControl Whether AccessControl was detected
125+
* @param hasTwoStepOwnable Whether two-step Ownable was detected
116126
* @returns True if verified against OZ interfaces
117127
*/
118128
function verifyOZInterface(
119129
functionNames: Set<string>,
120130
hasOwnable: boolean,
121-
hasAccessControl: boolean
131+
hasAccessControl: boolean,
132+
hasTwoStepOwnable = false
122133
): boolean {
123134
// If no OZ patterns detected, not applicable
124135
if (!hasOwnable && !hasAccessControl) {
125136
return false;
126137
}
127138

128-
// Verify Ownable optional functions (at least 2 of 3 should be present)
139+
// Verify Ownable optional functions
140+
// For two-step Ownable, require at least 3 of 4 optional functions
141+
// For basic Ownable, at least 2 of 3 (excluding accept_ownership)
129142
if (hasOwnable) {
130143
const ownableOptionalCount = OWNABLE_FUNCTIONS.optional.filter((fnName) =>
131144
functionNames.has(fnName)
132145
).length;
133146

134-
if (ownableOptionalCount < 2) {
135-
return false;
147+
if (hasTwoStepOwnable) {
148+
// Two-step Ownable should have at least 3 of 4 optional functions
149+
// (transfer_ownership, accept_ownership, renounce_ownership, and accept_ownership is guaranteed)
150+
if (ownableOptionalCount < 3) {
151+
return false;
152+
}
153+
} else {
154+
// Basic Ownable should have at least 2 of 3 optional functions
155+
if (ownableOptionalCount < 2) {
156+
return false;
157+
}
136158
}
137159
}
138160

packages/adapter-stellar/src/access-control/index.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,35 @@
44
* Exports access control functionality for Stellar (Soroban) contracts including
55
* capability detection, on-chain data reading, action assembly, validation, indexer client,
66
* and the AccessControlService.
7+
*
8+
* ## Two-Step Ownable Support
9+
*
10+
* This module provides full support for OpenZeppelin's two-step Ownable pattern:
11+
* - {@link StellarAccessControlService.getOwnership} - Returns ownership state (owned/pending/expired/renounced)
12+
* - {@link StellarAccessControlService.transferOwnership} - Initiates two-step transfer with expiration
13+
* - {@link StellarAccessControlService.acceptOwnership} - Accepts pending ownership transfer
14+
* - {@link getCurrentLedger} - Gets current ledger sequence for expiration calculation
15+
* - {@link validateExpirationLedger} - Validates expiration ledger before submission
16+
* - {@link readPendingOwner} - Reads pending owner info from on-chain state
17+
*
18+
* ## Action Assembly
19+
*
20+
* - {@link assembleGrantRoleAction} - Prepares grant_role transaction
21+
* - {@link assembleRevokeRoleAction} - Prepares revoke_role transaction
22+
* - {@link assembleTransferOwnershipAction} - Prepares transfer_ownership transaction with expiration
23+
* - {@link assembleAcceptOwnershipAction} - Prepares accept_ownership transaction
24+
*
25+
* ## Feature Detection
26+
*
27+
* - {@link detectAccessControlCapabilities} - Detects Ownable/AccessControl support
28+
* - `hasTwoStepOwnable` capability flag indicates two-step transfer support
29+
*
30+
* ## Indexer Client
31+
*
32+
* - {@link StellarIndexerClient} - Queries historical events and pending transfers
33+
* - {@link OwnershipTransferStartedEvent} - Pending transfer event from indexer
34+
*
35+
* @module access-control
736
*/
837

938
export * from './actions';

0 commit comments

Comments
 (0)