Integration manual with the JavaScript SDK
In this tutorial, we’ll create a sample web app that supports TON Connect 2.0 authentication. It will allow for signature verification to eliminate the possibility of fraudulent identity impersonation without agreement establishment between parties.
Documentation links
- @tonconnect/sdk documentation
- Wallet-application message exchange protocol
- Tonkeeper implementation of wallet side
Prerequisites
In order for connectivity to be fluent between apps and wallets, the web app must make use of manifest that is accessible via wallet applications. The prerequisite to accomplish this is typically a host for static files. For example, say if a developer wants to make use of GitHub pages, or deploy their website using TON Sites hosted on their computer. This would therefore mean their web app site is publicly accessible.
Getting wallets support list
To increase the overall adoption of TON Blockchain, it is necessary that TON Connect 2.0 is able to facilitate a vast number of application and wallet connectivity integrations. Of late and of significant importance, the ongoing development of TON Connect 2.0 has allowed for the connection of the Tonkeeper, TonHub, MyTonWallet and other wallets with various TON Ecosystem Apps. It is our mission to eventually allow for the exchange of data between applications and all wallet types built on TON via the TON Connect protocol. For now, this is realized by providing the ability for TON Connect to load an extensive list of available wallets currently operating within the TON Ecosystem.
At the moment our sample web app enables the following:
- loads the TON Connect SDK (library meant to simplify integration),
- creates a connector (currently without an application manifest),
- loads a list of supported wallets (from wallets.json on GitHub).
For learning purposes, let's take a looks at the HTML page described by the following code:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script src="https://unpkg.com/@tonconnect/sdk@latest/dist/tonconnect-sdk.min.js" defer></script> <!-- (1) -->
</head>
<body>
<script>
window.onload = async () => {
const connector = new TonConnectSDK.TonConnect(); // (2)
const walletsList = await connector.getWallets(); // (3)
console.log(walletsList);
}
</script>
</body>
</html>
If you load this page in browser and look into console, you may get something like that:
> Array [ {…}, {…} ]
0: Object { name: "Tonkeeper", imageUrl: "https://tonkeeper.com/assets/tonconnect-icon.png", aboutUrl: "https://tonkeeper.com", … }
aboutUrl: "https://tonkeeper.com"
bridgeUrl: "https://bridge.tonapi.io/bridge"
deepLink: undefined
embedded: false
imageUrl: "https://tonkeeper.com/assets/tonconnect-icon.png"
injected: false
jsBridgeKey: "tonkeeper"
name: "Tonkeeper"
tondns: "tonkeeper.ton"
universalLink: "https://app.tonkeeper.com/ton-connect"
According to TON Connect 2.0 specifications, wallet app information always makes use of the following format:
{
name: string;
imageUrl: string;
tondns?: string;
aboutUrl: string;
universalLink?: string;
deepLink?: string;
bridgeUrl?: string;
jsBridgeKey?: string;
injected?: boolean; // true if this wallet is injected to the webpage
embedded?: boolean; // true if the DAppis opened inside this wallet's browser
}
Button display for various wallet apps
Buttons may vary according to your web application design. The current page produces the following result:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script src="https://unpkg.com/@tonconnect/sdk@latest/dist/tonconnect-sdk.min.js" defer></script>
<style>
body {
width: 1000px;
margin: 0 auto;
font-family: Roboto, sans-serif;
}
.section {
padding: 20px; margin: 20px;
border: 2px #AEFF6A solid; border-radius: 8px;
}
#tonconnect-buttons>button {
display: block;
padding: 8px; margin-bottom: 8px;
font-size: 18px; font-family: inherit;
}
.featured {
font-weight: 800;
}
</style>
</head>
<body>
<div class="section" id="tonconnect-buttons">
</div>
<script>
const $ = document.querySelector.bind(document);
window.onload = async () => {
const connector = new TonConnectSDK.TonConnect();
const walletsList = await connector.getWallets();
let buttonsContainer = $('#tonconnect-buttons');
for (let wallet of walletsList) {
let connectButton = document.createElement('button');
connectButton.innerText = 'Connect with ' + wallet.name;
if (wallet.embedded) {
// `embedded` means we are browsing the app from wallet application
// we need to mark this sign-in option somehow
connectButton.classList.add('featured');
}
if (!wallet.bridgeUrl && !wallet.injected && !wallet.embedded) {
// no `bridgeUrl` means this wallet app is injecting JS code
// no `injected` and no `embedded` -> app is inaccessible on this page
connectButton.disabled = true;
}
buttonsContainer.appendChild(connectButton);
}
};
</script>
</body>
</html>
Please note the following:
- If the web page is displayed through a wallet application, it sets the property
embedded
option totrue
. This means it is important to highlight this login option because it's most commonly used. - If a specific wallet is built using only JavaScript (it has no
bridgeUrl
) and it hasn't set propertyinjected
(orembedded
, for safety), then it is clearly inaccessible and the button should be disabled.
Connection without the app manifest
In the instance the connection is made without the app manifest, the script should be changed as follows:
const $ = document.querySelector.bind(document);
window.onload = async () => {
const connector = new TonConnectSDK.TonConnect();
const walletsList = await connector.getWallets();
const unsubscribe = connector.onStatusChange(
walletInfo => {
console.log('Connection status:', walletInfo);
}
);
let buttonsContainer = $('#tonconnect-buttons');
for (let wallet of walletsList) {
let connectButton = document.createElement('button');
connectButton.innerText = 'Connect with ' + wallet.name;
if (wallet.embedded) {
// `embedded` means we are browsing the app from wallet application
// we need to mark this sign-in option somehow
connectButton.classList.add('featured');
}
if (wallet.embedded || wallet.injected) {
connectButton.onclick = () => {
connectButton.disabled = true;
connector.connect({jsBridgeKey: wallet.jsBridgeKey});
};
} else if (wallet.bridgeUrl) {
connectButton.onclick = () => {
connectButton.disabled = true;
console.log('Connection link:', connector.connect({
universalLink: wallet.universalLink,
bridgeUrl: wallet.bridgeUrl
}));
};
} else {
// wallet app does not provide any auth method
connectButton.disabled = true;
}
buttonsContainer.appendChild(connectButton);
}
};
Now that the above process has been carried out, status changes are being logged (to see whether TON Connect works or not). Showing the modals with QR codes for connectivity is out of the scope of this manual. For testing purposes, it is possible to use a browser extension or send a connection request link to the user’s phone by any means necessary (for example, using Telegram). Note: we haven't created an app manifest yet. At this time, the best approach is to analyze the end result if this requirement is not fulfilled.
Logging in with Tonkeeper
In order to log into Tonkeeper, the following link is created for authentication (provided below for reference):
https://app.tonkeeper.com/ton-connect?v=2&id=3c12f5311be7e305094ffbf5c9b830e53a4579b40485137f29b0ca0c893c4f31&r=%7B%22manifestUrl%22%3A%22null%2Ftonconnect-manifest.json%22%2C%22items%22%3A%5B%7B%22name%22%3A%22ton_addr%22%7D%5D%7D
When decoded, the r
parameter produces the following JSON format:
{"manifestUrl":"null/tonconnect-manifest.json","items":[{"name":"ton_addr"}]}
Upon clicking the mobile phone link, Tonkeeper automatically opens and then closes, dismissing the request. Additionally, the following error appears in the web app page console:
Error: [TON_CONNECT_SDK_ERROR] Can't get null/tonconnect-manifest.json
.
This means the application manifest must be available for download.
Connection with using app manifest
Starting from this point forward, it is necessary to host user files (mostly tonconnect-manifest.json) somewhere. In this instance we’ll use the manifest from another web application. This however is not recommended for production environments, but allowed for testing purposes.
The following code snippet:
window.onload = async () => {
const connector = new TonConnectSDK.TonConnect();
const walletsList = await connector.getWallets();
const unsubscribe = connector.onStatusChange(
walletInfo => {
console.log('Connection status:', walletInfo);
}
);
Must be replaced with this version:
window.onload = async () => {
const connector = new TonConnectSDK.TonConnect({manifestUrl: 'https://ratingers.pythonanywhere.com/ratelance/tonconnect-manifest.json'});
window.connector = connector; // for experimenting in browser console
const walletsList = await connector.getWallets();
const unsubscribe = connector.onStatusChange(
walletInfo => {
console.log('Connection status:', walletInfo);
}
);
connector.restoreConnection();
In the newer version above, the storing connector
variable in the window
was added so it is accessible in the browser console. Additionally, the restoreConnection
so users don’t have to log in on each web application page.
Logging in with Tonkeeper
If we decline our request from wallet, The result that appeared in the console will Error: [TON_CONNECT_SDK_ERROR] Wallet declined the request
.
Therefore, the user is able to accept the same login request if the link is saved. This means the web app should be able to handle the authentication decline as non-final so it works correctly.
Afterwards, the login request is accepted and is immediately reflected in the browser console as follows:
22:40:13.887 Connection status:
Object { device: {…}, provider: "http", account: {…} }
account: Object { address: "0:b2a1ec...", chain: "-239", walletStateInit: "te6cckECFgEAAwQAAgE0ARUBFP8A9..." }
device: Object {platform: "android", appName: "Tonkeeper", appVersion: "2.8.0.261", …}
provider: "http"
The results above take the following into consideration:
- Account: information: contains the address (workchain+hash), network (mainnet/testnet), and the wallet stateInit that is used for public key extraction.
- Device: information: contains the name and wallet application version (the name should be equal to what was requested initially, but this can be verified to ensure authenticity), and the platform name and supported features list.
- Provider: contains http -- which allows all requests and responses between the wallet and web applications to be served over the bridge.
Logging out and requesting TonProof
Now we have logged into our Mini App, but... how does the backend know that it is the correct party? To verify this we must request the wallet ownership proof.
This can be completed only using authentication, so we must log out. Therefore, we run the following code in the console:
connector.disconnect();
When the disconnection process is complete, the Connection status: null
will be displayed.
Before the TonProof is added, let's alter the code to show that the current implementation is insecure:
let connHandler = connector.statusChangeSubscriptions[0];
connHandler({
device: {
appName: "Uber Singlesig Cold Wallet App",
appVersion: "4.0.1",
features: [],
maxProtocolVersion: 3,
platform: "ios"
},
account: {
/* TON Foundation address */
address: '0:83dfd552e63729b472fcbcc8c45ebcc6691702558b68ec7527e1ba403a0f31a8',
chain: '-239',
walletStateInit: 'te6ccsEBAwEAoAAFcSoCATQBAgDe/wAg3SCCAUyXuiGCATOcurGfcbDtRNDTH9MfMdcL/+ME4KTyYIMI1xgg0x/TH9Mf+CMTu/Jj7UTQ0x/TH9P/0VEyuvKhUUS68qIE+QFUEFX5EPKj+ACTINdKltMH1AL7AOjRAaTIyx/LH8v/ye1UAFAAAAAAKamjF3LJ7WtipuLroUqTuQRi56Nnd3vrijj7FbnzOETSLOL/HqR30Q=='
},
provider: 'http'
});
The resulting lines of code in the console are almost identical to those displayed when the connection was initiated in the first place. Therefore, if the backend doesn't perform user authentication correctly as expected, a way to test if it is working correctly is required. To accomplish this, it is possible to act as the TON Foundation within the console, so the legitimacy of token balances and token ownership parameters can be tested. Naturally, the provided code doesn't change any variables in the connector, but the user is able to use the app as desired unless that connector is protected by the closure. Even if that is the case, it is not difficult to extract it using a debugger and coding breakpoints.
Now that the authentication of the user has been verified, let's proceed to writing the code.
Connection using TonProof
According to TON Connect’s SDK documentation, the second argument refers to the connect()
method which contains a payload that will be wrapped and signed by the wallet. Therefore, the result is new connection code:
if (wallet.embedded || wallet.injected) {
connectButton.onclick = () => {
connectButton.disabled = true;
connector.connect({jsBridgeKey: wallet.jsBridgeKey},
{tonProof: 'doc-example-<BACKEND_AUTH_ID>'});
};
} else if (wallet.bridgeUrl) {
connectButton.onclick = () => {
connectButton.disabled = true;
console.log('Connection link:', connector.connect({
universalLink: wallet.universalLink,
bridgeUrl: wallet.bridgeUrl
}, {tonProof: 'doc-example-<BACKEND_AUTH_ID>'}));
};
Connection link:
https://app.tonkeeper.com/ton-connect?v=2&id=4b0a7e2af3b455e0f0bafe14dcdc93f1e9e73196ae2afaca4d9ba77e94484a44&r=%7B%22manifestUrl%22%3A%22https%3A%2F%2Fratingers.pythonanywhere.com%2Fratelance%2Ftonconnect-manifest.json%22%2C%22items%22%3A%5B%7B%22name%22%3A%22ton_addr%22%7D%2C%7B%22name%22%3A%22ton_proof%22%2C%22payload%22%3A%22doc-example-%3CBACKEND_AUTH_ID%3E%22%7D%5D%7D
Expanded and simplified r
parameter:
{
"manifestUrl":
"https://ratingers.pythonanywhere.com/ratelance/tonconnect-manifest.json",
"items": [
{"name": "ton_addr"},
{"name": "ton_proof", "payload": "doc-example-<BACKEND_AUTH_ID>"}
]
}
Next, the url address link is sent to a mobile device and opened using Tonkeeper.
After this process is complete, the following wallet-specific information is received:
{
"device": {
"platform": "android",
"appName": "Tonkeeper",
"appVersion": "2.8.0.261",
"maxProtocolVersion": 2,
"features": [
"SendTransaction"
]
},
"provider": "http",
"account": {
"address": "0:b2a1ecf5545e076cd36ae516ea7ebdf32aea008caa2b84af9866becb208895ad",
"chain": "-239",
"walletStateInit": "te6cckECFgEAAwQAAgE0ARUBFP8A9KQT9LzyyAsCAgEgAxACAUgEBwLm0AHQ0wMhcbCSXwTgItdJwSCSXwTgAtMfIYIQcGx1Z70ighBkc3RyvbCSXwXgA/pAMCD6RAHIygfL/8nQ7UTQgQFA1yH0BDBcgQEI9ApvoTGzkl8H4AXTP8glghBwbHVnupI4MOMNA4IQZHN0crqSXwbjDQUGAHgB+gD0BDD4J28iMFAKoSG+8uBQghBwbHVngx6xcIAYUATLBSbPFlj6Ahn0AMtpF8sfUmDLPyDJgED7AAYAilAEgQEI9Fkw7UTQgQFA1yDIAc8W9ADJ7VQBcrCOI4IQZHN0coMesXCAGFAFywVQA88WI/oCE8tqyx/LP8mAQPsAkl8D4gIBIAgPAgEgCQ4CAVgKCwA9sp37UTQgQFA1yH0BDACyMoHy//J0AGBAQj0Cm+hMYAIBIAwNABmtznaiaEAga5Drhf/AABmvHfaiaEAQa5DrhY/AABG4yX7UTQ1wsfgAWb0kK29qJoQICga5D6AhhHDUCAhHpJN9KZEM5pA+n/mDeBKAG3gQFImHFZ8xhAT48oMI1xgg0x/TH9MfAvgju/Jk7UTQ0x/TH9P/9ATRUUO68qFRUbryogX5AVQQZPkQ8qP4ACSkyMsfUkDLH1Iwy/9SEPQAye1U+A8B0wchwACfbFGTINdKltMH1AL7AOgw4CHAAeMAIcAC4wABwAORMOMNA6TIyx8Syx/L/xESExQAbtIH+gDU1CL5AAXIygcVy//J0Hd0gBjIywXLAiLPFlAF+gIUy2sSzMzJc/sAyEAUgQEI9FHypwIAcIEBCNcY+gDTP8hUIEeBAQj0UfKnghBub3RlcHSAGMjLBcsCUAbPFlAE+gIUy2oSyx/LP8lz+wACAGyBAQjXGPoA0z8wUiSBAQj0WfKnghBkc3RycHSAGMjLBcsCUAXPFlAD+gITy2rLHxLLP8lz+wAACvQAye1UAFEAAAAAKamjFyM60x2mt5eboNyOTE+5RGOe9Ee2rK1Qcb+0ZuiP9vb7QJRlz/c="
},
"connectItems": {
"tonProof": {
"name": "ton_proof",
"proof": {
"timestamp": 1674392728,
"domain": {
"lengthBytes": 28,
"value": "ratingers.pythonanywhere.com"
},
"signature": "trCkHit07NZUayjGLxJa6FoPnaGHkqPy2JyNjlUbxzcc3aGvsExCmHXi6XJGuoCu6M2RMXiLzIftEm6PAoy1BQ==",
"payload": "doc-example-<BACKEND_AUTH_ID>"
}
}
}
}
Let's verify the received signature. In order to accomplish this, the signature verification uses Python because it can easily interact with the backend. The libraries required to carry out this process are the pytoniq
and the pynacl
.
Next, it is necessary to retrieve the wallet's public key. To accomplish this, tonapi.io
or similar services are not used because the end result cannot be reliably trusted. Instead, this is accomplished by parsing the walletStateInit
.
It is also critical to ensure that the address
and walletStateInit
match, or the payload could be signed with their wallet key by providing their own wallet in the stateInit
field and another wallet in the address
field.
The StateInit
is made up of two reference types: one for code and one for data. In this context, the purpose is to retrieve the public key so the second reference (the data reference) is loaded. Then 8 bytes are skipped (4 bytes are used for the seqno
field and 4 for subwallet_id
in all modern wallet contracts) and the next 32 bytes are loaded (256 bits) -- the public key.
import nacl.signing
import pytoniq
import hashlib
import base64
received_state_init = 'te6cckECFgEAAwQAAgE0ARUBFP8A9KQT9LzyyAsCAgEgAxACAUgEBwLm0AHQ0wMhcbCSXwTgItdJwSCSXwTgAtMfIYIQcGx1Z70ighBkc3RyvbCSXwXgA/pAMCD6RAHIygfL/8nQ7UTQgQFA1yH0BDBcgQEI9ApvoTGzkl8H4AXTP8glghBwbHVnupI4MOMNA4IQZHN0crqSXwbjDQUGAHgB+gD0BDD4J28iMFAKoSG+8uBQghBwbHVngx6xcIAYUATLBSbPFlj6Ahn0AMtpF8sfUmDLPyDJgED7AAYAilAEgQEI9Fkw7UTQgQFA1yDIAc8W9ADJ7VQBcrCOI4IQZHN0coMesXCAGFAFywVQA88WI/oCE8tqyx/LP8mAQPsAkl8D4gIBIAgPAgEgCQ4CAVgKCwA9sp37UTQgQFA1yH0BDACyMoHy//J0AGBAQj0Cm+hMYAIBIAwNABmtznaiaEAga5Drhf/AABmvHfaiaEAQa5DrhY/AABG4yX7UTQ1wsfgAWb0kK29qJoQICga5D6AhhHDUCAhHpJN9KZEM5pA+n/mDeBKAG3gQFImHFZ8xhAT48oMI1xgg0x/TH9MfAvgju/Jk7UTQ0x/TH9P/9ATRUUO68qFRUbryogX5AVQQZPkQ8qP4ACSkyMsfUkDLH1Iwy/9SEPQAye1U+A8B0wchwACfbFGTINdKltMH1AL7AOgw4CHAAeMAIcAC4wABwAORMOMNA6TIyx8Syx/L/xESExQAbtIH+gDU1CL5AAXIygcVy//J0Hd0gBjIywXLAiLPFlAF+gIUy2sSzMzJc/sAyEAUgQEI9FHypwIAcIEBCNcY+gDTP8hUIEeBAQj0UfKnghBub3RlcHSAGMjLBcsCUAbPFlAE+gIUy2oSyx/LP8lz+wACAGyBAQjXGPoA0z8wUiSBAQj0WfKnghBkc3RycHSAGMjLBcsCUAXPFlAD+gITy2rLHxLLP8lz+wAACvQAye1UAFEAAAAAKamjFyM60x2mt5eboNyOTE+5RGOe9Ee2rK1Qcb+0ZuiP9vb7QJRlz/c='
received_address = '0:b2a1ecf5545e076cd36ae516ea7ebdf32aea008caa2b84af9866becb208895ad'
state_init = pytoniq.Cell.one_from_boc(base64.b64decode(received_state_init))
address_hash_part = state_init.hash.hex()
assert received_address.endswith(address_hash_part)
public_key = state_init.refs[1].bits.tobytes()[8:][:32]
# bytearray(b'#:\xd3\x1d\xa6\xb7\x97\x9b\xa0\xdc\x8eLO\xb9Dc\x9e\xf4G\xb6\xac\xadPq\xbf\xb4f\xe8\x8f\xf6\xf6\xfb')
verify_key = nacl.signing.VerifyKey(bytes(public_key))
After the sequencing code above is implemented, the correct documentation is consulted to check which parameters are verified and signed using the wallet key:
message = utf8_encode("ton-proof-item-v2/") ++
Address ++
AppDomain ++
Timestamp ++
Payload
signature = Ed25519Sign(
privkey,
sha256(0xffff ++ utf8_encode("ton-connect") ++ sha256(message))
)
Whereby the:
Address
denotes the wallet address encoded as a sequence:
workchain
: 32-bit signed integer big endian;hash
: 256-bit unsigned integer big endian;AppDomain
is the Length ++ EncodedDomainName
Length
uses a 32-bit value of utf-8 encoded app domain name length in bytesEncodedDomainName
idLength
-byte utf-8 encoded app domain nameTimestamp
denotes the 64-bit unix epoch time of the signing operationPayload
denotes a variable-length binary stringutf8_encode
produces a plain byte string with no length prefixes.
Let's reimplement this in Python. The endianness of some of the integers above is not specified, so several examples must be considered. Please refer to the following Tonkeeper implementation detailing some related examples: : ConnectReplyBuilder.ts.
received_timestamp = 1674392728
signature = 'trCkHit07NZUayjGLxJa6FoPnaGHkqPy2JyNjlUbxzcc3aGvsExCmHXi6XJGuoCu6M2RMXiLzIftEm6PAoy1BQ=='
message = (b'ton-proof-item-v2/'
+ 0 .to_bytes(4, 'big') + si.bytes_hash()
+ 28 .to_bytes(4, 'little') + b'ratingers.pythonanywhere.com'
+ received_timestamp.to_bytes(8, 'little')
+ b'doc-example-<BACKEND_AUTH_ID>')
# b'ton-proof-item-v2/\x00\x00\x00\x00\xb2\xa1\xec\xf5T^\x07l\xd3j\xe5\x16\xea~\xbd\xf3*\xea\x00\x8c\xaa+\x84\xaf\x98f\xbe\xcb \x88\x95\xad\x1c\x00\x00\x00ratingers.pythonanywhere.com\x984\xcdc\x00\x00\x00\x00doc-example-<BACKEND_AUTH_ID>'
signed = b'\xFF\xFF' + b'ton-connect' + hashlib.sha256(message).digest()
# b'\xff\xffton-connectK\x90\r\xae\xf6\xb0 \xaa\xa9\xbd\xd1\xaa\x96\x8b\x1fp\xa9e\xff\xdf\x81\x02\x98\xb0)E\t\xf6\xc0\xdc\xfdx'
verify_key.verify(hashlib.sha256(signed).digest(), base64.b64decode(signature))
# b'\x0eT\xd6\xb5\xd5\xe8HvH\x0b\x10\xdc\x8d\xfc\xd3#n\x93\xa8\xe9\xb9\x00\xaaH%\xb5O\xac:\xbd\xcaM'
After implementing the above parameters, if an attacker tries to impersonate a user and doesn't provide a valid signature, the following error will be displayed: nacl.exceptions.BadSignatureError: Signature was forged or corrupt
.
Next steps
When writing a dApp, the following should also be considered:
- after a successful connection is completed (either a restored or new connection), the
Disconnect
button should be displayed instead of severalConnect
buttons - after a user disconnects,
Disconnect
buttons will need to be recreated - wallet code should be checked, because
- newer wallet versions could place public keys in a different location and create issues
- the current user may sign in using another type of contract instead of a wallet. Thankfully, this will contain the public key in the expected location
Good luck and have fun writing dApps!