Sign & Verify a User Message On Chain

The idea here is you want to have a user sign an “intent” and verify that:

  1. The user (or their wallet address) did indeed sign that specific intent
  2. That the signature is from a specific time so you can check if its outdated

The message the user is actually signing contains:

  1. The intent (ex. “Withdraw 10.00 $FLOW from the treasury.“)
  2. Block ID that the user signed the message

We then use this message to verify both the intent and block id on chain.

javascript
		
			import { authenticate, block, config, currentUser, query, unauthenticate } from '@onflow/fcl';
import { cadence } from './verify';

config({
	'accessNode.api': 'https://rest-testnet.onflow.org',
	'discovery.wallet': 'https://fcl-discovery.onflow.org/testnet/authn'
});

async function signAndVerifyUserSignature(intent) {
	const { acctAddress, message, keyIds, signatures, signatureBlock } = await signMessage(intent);

	try {
		const isValid = await query({
			cadence,
			args: (arg, t) => [
				arg(acctAddress, t.Address),
				arg(intent, t.String),
				arg(message, t.String),
				arg(keyIds, t.Array(t.Int)),
				arg(signatures, t.Array(t.String)),
				arg(signatureBlock.toString(), t.UInt64)
			]
		});
		console.log({ isValid });
	} catch (e) {
		console.error(e);
	}
}

async function signMessage(intent) {
	const user = await authenticate();

	const latestBlock = await block(true);
	// message needs to be hex for it to be signed
	const intentHex = Buffer.from(intent).toString('hex');
	const message = `${intentHex}${latestBlock.id}`;
	const sig = await currentUser().signUserMessage(message);

	const keyIds = sig.map((s) => {
		return s.keyId.toString();
	});
	const signatures = sig.map((s) => {
		return s.signature;
	});

	return {
		acctAddress: user.addr,
		message,
		keyIds,
		signatures,
		signatureBlock: latestBlock.height
	};
}

const intent = 'Withdraw 10.00 $FLOW from the treasury.';
signAndVerifyUserSignature(intent);
		 
	

The following is the Cadence code imported in the above file:

cadence
		
			import Crypto

access(all) fun main(acctAddress: Address, intent: String, message: String, keyIds: [Int], signatures: [String], signatureBlock: UInt64): Bool {
  return verifySignature(acctAddress: acctAddress, intent: intent, message: message, keyIds: keyIds, signatures: signatures, signatureBlock: signatureBlock) != nil
}

// From: ZayVerifierV2 contract: https://contractbrowser.com/A.4c577a03bc1a82e0.ZayVerifierV2
//
// Explanation:
// Verifies that `acctAddress` is the one that signed the `message`, which contains `intent`,
// producing `signatures` with the `keyIds` (that are hopefully in its account, or its false)
// during `signatureBlock`
// Parameters:
// acctAddress: the address of the account we're verifying
// message: {intent}{blockId}
// keyIds: the keyIds that the acctAddress theoretically signed with
// signatures: the signature that was theoretically produced from the `acctAddress` signing `message` with `keyIds`.
// can be multiple because you can sign with multiple keys, thus creating multiple signatures.
// signatureBlock: when the signature was produced
//
// Returns:
// Whether or not this signature is valid
access(all) fun verifySignature(acctAddress: Address, intent: String, message: String, keyIds: [Int], signatures: [String], signatureBlock: UInt64): Bool {
  let keyList = Crypto.KeyList()
  let account = getAccount(acctAddress)

  let publicKeys: [[UInt8]] = []
  let weights: [UFix64] = []
  let signAlgos: [UInt] = []

  let uniqueKeys: {Int: Bool} = {}
  for id in keyIds {
      uniqueKeys[id] = true
  }
  assert(uniqueKeys.keys.length == keyIds.length, message: "Invalid duplicates of the same keyID provided for signature")

  var counter = 0
  var totalWeight = 0.0
  while (counter < keyIds.length) {
      let accountKey: AccountKey = account.keys.get(keyIndex: keyIds[counter]) ?? panic("Provided key signature does not exist")

      publicKeys.append(accountKey.publicKey.publicKey)
      let keyWeight = accountKey.weight
      weights.append(keyWeight)
      totalWeight = totalWeight + keyWeight

      signAlgos.append(UInt(accountKey.publicKey.signatureAlgorithm.rawValue))

      counter = counter + 1
  }

  // Why 999 instead of 1000? Blocto currently signs with a 999 weight key sometimes for non-custodial blocto accounts.
  // We would like to support these non-custodial Blocto wallets - but can be switched to 1000 weight when Blocto updates this.
  assert(totalWeight >= 999.0, message: "Total weight of combined signatures did not satisfy 999 requirement.")

  var i = 0
  for publicKey in publicKeys {
      keyList.add(
          PublicKey(
              publicKey: publicKey,
              signatureAlgorithm: signAlgos[i] == 2 ? SignatureAlgorithm.ECDSA_secp256k1  : SignatureAlgorithm.ECDSA_P256
          ),
          hashAlgorithm: HashAlgorithm.SHA3_256,
          weight: weights[i]
      )
      i = i + 1
  }

  // In verify we need a [KeyListSignature] so we do that here
  let signatureSet: [Crypto.KeyListSignature] = []
  var j = 0
  for signature in signatures {
      signatureSet.append(
          Crypto.KeyListSignature(
              keyIndex: keyIds[j],
              signature: signature.decodeHex()
          )
      )
      j = j + 1
  }

  counter = 0
  let signingBlock = getBlock(at: signatureBlock)!
  let blockId = signingBlock.id
  let blockIds: [UInt8] = []
  while (counter < blockId.length) {
      blockIds.append(blockId[counter])
      counter = counter + 1
  }

  // message: {intent}{blockId}
  let intentHex = String.encodeHex(intent.utf8)
  let blockIdHexStr: String = String.encodeHex(blockIds)

  // Matches the intent
  assert(
      intentHex == message.slice(from: 0, upTo: intentHex.length),
      message: "Failed to validate intent"
  )
  // Ensure that the message passed in is of the current block id...
  assert(
      blockIdHexStr == message.slice(from: intentHex.length, upTo: message.length),
      message: "Unable to validate signature provided contained a valid block id."
  )

  let signatureValid = keyList.verify(
      signatureSet: signatureSet,
      signedData: message.decodeHex(),
      domainSeparationTag: "FLOW-V0.0-user"
  )
  return signatureValid
}