Soulbound Token (SBT) contract implementing Self's identity verification system. Designed with the one dapp β one SBT model where each dApp deploys their own SBT contract for isolated, privacy-preserving identity verification.
# Clone and setup
git clone https://github.com/selfxyz/self-sbt.git
cd self-sbt
forge install && pnpm install
# Compile and test
forge build
forge test
# Deploy using manual pipeline
# See DEPLOYMENT.md for complete instructionsThe one dapp β one SBT model ensures:
- Isolated Identity Verification: Each dApp has its own SBT contract with separate nullifier spaces
- No Cross-dApp Tracking: Users can't be linked across different applications
- dApp-Specific Policies: Each deployment can have custom validity periods and governance rules
- Granular Privacy Control: Users prove identity to individual applications without revealing cross-platform activity
- One SBT per User: Each address can only have one active token per dApp
- Anti-Replay Protection: Each nullifier can only be used by its original owner
- Configurable Validity: Owner-controlled expiry periods
- Soulbound: Non-transferable via ERC5192 standard
- Owner Controls: Burn capability and validity period management
The contract owner (typically the dApp) can:
- Set Validity Period: Customize token expiry duration for their use case
- Burn Tokens: Remove user SBTs when necessary (abuse, violations, etc.)
- Transfer Ownership: Change contract control as needed
// Set custom validity period (e.g., 30 days for short-term verification)
sbtContract.setValidityPeriod(30 days);
// Burn a specific user's token
sbtContract.burnSBT(tokenId);
// Transfer ownership
sbtContract.transferOwnership(newOwner);graph TD
A[Zero-Knowledge Proof] --> B[Identity Hub Verification]
B --> C{Nullifier Used?}
C -->|No| D{User Has SBT?}
C -->|Yes| E{User Has SBT?}
D -->|No| F[MINT New SBT]
D -->|Yes| G[UPDATE Expiry]
E -->|No| H[MINT New SBT]
E -->|Yes| I{Owner Match?}
I -->|Yes| J[UPDATE Expiry]
I -->|No| K[REVERT]
The contract handles four scenarios based on nullifier usage and receiver SBT ownership:
| Nullifier Status | Receiver Has SBT | Action | Description |
|---|---|---|---|
| NEW | NO | π’ MINT | First-time mint: Create new SBT for receiver |
| NEW | YES | π‘ UPDATE | Edge case: Different passport for same address |
| USED | NO | π RECOVER/REVERT | Recover burned token or revert if still active |
| USED | YES | π CHECK OWNER | Verify if nullifier owner matches receiver |
| Token Owner | Action | Description |
|---|---|---|
| address(0) | π’ RECOVER | Token was burned, recover to new address |
| Active owner | π΄ REVERT | Token still active, ask admin to burn first |
| Nullifier Owner | Receiver | Action | Description |
|---|---|---|---|
| Same as receiver | Any | π‘ UPDATE | Valid: Same user refreshing with their nullifier |
| Different from receiver | Any | π΄ REVERT | Invalid: User trying to use someone else's nullifier |
The SBT contract supports two distinct recovery scenarios through different mechanisms:
Scenario: User loses passport but retains wallet access
Solution: Direct re-verification (no admin action needed)
- User obtains new passport (generates new nullifier)
- User proves identity with new nullifier to same wallet
- Case 2 triggers: NEW nullifier + HAS SBT = update existing token expiry
Scenario: User loses wallet access but retains same passport
Solution: Admin burn + token recovery
- User reports lost wallet to admin
- Admin calls
burnSBT(tokenId)to burn existing SBT - User proves identity with same nullifier to new wallet
- Case 3 triggers: USED nullifier + NO SBT + burned token = recovery
- Same token ID gets minted to new address
- Permanent Nullifier Binding: Each nullifier permanently maps to one token ID throughout entire lifecycle
- Admin-Mediated Recovery: All recovery requires explicit admin intervention for security
- Token ID Persistence: Lost wallet recovery preserves original token ID
- Automatic Prevention: Active tokens cannot be hijacked (Case 3 reverts if token still owned)
// Admin burns user's SBT for recovery
sbtContract.burnSBT(tokenId);
// Check if nullifier can be recovered
bool canRecover = sbtContract.isNullifierUsed(nullifier) &&
sbtContract.getTokenIdByAddress(userAddress) == 0;Known Limitation: Nullifier Ambiguity Attack
Due to the zero-knowledge nature of the system, there is no cryptographic way to distinguish between:
- Same person renewing expired passport (legitimate Case 2)
- Different person targeting existing wallet (potential attack)
Both scenarios result in multiple nullifiers mapping to the same token ID. This creates a theoretical attack vector where:
- Attacker triggers Case 2 to link their nullifier to victim's token
- Attacker requests admin to burn the token
- Attacker recovers the token to their own wallet via Case 3
Mitigation Strategies:
- Admin Due Diligence: Implement robust identity verification before processing burn requests
- User Education: Document that sharing wallet addresses reduces security
- Monitoring: Track unusual patterns in Case 2 triggers and recovery requests
- Future Enhancement: Consider hierarchical identity systems for cryptographic continuity
This limitation is inherent to privacy-preserving identity systems and represents the classic tradeoff between privacy and verifiable identity continuity.
import { SelfSBTV2 } from "./SelfSBTV2.sol";
contract MyDApp {
SelfSBTV2 public immutable sbtContract;
modifier requireValidSBT(address user) {
uint256 tokenId = sbtContract.getTokenIdByAddress(user);
require(tokenId != 0, "No SBT found");
require(sbtContract.isTokenValid(tokenId), "SBT expired");
_;
}
function restrictedFunction() external requireValidSBT(msg.sender) {
// Only verified users can access
}
}// Check user verification status
async function isUserVerified(userAddress) {
const tokenId = await contract.getTokenIdByAddress(userAddress);
if (tokenId === 0) return false;
return await contract.isTokenValid(tokenId);
}
// Get user SBT details
async function getUserSBT(userAddress) {
const tokenId = await contract.getTokenIdByAddress(userAddress);
if (tokenId === 0) return null;
const [isValid, expiry, validityPeriod] = await Promise.all([
contract.isTokenValid(tokenId),
contract.getTokenExpiry(tokenId),
contract.getValidityPeriod(),
]);
return { tokenId, isValid, expiry, validityPeriod };
}SelfSBTV2 includes a deployment pipeline that handles scope generation and contract deployment using TypeScript and Foundry.
Deploy using the TypeScript scope calculator and Foundry:
# 1. Calculate scope value
cd ts-scripts && pnpm install && pnpm run dev
# 2. Deploy with Foundry using placeholder scope value
export PLACEHOLDER_SCOPE="0x..." # Use value from step 1
forge script script/DeployV2.s.sol:DeployV2 --rpc-url $RPC_URL --broadcast
# 3. Set actual scope on deployed contract
# (Use the contract's setScope function with calculated value)# Quick test deployment for development
forge script script/DeployV2.s.sol:DeployV2 --rpc-url $RPC_URL --broadcast
# This uses placeholder scope - suitable for testing only
# For production, use GitHub Actions workflowOWNER_ADDRESS- Contract owner addressVERIFICATION_CONFIG_ID- Verification config (bytes32)SCOPE_SEED- Scope identifier from your frontend Self SDK- For Celo networks, hub addresses and RPC URLs are predefined
For complete deployment instructions, troubleshooting, and advanced configuration options, see DEPLOYMENT.md.
- Nullifier Binding: Each nullifier permanently links to a specific token within this dApp's scope
- Owner Protection: Prevents cross-user nullifier theft
- Configurable Expiry: Owner-controlled validity periods for different use cases
- Soulbound: Immutable ownership after minting
- Privacy Isolation: No linkability across different dApp deployments
# Run all tests
forge test -vv
# Test specific functionality
forge test --match-test "test_VerifySelfProof_Case"
forge test --match-test "test_BurnSBT"
forge test --match-test "test_SetValidityPeriod"Test Coverage: 22 tests covering all 4 logic cases, owner functions, signature verification, and edge cases
RegisteredNullifier(): Thrown when nullifier is already used inappropriatelyERC5192Locked(): Thrown when attempting to transfer soulbound tokensInvalidValidityPeriod(): Thrown when setting validity period to zeroOwnable: caller is not the owner: Thrown when non-owner tries to call owner functions
MIT License