In this tutorial, you will create a Decentralized ID (DID), then you will sign a contract using the private key tied to it. After the contract is signed, you will store proof about this on-chain.
- NodeJS 12
makeand eitherpythonorpython3- Download the project template and setup the environment as described in the readme.
- Flutter
- A sample Flutter project. Please follow their Test Drive page to create it. In the end, you'll have a simple counter application.
This sample project has a lib/main.dart file.
This is the file where you will work. Except for the imports, we will write our code into the _incrementcounter method, which is changed to async, as follows:
Future<void> _incrementCounter() async {
// our code comes here...
};First, you need access to the SDK in the code.
For this tutorial, you will use the Crypto, Layer-1, Layer-2, and Network modules from our stack.
The Typescript package is available on npmjs.com.
In Typescript you need to use multiple modules from the SDK (The Layer1 and Network modules are already included in the project template). Additional features can be accessed through other modules about which you can read here.
// Import the necessary modules from our SDK
import { Crypto, Layer1, Layer2, Network, NetworkConfig } from '@internet-of-people/sdk';To use our SDK in your Flutter Android application, you need to run our installer script first, that does the followings:
- It downloads the dynamic libraries you need and puts those files in the right place. Those files are required because the SDK's crypto codebase is implemented in Rust and uses Dart FFI.
- It adds our Dart SDK into your
pubspec.yamlfile.
You just have to run this under your project's root on your Linux or macOS (Windows is not yet supported):
curl https://raw.githubusercontent.com/Internet-of-People/iop-dart/master/tool/init-flutter-android.sh | shWhen the script is finished, the only remaining task is to import the SDK in the lib/main.dart.
import 'dart:convert';
import 'dart:typed_data';
// Import the necessary modules from our SDK
import 'package:iop_sdk/crypto.dart';
import 'package:iop_sdk/layer1.dart';
import 'package:iop_sdk/layer2.dart';
import 'package:iop_sdk/network.dart';- Interested how to create such a secure, persistent vault? Check out our Create a Secure Vault tutorial here.
- The gas passphrase and public key is the Hydra wallet's credential that pays for the actual on-chain transactions with testnet HYD.
// Configure the network and account settings
const network = Network.Testnet;
const unlockPassword = 'correct horse battery staple';
const gasVault = Crypto.Vault.create(Crypto.Seed.demoPhrase(), '', unlockPassword);
// Initialize the transaction sender's vault to send Layer-1 transactions
const parameters = new Crypto.HydraParameters(
Crypto.Coin.Hydra.Testnet,
0
);
Crypto.HydraPlugin.init(gasVault, unlockPassword, parameters);
// Get the address and the private interface from the vault's hydra plugin
const hydraPlugin = Crypto.HydraPlugin.get(gasVault, parameters);
const senderPrivate = hydraPlugin.priv(unlockPassword);
const senderAddress = hydraPlugin.pub.key(0).address;// Configure the network and account settings
final network = Network.TestNet;
final unlockPassword = 'correct horse battery staple';
final accountNumber = 0;
// Initialize the transaction sender's vault to send Layer-1 transactions
final gasVault = Vault.create(Bip39.DEMO_PHRASE, '', unlockPassword);
HydraPlugin.init(gasVault, unlockPassword, network, accountNumber);
// Get the address and the private interface from the vault's hydra plugin
final hydraPlugin = HydraPlugin.get(gasVault, network, accountNumber);
final senderPrivate = hydraPlugin.private(unlockPassword);
final senderAddress = hydraPlugin.public.key(accountNumber).address;- The Vault is a hierarchical deterministic key generator, a general purpose version of a Bitcoin HD wallet.
- You'll generate a human-readable seed phrase (a.k.a mnemonic word list, cold wallet) for recovery.
- If you are eager to know what these passwords are for, please check out our Create a Secure Vault tutorial here.
// YOU HAVE TO SAVE THE PASSPHRASE SECURELY!
const phrase = new Crypto.Bip39('en').generate().phrase;
// Create a new vault based on the BIP39 passphrase, password and unlock password
const vault = Crypto.Vault.create(
phrase,
'', // The 25th word of the passphrase
unlockPassword, // Encrypts the master seed
);// YOU HAVE TO SAVE THE PASSPHRASE SECURELY!
final phrase = Bip39('en').generatePhrase();
// Create a personal vault based on the BIP39 passphrase, password and unlock password
final vault = Vault.create(
phrase,
'', // The 25th word of the passphrase
unlockPassword, // Encrypts the master seed
);
To create a DID, you need to initialize the Morpheus plugin from the SDK.
The plugin enables the previously created vault to handle your DIDs.
The plugin consists of a public part accessible without a password.
The private interface requires the unlock password explicitly.
// Initialize the Morpheus plugin on your personal vault:
Crypto.MorpheusPlugin.init(vault, unlockPassword);
const morpheusPlugin = Crypto.MorpheusPlugin.get(vault);
// Select the first DID
const did = morpheusPlugin.pub.personas.did(0);
console.log("Using DID: ", did.toString());Outputs:
Using DID: did:morpheus:ezbeWGSY2dqcUBqT8K7R14xr
Note: to learn more about the Morpheus and other plugins, please visit our technical documentation in the SDK's repository.
// Initialize the Morpheus plugin (Layer-2 SSI) on your personal vault:
MorpheusPlugin.init(vault, unlockPassword);
final morpheusPlugin = MorpheusPlugin.get(vault);
// Select the first DID
final did = morpheusPlugin.public.personas.did(0);
print('Using DID: ${did.toString()}');Outputs:
Using DID: did:morpheus:ezbeWGSY2dqcUBqT8K7R14xr
Note: to learn more about the Morpheus plugin's public and private interfaces, please visit our technical documentation in the SDK's repository.
- When a DID is created, it has a default public key and DID document attached to it. These can act on behalf of the DID by signing related operations. This unmodified (keys untouched) DID Document is called an implicit DID Document.
- Signed data is similar to warranty tickets in a sense that it's not mandatory to keep it safe, until you have to prove that you have signed the contract.
// Acquire the default key
const keyId = did.defaultKeyId();
// The contract details
const contractStr = "A long legal document, e.g. a contract with all details";
const contractBytes = new Uint8Array(Buffer.from(contractStr));
// Acquire the plugin's private interface that provides you the sign interface
const morpheusPrivate = morpheusPlugin.priv(unlockPassword);
// The signed contract, which you need to store securely!
const signedContract = morpheusPrivate.signDidOperations(keyId, contractBytes);
console.log("Signed contract:", JSON.stringify({
content: Buffer.from(signedContract.content).toString('utf8'),
publicKey: signedContract.publicKey.toString(),
signature: signedContract.signature.toString(),
}, null, 2));Outputs:
Signed contract: {
"content": "A long legal document, e.g. a contract with all details",
"publicKey": "pez7aYuvoDPM5i7xedjwjsWaFVzL3qRKPv4sBLv3E3pAGi6",
"signature": "sez6sgyb4hPbD3UmSsp3MwAv6rAF2UTYA8V6WNR8ncdUUmLV2rv6ewZQvNrNvthos1TW7aXDRvss2RDPt7Mtr82nDK6"
}
Note: to learn more about the Morpheus plugin's public and private interfaces, please visit our technical documentation in the SDK's repository.
// Acquire the default key
final keyId = did.defaultKeyId();
// The contract details
final contractStr = 'A long legal document, e.g. a contract with all details';
final contractBytes = Uint8List.fromList(utf8.encode(contractStr)).buffer.asByteData();
// Acquire the plugin's private interface that provides you the signing interface
final morpheusPrivate = morpheusPlugin.private(unlockPassword);
// The signed contract, which you need to store securely!
final signedContract = morpheusPrivate.signDidOperations(keyId, contractBytes);
final signedContractJson = <String, dynamic>{
'content': utf8.decode(signedContract.content.content.buffer.asUint8List()), // you must use this Buffer wrapper at the moment, we will improve in later releases,
'publicKey': signedContract.signature.publicKey.value,
'signature': signedContract.signature.bytes.value,
};
print('Signed contract: ${stringifyJson(signedContractJson)}');Outputs:
Signed contract: {
"content": "A long legal document, e.g. a contract with all details",
"publicKey": "pez7aYuvoDPM5i7xedjwjsWaFVzL3qRKPv4sBLv3E3pAGi6",
"signature": "sez6sgyb4hPbD3UmSsp3MwAv6rAF2UTYA8V6WNR8ncdUUmLV2rv6ewZQvNrNvthos1TW7aXDRvss2RDPt7Mtr82nDK6"
}
Note: to learn more about the Morpheus plugin's public and private interfaces, please visit our technical documentation in the SDK's repository.
- The signed contract is hashed into a content ID that proves the content without exposing it.
- Hashing an object into a content ID is also called digesting.
- We allow partial masking when only parts of the object are digested see more about it here.
// The beforeProof (a.k.a. Proof of Existence) is generated by hashing the signed contract
const beforeProof = Crypto.digestJson(signedContract);
console.log("Proof of Existence:", beforeProof);Outputs:
Proof of Existence: cjuMiVbDzAf5U1c0O32fxmB4h9mA-BuRWA-SVm1sdRCfEw
// The beforeProof (a.k.a. Proof of Existence) is generated by hashing the signed contract
final beforeProof = digestJson(signedContractJson);
print('Proof of Existence: ${beforeProof.value}');Outputs:
Proof of Existence: cjuMiVbDzAf5U1c0O32fxmB4h9mA-BuRWA-SVm1sdRCfEw
A single SSI transaction consists of one or multiple SSI operations. Registering a hash - or Proof of Existence - is an example of such an operation.
- As you see in the example, you create operation attemps. We call those attempts, because even if the blockchain (layer-1) accepts the transaction, the layer-2 consensus mechanism might still reject it.
- When you send in a transaction with a Hydra account, the transaction has to contain a nonce, which is increased by one after each and every transaction.
- If you provide the ID of an existing block into the signed contents then you can also prove that the content was created after the timestamp of that block.
// Create the Layer-2 data structure
const morpheusBuilder = new Crypto.MorpheusAssetBuilder()
morpheusBuilder.addRegisterBeforeProof(beforeProof);
const morpheusAsset = morpheusBuilder.build();
// Initialize the Layer-1 API
const layer1Api = await Layer1.createApi(NetworkConfig.fromNetwork(network));
// Send the transaction on Layer-1
const txId = await layer1Api.sendMorpheusTx(senderAddress, morpheusAsset, senderPrivate);
console.log("Transaction ID: ", txId);Outputs:
Transaction ID: af868c9f4b4853e5055630178d07055cc49f2e5cd033687b2a91598a5d720e19
// Create the Layer-2 data structure
final morpheusAssetBuilder = new MorpheusAssetBuilder.create();
morpheusAssetBuilder.addRegisterBeforeProof(beforeProof);
final morpheusAsset = morpheusAssetBuilder.build();
// Initialize the Layer-1 API
final networkConfig = NetworkConfig.fromNetwork(network);
final layer1Api = Layer1Api.createApi(networkConfig);
// Send the transaction
final txId = await layer1Api.sendMorpheusTx(senderAddress, morpheusAsset, senderPrivate);
print('Transaction ID: $txId');Outputs:
Transaction ID: af868c9f4b4853e5055630178d07055cc49f2e5cd033687b2a91598a5d720e19
Even though the transaction was successfully sent, it takes some time until it is included in a block and accepted by the consensus mechanism. After sending the transaction, you can fetch its status both on layer-1 and layer-2.
If a transaction was accepted on
- layer-1, it was a valid Hydra transaction without any layer-2 consensus (e.g. its format is valid, fees are covered and a delegate forged it into a block)
- layer-2, it was also accepted as a valid SSI transaction
// Block confirmation time
const waitUntil12Sec = (): Promise<void> => {
return new Promise((resolve) => {
return setTimeout(resolve, 12*1000);
});
};
await waitUntil12Sec();
// Layer-1 transaction must be confirmed
let txStatus = await layer1Api.getTxnStatus(txId);
console.log("Tx status:", txStatus.get());
// Initialize the Layer-2 Morpheus API to query the transaction status
const layer2MorpheusApi = await Layer2.createMorpheusApi(NetworkConfig.fromNetwork(network));
let ssiTxStatus = await layer2MorpheusApi.getTxnStatus(txId);
console.log("SSI Tx status:", ssiTxStatus.get());Outputs:
Tx status: {
"id": "af868c9f4b4853e5055630178d07055cc49f2e5cd033687b2a91598a5d720e19",
"blockId": "0adae3bd423939959aa800339555a6a2816f7ca1efef343bd1ab05fda185ae1c",
"confirmations": 1,
...
}
SSI Tx status: true
// Block confirmation time
await Future.delayed(Duration(seconds: 12));
// Layer-1 transaction must be confirmed
final txStatus = await layer1Api.getTxnStatus(txId);
print('Tx status: ${txStatus.toJson()}');
// Initialize the Layer-2 Morpheus API to query the transaction status
final layer2Api = Layer2Api.createMorpheusApi(networkConfig);
final ssiTxStatus = await layer2Api.getTxnStatus(txId);
print('SSI Tx confirmed: $ssiTxStatus');Outputs:
Tx status: {
"id": "af868c9f4b4853e5055630178d07055cc49f2e5cd033687b2a91598a5d720e19",
"blockId": "0adae3bd423939959aa800339555a6a2816f7ca1efef343bd1ab05fda185ae1c",
"confirmations": 1,
...
}
SSI Tx status: true
The following steps allow you to prove the fact that you signed a contract when necessary.
-
Load and present the contents of the signed contract from your safe storage.
-
Anyone can calculate its content ID, by hashing the content of the signed contract.
// We assume that signedContract is in scope and available
const expectedContentId = Crypto.digestJson(signedContract);// We assume that signedContract is in scope and available
final expectedContentId = digestJson(signedContractJson);- The history of the content ID can be queried on layer-2 by using its API.
// Query the blockchain for the hash of the signed contract (Proof of Existence)
const history = await layer2MorpheusApi.getBeforeProofHistory(expectedContentId);
console.log("Proof history:", history);Outputs:
Proof history: {
"contentId": "cjuMiVbDzAf5U1c0O32fxmB4h9mA-BuRWA-SVm1sdRCfEw",
"existsFromHeight": 507997,
"queriedAtHeight": 508993
}
// Query the blockchain for the hash of the signed contract (Proof of Existence)
final history = await layer2Api.getBeforeProofHistory(expectedContentId);
print('Proof history: ${history.toJson()}');Outputs:
Proof history: {
"contentId": "cjuMiVbDzAf5U1c0O32fxmB4h9mA-BuRWA-SVm1sdRCfEw",
"existsFromHeight": 507997,
"queriedAtHeight": 508993
}
- This returns the blockheight, which you can use to check the timestamp (eg.: on the explorer) of the content ID. This means that the signature must have been created before being included in that block.
Congratulations, you've accomplished a lot by using our IOP stack. Don't forget, that if you need more detailed or technical information, visit the SDK's source code on GitHub (Typescript/Flutter) or contact us here.