Interfaces

Get familiar with ZKsync SSO interfaces

While the internal functionality can be quite complex, the external interfaces for developers are very simple. This provides some technical information on how these components can be used.

Auth Server

The entrypoint to the SDK is via the zksyncAccountConnector to connect to the Auth Server. This takes the auth domain, session, and basic app information (name, icon). The fully expanded type looks like:

type ZKsyncAccountConnectorOptions = {
  metadata?: {
    name: string,
    icon?: string,
  },
  session?: {
    expiresAt?: bigint | Date;
    feeLimit?: Limit,
    transfers?: TransferPolicy[];
    contractCalls?: CallPolicy[];
  },
  authServerUrl?: string;
};

This returns a WAGMI connector that can be used to perform wallet-like actions with the available account. All of the functionality is then exposed via WAGMI, making the ZKsync SSO account nearly indistinguishable from any other standard wallet provider!

Sessions

Sessions' combination of function selector and multiple layers of limits makes them effective security measures against unexpected behavior, but they must be configured correctly in order to be most specific to the expected use cases. The session interface is also included with the account and passkey creation interfaces so sessions can be created during any important user interactions.

Limit types

Limits are tracked either for a lifetime or a specific period on-chain. Not all sessions or constraints are required to have limits, but it's the only way to prevent mis-use. The limit amount provided is always an upper limit, where exceeding it for the value in question causes the limit to invalidate the transaction. The value being limited depends on the limit's use, the two places where limits are applied are for transfer value policies, which limit the amount in the value of the transaction directly. The other limit is the dynamic constraint, where the value is specified via a byte offset from the start of the transaction data.

(Limit period validation is currently WORK-IN-PROGRESS, period limits are currently skipped entirely) A single lifetime limit per value isn't expressive enough to support great use cases like subscriptions, so the limits also support the ability to reset the limit on a fixed period basis. If provided as part of the limit, the block.timestamp divisor for the limit to be enforced (eg: 60 for a minute, 86400 for a day, 604800 for a week, unset for lifetime).

type LimitType =
  "Unlimited" |
  "Lifetime" |
  "Allowance";

/**
 * Limit is the value tracked either for a lifetime or a period on-chain
 * @member limitType - Used to validate limit & period values (unlimited has no limit, lifetime has no period, allowance has both!)
 * @member limit - The limit is exceeded if the tracked value is greater than this over the provided period
 * @member period - The block.timestamp divisor for the limit to be enforced (eg: 60 for a minute, 86400 for a day, 604800 for a week, unset for lifetime)
 */
type Limit =
  bigint | // Resolves to "lifetime" limit
  { limit: bigint; period?: bigint } | // If period is set resolves to "allowance" limit, otherwise "lifetime"
  { limitType: "lifetime"; limit: bigint } |
  { limitType: "unlimited" } |
  { limitType: "allowance"; limit: bigint; period: bigint };

Transfer Policy

This is the simplest policy because there's the least amount of data to constrain. They are applied at the contract (address) level, so they can be great for preventing transfers with specific contracts or EOAs. They are only applied when the transaction data is less than 4 bytes and only look at the transaction value field.

They can also apply a max value that's independent of the value limit.

Only one transfer policy is available per targeted contract (per account), so they don't stack it overwrites if you apply a new policy to the same address.

/**
 * Simplified CallPolicy for transactions with less than 4 bytes of data
 * @member target - Only one policy per target per session (unique mapping from contractCalls)
 * @member maxValuePerUse - Will reject transaction if value is set above this amount
 * @member valueLimit - Validated from value
 */
type TransferPolicy = {
  target: Address;
  maxValuePerUse?: bigint;
  valueLimit?: Limit;
};

Call Policy

This is a much more dynamic policy than a transfer policy that can be specified at the contract (address) level, as well as the specific function selector. Again, this policy is unique per target + selector, so only one policy is applied per transaction.

/**
 * CallPolicy is a policy for a specific contract (address/function) call.
 * @member target - Only one policy per target per session (unique mapping)
 * @member function - Solidity function signature (e.g. "transfer(address,uint256)")
 * @member selector - Solidity function selector (the selector directly), also unique mapping with target
 * @member maxValuePerUse - Will reject transaction if value is set above this amount (for transfer or call)
 * @member valueLimit - If not set, then zero. If a number or a limit without a period, converts to a lifetime value. Also rejects transactions that have cumulative value greater than what's set here
 * @member constraints - Array of conditions with specific limits for performing range and logic checks (e.g. 5 > x >= 30) on the transaction data (not value!)
 */
type CallPolicy = {
  target: Address;
  valueLimit?: Limit;
  maxValuePerUse?: bigint;
  function?: string;
  selector?: Hash; // if function is not provided
  constraints?: Constraint[];
};

The powerful part of call policies are the list of constraints, which can apply multiple conditional logic statements to individual values within the transaction data. These conditions are checked in order, so any condition failure (not unconstrained) will cause the validation to fail. The index of the constraint provides the offset from the start of the transaction data so depending on the data layout any static data can be validated. Each condition track it's own limit, making this approach flexible enough to support adding limits to individual arguments to contract function calls.

/**
 * Common logic operators to used combine multiple constraints
 */
type ConstraintCondition =
  "Unconstrained" |
  "Equal" |
  "Greater" |
  "Less" |
  "GreaterEqual" |
  "LessEqual" |
  "NotEqual";

/**
 * Constraint allows performing logic checks on any binary word (bytes32) in the transaction.
 * This can let you set spend limits against functions on specific contracts
 * @member index - The location of the start of the data in the transaction. This is not the index of the constraint within the containing array!
 * @member condition - The kind of check to perform (None, =, >, <, >=, <=, !=)
 * @member refValue - The value to compare against (as bytes32)
 * @member limit - The limit to enforce on the parsed value (from index)
 */
type Constraint = {
  index: number;
  condition?: ConstraintCondition;
  refValue?: Hash;
  limit?: Limit;
};

This design was inspired by Smart Sessions. For those looking to configure sessions to restrict access to functions, understanding the storage layout of the sessions will help when setting the function selector and limits.

Again, the split between call policy and transfer policy means that only one policy will be enforced per account transaction, and it will be determined based on the amount of data provided.


Made with ❤️ by the ZKsync Community