Nightly Mobile2Mobile is a React Native reference dapp that exercises mobile-to-mobile deep-link flows with the Nightly wallet across pluggable blockchain networks. It showcases the encrypted handshake, message signing, and transaction building needed to hand transactions off to the wallet.
- Node.js 20 or newer.
- Yarn 1.x (recommended) or npm that ships with Node 20.
- A working React Native CLI environment (Xcode + CocoaPods for iOS, Android Studio/SDK + Java for Android). Follow the official setup guide.
- Ruby >= 2.6 with Bundler for managing the CocoaPods toolchain (
Gemfile). - A Nightly mobile wallet build installed on the test device/emulator to consume the
nightly://scheme.
- Install JavaScript dependencies:
yarn install # or npm install - (iOS only) Install Ruby gems and CocoaPods:
bundle install (cd ios && bundle exec pod install)
This project reserves Metro port 8083 to avoid clashes with other React Native apps.
-
Start Metro
yarn start:8083 # or npm run start:8083 -
Launch a platform target (use a second terminal):
- Android:
yarn androidornpm run android - iOS:
yarn iosornpm run ios
For iOS on Apple Silicon, ensure you have installed pods with
arch -x86_64 bundle exec pod installif the simulator target still requires Rosetta. - Android:
When everything is set up correctly you will see the Network selector screen, allowing you to jump into whichever network-specific tooling is available.
Every session starts by minting a fresh X25519 keypair so the dapp and wallet can agree on an encrypted channel.
generateKeypair()fromsrc/utils/encryption.tswrapstweetnacl.box.keyPair(), returning a base58 public key and a raw secret key kept only in memory.- The public half is passed to the wallet as
dappEncryptionPublicKey; the secret half never leaves the app but is used after connect to derive the shared secret.
Example from src/screens/AptosScreen.tsx:
const [initialKeys] = useState(() => generateKeypair());
const [dappPublicKey, setDappPublicKey] = useState(initialKeys.publicKey58);
const [dappSecretKey, setDappSecretKey] = useState(initialKeys.secretKey);
// After the wallet replies we turn the two keys into a shared secret:
const shared = deriveSharedSecret(response.walletPub, dappSecretKey);
setWalletSharedKey(shared);Call generateKeypair() again whenever you want to reset the session (the sample does this on screen mount and when you tap Disconnect).
Tap Connect to start the Nightly handshake; the app pushes a nightly://v1/direct/connect URL to the wallet. For a full implementation, see the openWalletConnect helper in the network screen modules under src/screens/.
Payload → Wallet (IDappConnectRequest)
{
"network": "aptos",
"cluster": "mainnet",
"responseRoute": "nightlydapp:///api/v1/connect",
"appInfo": {
"name": "NightlyMobile2Mobile",
"icon": "https://placehold.co/64x64",
"url": "https://example.local"
},
"dappEncryptionPublicKey": "<dapp-public-key-base58>"
}network(required): one ofaptos,movement,sui, oriota(defined intypes.ts).cluster(required):mainnet,testnet, ordevnet; skip combos the target chain does not support.responseRoute(required): any deep link owned by your app; use your own scheme/host if you fork the sample.appInfo(required): wallet-facing metadata rendered in the approval sheet (seeAppInfoMeta).dappEncryptionPublicKey(required): base58 public key generated per session to derive the shared secret.
Response → App (WalletEncryptedResponse)
Wallet returns to the responseRoute with a base64 data param containing the WalletEncryptedResponse envelope:
{
"success": true,
"walletPub": "<wallet-public-key-base58>",
"nonce": "<nonce-base58>",
"payload": "<ciphertext-base64>"
}This envelope shape is identical across every supported network.
success: boolean indicating whether the wallet completed the flow.walletPub+ the stored secret key allow the dapp to derive the shared key.nonce: base58 nonce needed when decrypting the payload.payload: base64 ciphertext encrypted with the shared key.- Persist the shared key for subsequent encrypted requests.
Decrypted payload example (placeholders):
{
"activeAccount": {
"address": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"publicKey": "5oooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo"
}
}address: wallet address selected inside Nightly for the current network.publicKey: associated base58-encoded public key the dapp can use for verification.
Once connected, tapping Sign Message sends an encrypted signMessage request. Refer to the openWalletSignMessage helper in the network screen modules under src/screens/ for the full flow, including payload encryption and deep-link construction.
Payload → Wallet (IDappSignMessageRequest)
{
"network": "aptos",
"cluster": "mainnet",
"responseRoute": "nightlydapp:///api/v1/response",
"payload": "<ciphertext-base64>",
"nonce": "<nonce-base58>",
"dappEncryptionPublicKey": "<dapp-public-key-base58>",
"address": "<wallet-address>",
"appInfo": {
"name": "NightlyMobile2Mobile",
"icon": "https://placehold.co/64x64",
"url": "https://example.local"
}
}network(required): one of theNETWORKenum values (aptos, movement, sui, iota).cluster(required): choose a supported cluster for the selected network (mainnet,testnet,devnet).responseRoute(required): deep link Nightly should call after signing (sample config usesnightlydapp:///api/v1/response).dappEncryptionPublicKey(required): session key used to encrypt the inner payload.payload(required): encrypted message payload for the target network (IAptosDappSignMessageInnerPayload,IMovementDappSignMessageInnerPayload, etc.; seesrc/types/wallet.ts).nonce(required): base58 nonce used when encryptingpayload.address(required): wallet account the dapp expects the signature from.appInfo(required): conforms toAppInfoMeta.
Response → App (WalletEncryptedResponse)
Wallet responds through the same envelope (success, walletPub, nonce, payload).
Decoded envelope example (placeholders):
{
"success": true,
"walletPub": "<wallet-public-key-base58>",
"nonce": "<nonce-base58>",
"payload": {
"signature": "0xaaaaaaaa…"
}
}Return an error payload instead if the user rejects or the wallet cannot sign.
The Transfer button builds network-specific transactions and requests a signature bundle. Refer to the openWalletSignTransactions helper in the network screen modules under src/screens/ for the end-to-end link construction.
Payload → Wallet (IDappSignTransactionsRequest)
{
"network": "aptos",
"cluster": "mainnet",
"responseRoute": "nightlydapp:///api/v1/response",
"payload": "<ciphertext-base64>",
"nonce": "<nonce-base58>",
"dappEncryptionPublicKey": "<dapp-public-key-base58>",
"address": "<wallet-address>",
"appInfo": {
"name": "NightlyMobile2Mobile",
"icon": "https://placehold.co/64x64",
"url": "https://example.local"
}
}network(required): one of theNETWORKenum values.cluster(required): target cluster (mainnet,testnet,devnet) supported by the network.responseRoute(required): deep link to receive the signed results (sample config usesnightlydapp:///api/v1/response).dappEncryptionPublicKey(required): session key used to encrypt the transaction payload.payload(required): encrypted transaction payload for the selected network (seeIAptosDappSignTransactionsInnerPayload,IMovementDappSignTransactionsInnerPayload, etc. insrc/types/wallet.ts).options(optional): behaviour flags that adjust wallet-side handling.submit(optional; defaults tofalse): set totrueto have the wallet broadcast on success (Aptos, Movement, Sui, Iota).
nonce(required): base58 nonce paired with the encrypted payload.address(required): wallet account expected to sign.appInfo(required): conforms toAppInfoMeta.
Response → App (WalletEncryptedResponse)
Wallet responds through the same envelope (success, walletPub, nonce, payload). The decoded payload differs between networks and whether options.submit was set.
Submit enabled (placeholders):
{
"success": true,
"walletPub": "<wallet-public-key-base58>",
"nonce": "<nonce-base58>",
"payload": {
"hashes": "[\"<transaction-hash>\"]"
}
}Applies to Aptos, Movement, Sui, and IOTA when options.submit is true.
Sign only examples (placeholders):
Aptos / Movement
{
"success": true,
"walletPub": "<wallet-public-key-base58>",
"nonce": "<nonce-base58>",
"payload": {
"senderAuthenticator": "0xbbbbbbbb…",
"network": "movement"
}
}Sui
{
"success": true,
"walletPub": "<wallet-public-key-base58>",
"nonce": "<nonce-base58>",
"payload": {
"signedTransaction": {
"signature": "APWPNC8o…",
"transactionBlockBytes": "AAACAAhA…"
}
}
}IOTA
{
"success": true,
"walletPub": "<wallet-public-key-base58>",
"nonce": "<nonce-base58>",
"payload": {
"signedTransaction": {
"signature": "ACPOCq6BPFI…",
"bytes": "AAACAAhA…"
}
}
}Regardless of flow, the UI surfaces the last outgoing URL, the callback URL, and the decoded payload so you can validate each step. Use Disconnect to throw away the shared key and restart the handshake.
- App identity – Update
APP_INFOinsrc/utils/deeplink.tswith your app name, URL, and icon. The wallet surfaces this information during connection prompts. - URL scheme – Change the
nightlydappscheme if you fork the project. Update:- iOS:
ios/NightlyMobile2Mobile/Info.plist(CFBundleURLSchemes) - Android:
android/app/src/main/AndroidManifest.xml<data android:scheme> - JavaScript constants:
DAPP_LINK_BASEin each screen module (e.g. Aptos, Movement, or Sui)
- iOS:
- Network/cluster defaults – Edit
types.tsto change supported clusters, default network enumeration values, or initial addresses.
- If tapping Connect does nothing, confirm the Nightly wallet is installed and registered for the
nightly://scheme on the device/emulator. - Incoming URLs are rendered in the debug panel; if the
datafield is missing, verify the wallet returned base64 payloads and that the shared secret was derived (watch the Metro logs for warnings). - Transaction building requires network access to the configured fullnode. Ensure your simulator/device can reach the endpoint for the network you are testing.
- Clear Metro cache (
yarn start:8083 --reset-cache) if you change native deep-link configuration and the app does not pick it up immediately.